# Bot Crossing — your agent threads, as a colony
**[botcrossing.com](https://botcrossing.com)**
Every coding-agent thread on this machine is a little bot. They walk out of the ship, claim
a plot for their repo, and build something. When one needs you it stops and holds a `?` over
its head; click it and the thread opens back in whichever harness it came from.
It reads the harness's own files, on your own machine. Nothing is uploaded, there is no
account, and **it never writes to a harness at all** — `data/colony.json`, where the map lives,
is the only file it writes anywhere.
> **Status:** published as-is. I built this for myself and cannot promise to maintain it —
> issues and PRs are welcome but may go unanswered, and forking is an entirely reasonable
> thing to do. [CONTRIBUTING.md](CONTRIBUTING.md) sets out what to expect.
## Run it
```bash
npm install && npm run dev
```
Needs Node 22.13 or newer. `npm test` runs the suite.
`npm run dev` is the whole thing: the API lives inside the Vite dev server, so there is no
second process. For a built version, `npm start` (build + serve) or `npm run serve` if
`dist/` already exists. Binds to `127.0.0.1` by default, and answers only its own page — see
[Keeping it local](#keeping-it-local).
**macOS, Linux and Windows.** Opening a thread, revealing a folder and starting a new session
all go through a `harness://` deep link handed to the OS opener — `open(1)` on macOS,
`xdg-open` on Linux, ShellExecute on Windows. The scanning half was portable already. On Linux,
where a desktop app often is not installed, the scheme is checked first and a terminal running
the harness's own CLI opens instead when nothing answers it.
A setting, *Open threads in*, makes the terminal the first choice rather than the fallback, on all three.
## Which harnesses work
A **harness** is whatever actually runs your threads. Bot Crossing reads each one's local
session files through a small adapter, so support is per-harness and mostly a matter of
somebody writing that adapter.
| Harness | Status |
| --- | --- |
| **[Claude Code](https://claude.com/claude-code)** (Anthropic) | ✅ **Supported** — desktop app and CLI, including worktrees and live-process detection |
| **[Codex](https://developers.openai.com/codex/cli)** (OpenAI) | ✅ **Supported** — desktop, VS Code and CLI sessions, opened through `codex://` |
| **[OpenCode](https://opencode.ai)** | ✅ **Supported** — top-level sessions from its own store; no per-thread link to open |
| **[Antigravity CLI](https://antigravity.google)** (Google) | ✅ **Supported** — transcripts, opened through `antigravity://`. The successor to Gemini CLI, which Google stopped serving individual accounts on 18 June 2026 |
| **[Cursor](https://cursor.com)** (Anysphere) | ✅ **Supported** — agent transcripts; the composer/sidebar threads are not read yet |
| **[Hermes](https://github.com/opsmason/hermes)** | ✅ **Supported** — sessions per pilot profile. Lives in the terminal and chat apps, so there is no link to open |
| **[Kilo Code](https://kilocode.ai)** | ✅ **Supported** — top-level sessions; a thread opens as its repo folder in VS Code |
| [Amp](https://ampcode.com) (Sourcegraph) | ⬜ Not yet |
| [Aider](https://aider.chat) | ⬜ Not yet |
| [Goose](https://block.github.io/goose/) (Block) | ⬜ Not yet |
| [Qwen Code](https://github.com/QwenLM/qwen-code) (Alibaba) | ⬜ Not yet |
| [Amazon Q Developer CLI](https://aws.amazon.com/q/developer/) | ⬜ Not yet |
Every harness that is installed shows up at once — the colony is the union of all of them, and
a bot carries the name of the harness it belongs to.
### Adding one
One new file in `server/harnesses/`, one line in its `index.mjs`, and nothing else. The
interface is small and written down in full, along with the thread shape, the ground rules,
and how to find where a given harness keeps its sessions:
**→ [`server/harnesses/README.md`](server/harnesses/README.md)**
If you add one, a PR is very welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) first, which is
honest about how much support I can offer. If landing it means editing the scanner or anything
under `src/`, please mention that: it means the seam needs widening, and I would rather fix that
than have you work around it.
## What you are looking at
| In the colony | In your threads |
| --- | --- |
| One hex zone | One repo. Bigger repos claim more tiles — one per seven threads, grown as a contiguous blob from the middle outward. A zone stays where it is: see below |
| One bot + one building | One session |
| A bot with no building of its own | An errand that session has out right now — a subagent |
| How finished a building looks | How large its transcript is, on a log scale |
| Scaffolding | Somebody is at that site right now |
| Walking out of the ship | A thread that just appeared |
| Walking back into the ship | You archived it |
### A zone stays where it is
The map is only useful if you can learn it, so the layout is *sticky*. The previous
arrangement is an input to the next one: a repo that still needs the same number of tiles
keeps exactly the tiles it had, one that grew keeps them and claims neighbours, and one that
shrank gives back whatever it claimed most recently — so growing and shrinking again returns
a zone to precisely the shape it started in. Only a repo that has never been placed is
placed at all, and it takes the innermost tiles still free.
A zone's origin is its **root** tile rather than the centre of the tiles it happens to hold,
so gaining one does not drag its buildings, its bots and its name sideways; the new tile
simply appears alongside. And the arrangement is written to `data/colony.json`, so the map
you have learned survives a reload — including for a repo whose last thread you archived,
which comes back to the same ground when you start a new one.
**And a zone can be carried.** Press and hold one, drag it, drop it on ground that is free: the
footprint follows the cursor as rounded tiles, green where it will land and red where it will not.
A zone other zones were leaning on used to refuse to move at all, because the allocator throws the
whole layout away when the colony breaks into islands. Now the drop stands and whatever it cut
loose is slid back into contact, each stranded group moving as one body so an arrangement you made
by hand is not reshuffled around you. Two things are still refused: a drop that lands nowhere near
the colony, and one that would leave the zone holding a corner of the map on its own.
**So can a bot.** Press and hold one — a session's bot or an errand's — and it dangles from the
cursor; let go anywhere and it walks back to its own site. Put it far away and it runs: the longer
the way home, the faster it goes. A bot has no say in where it lives, so dropping one never
rearranges anything.
The version before this was a pure function of the thread counts: one session appearing
anywhere changed the sort order, the order decided the tiles, and the whole colony re-laid
itself out. A zone you were watching could jump to the far side of the map because a
*different* repo gained a thread.
### The deck has to clear the ground
Inside the colony the terrain is gentle but not flat — it runs from about -0.3 to +0.24 on
the Moon, and half again as far on Mars. A deck's top face has to sit above the roughest
ground any plot can be dealt, or the ground comes through it: the slab reads as sunken, props
standing on it are buried to the waist, and every surface where the two meet tears. So the
slab is 0.45 tall, and everything on a plot — buildings, kerbs, clutter, boots — is measured
from that one number rather than from a height of its own.
Ground scatter has to miss the plots, and the order it happens in is the whole problem: the
world is built before the first roster arrives, so at that moment there are no plots to miss.
Boulders and trees would end up under decks laid on top of them afterwards, poking through in
fragments. The scatter is therefore rebuilt whenever a zone's footprint changes — cheap,
because it no longer drags the terrain mesh along with it.
Bot behaviour is a **strict precedence** rather than a set of independent flags, so a
thread can only ever be doing one thing. First match wins:
| Signal | What the bot does | Badge |
| --- | --- | --- |
| Errored in the last 3 days | Slumps, red eyes, fault light stutters | `!` |
| Running now | Hammers away at its building, sparks fly | `⚒` |
| PR merged | Jumps, confetti, heart eyes | `✓` |
| Unread | **Stops and waits on you** | `?` |
| Nothing for 3 days | Sits down and sleeps, `z` bubbles | — |
| Anything else | Potters around its plot | — |
An error ages out on the same three-day clock as everything else. Without that, a run that
failed a month ago would hold its bot at `!` forever and keep its whole repo from folding away.
Only the states that want something from you get a badge. With most of a real thread list
sitting quiet, a symbol over every bot buries the one `?` that actually matters.
Zone names follow the same rule: a plot shows its name only while somebody there is working,
waiting or stuck. Everything else is nameless until you point at it. The plate itself is just
text over a soft halo with a small accent dot — no panel, no outline.
## Getting about
Bots route rather than drift. Buildings and the landing pad are rasterised into a
navigation grid whenever the roster changes, and the bots walk it with A*, string-pulled
afterwards so they take the corners they actually need instead of a visible staircase.
Two guarantees, deliberately independent:
- **Routing** finds a way *around* a building, including threading the gaps between a ring of
them. Blocking radii are the building's bounding radius trimmed a little plus the
bot's own width — the trim is what keeps those gaps walkable.
- **Collision** is applied to every step whether or not a path is being followed. Routing can
fail — a site walled in between polls, a path budget that has not caught up — and walking
through a wall must not be what happens when it does. Blocked head-on, a bot slides
along the obstacle instead of stopping dead.
Measured over the live colony: 288 path legs, **0 crossing a building**, and **0 penetrations
across 78,000 agent-frames**. A typical path costs 6 µs (most are a clear straight shot and
skip the search); the worst frame when a poll invalidates every route at once is 0.6 ms.
They also push each other apart, so a busy plot is a crowd rather than a pile. That spacing
is measured against the widest thing a bot wears — the helmet, at 0.95 units — because
holding a crowd at less than that is a crowd standing *inside* itself, which is what the first
version did at 0.72. Arrival is derived from the same number and is deliberately larger: an
bot that had to get closer than its neighbours would let it could never finish arriving,
and would shoulder at the crowd for as long as its thread existed.
Standing spots are placed clear of the building's own blocked radius rather than at a fixed
distance from it, and checked against the navigation grid — a spot inside a wall is a spot the
bots can never reach, and the bot sent to it walks at that wall forever. Measured over
the live colony: **68 of 68 bots settled, nobody closer than 1.14 units, no standing spot
left inside an obstacle.** As a last resort a bot that has been blocked for six seconds
adopts the ground it got to instead of pushing on.
## Clicking one
All of the chrome is one panel on the right — the name, the counts, and every repo. There
is no top bar and no strip along the bottom: a colony is a place, and a place reads better
without a frame around it.
A bot, a zone's deck, the name plate over it, or a repo in that list — all four drill
into the same repo. Picking somebody is also picking the zone they are standing on.
**The repo**, at the top, whether or not anybody is selected:
- **New conversation** (`C`) starts a fresh thread in that folder. It is the same
`claude://code/new?folder=…` deep link Finder's "New Claude Code Session Here" quick
action uses, so the desktop app opens an empty session with the repo as its workspace —
nothing is resumed and nothing is written.
- **Finder** (Explorer on Windows) opens the folder, **Copy path** copies it.
- Underneath, everything running in that repo, whoever wants something first. Clicking one
flies to its bot and selects it.
**The thread**, when a bot is selected, in a card parked **beside that bot**
rather than in the panel: its face, title, worktree, branch, model, last activity, and how
far along its building is. The answer to "what is this one doing" belongs next to the thing
you clicked, so the card follows its bot around the screen — preferring its right,
flipping to its left rather than sliding under the sidebar, and never leaving the window.
It is moved with a transform rather than with `left`/`top`, the one geometric change a
browser makes without touching layout, so following a walking bot costs nothing.
The camera follows the bot you picked, which is on by default — you clicked it to watch it, and a
working bot rarely stands still long enough to be watched otherwise. Panning, orbiting and zooming
all still work while it follows; deselecting stops it, and the crosshair on the card turns it off
for good if you would rather the view stayed put.
- **Open** hands the thread back to whichever harness owns it and its app comes forward. On a
Linux box with no desktop app to answer the deep link, a terminal opens with the CLI resuming
the session instead.
*Open threads in: Terminal*, in settings, asks for that every time, on any of the three. On Windows a
thread that is already running in a terminal gets that window fronted instead of a second copy of
itself imported into the desktop app.
- **Viewed** (`V`), on a thread that is asking for you, puts its hand down. The harness only
counts a thread as read once it has been focused in its own app, so one you answered in a
terminal waves for good. This records when you looked, and the thread starts asking again the
moment it does something newer.
- **Archive** retires the thread *here*: the bot walks back up the ramp and boards the
ship. Nothing is written to the harness — see [Keeping it local](#keeping-it-local). A thread
you archive in the harness's own app goes home on the next poll too, because the scan reads
that flag. Changed your mind? The toast offers Undo for a few seconds, and each zone keeps a
folded list of what was archived here, with Restore on every thread. One archived in the
harness's own app is restored there.
- **Hide** takes a whole repo off the map without touching a single thread. It comes back from
the list at the foot of the sidebar, onto the same ground it left.
**Quiet repos fold away** by default: a repo where every thread has been silent for three days
comes off the map, which on a machine with a few harnesses and a lot of checkouts is most of
them. The sidebar keeps a count and a way back, and a repo returns to its own ground the moment
a thread in it wakes up. *Hide dormant repos* in settings turns it off.
Only one button in the panel is ever the accent colour: whichever action is the immediate
one. `Esc` steps outward a notch at a time — the thread first, then its zone.
Opening uses `claude://claude.ai/epitaxy/<local_…>`, which *navigates* the desktop app to a
thread it already has. `claude://resume` is the fallback for threads that only exist as a CLI
transcript: it *imports* the transcript, which creates a second untitled session and rewrites
the `.jsonl`, so it is only ever used when there is nothing to navigate to.
Archiving is the colony's own bookkeeping, with one writer: the browser owns
`data/colony.json` and PUTs it whole, and the server only reads the archive list to mark the
threads it names. A save from a tab holding older state is refused with what is on disk, and
the tab merges its own edits on top, so it cannot drop an archive another tab made since it
loaded.
Nothing is ever written to your Claude Code data. Archiving here used to set `isArchived` on
the desktop app's own record, but the app serves from the copy it loaded at launch, so the
thread stayed in its list until a restart; [DECISIONS.md](DECISIONS.md) has the rest. The
folder buttons only ever hand a path to `open`.
The deep links above are the **Claude Code adapter's** business, not the colony's — another
harness plugs its own in, and a harness with no deep link simply greys the button out. See
[`server/harnesses/README.md`](server/harnesses/README.md).
Plots are keyed by the folder's *name*, which is all the colony needs to draw one, so the path
is read back off the threads standing there. Where a name is ambiguous — `~/workspaces/1/foo`
and `~/workspaces/2/foo`, which is what you get keeping parallel copies instead of worktrees —
it grows leftward until it is not, and you get `1/foo` and `2/foo` on separate ground. Only
names that actually collide change, because the name is also the key your saved layout is
stored under and disambiguating everything would move every plot on the map. A repo that has
moved or gone since the last scan fails at the server rather than handing `open` a dead path.
A linked git worktree stands on its repository's plot whichever harness made it: the checkout's
own `.git` file names the repository, so a Codex worktree under `~/.codex/worktrees` lands on the
same ground as the repo instead of a second plot beside it. The Claude desktop app's scratch
sessions each live in a folder of their own, and share one plot called **Scratch**.
Name plates are hit-tested in screen space rather than raycast: they are billboarded in the
vertex shader, so a raycast would test the quad where it was authored rather than where it
ended up. That test deliberately ignores whether the plate is currently faded in — pointing
at where a quiet project's name *would* be is exactly what makes it appear.
## Getting around
Navigation is Google Earth's, including both of the things that make Earth feel like Earth:
| | |
| --- | --- |
| **Drag** | Grabs the ground. The point under your cursor stays pinned there for the whole drag |
| **Press and hold** a bot or a zone | Picks it up; a bot walks (or runs) home when you let go, a zone stays where you drop it |
| **Right-drag** (or ⌃ / ⇧ / middle-drag) | Tilt and rotate. Up tilts toward the horizon |
| **Scroll** | Zooms **at the cursor**, not at the screen centre |
| **Two fingers** | Pinch to zoom, drag to pan — both anchored between your fingers |
| **Arrows**, **+** / **−** | Move and zoom from the keyboard |
| **Orbit mode** (rail button, or `O`) | Earth's auto-rotate: a slow sweep around whatever is centred, about two minutes a revolution. It drives the heading only, so you can keep dragging, tilting and zooming while it runs |
Both anchors are exact, not approximate: a 200px drag holds its grabbed point to 0.00 world
units, and dollying 62→37 holds the cursor's point to 0.01.
Optionally, letting go can ease the *angle* back to the nearest clean isometric heading after
a couple of seconds. Your position and zoom are never touched — going home on its own would
fight you; tidying the angle after you stop does not. It is off by default, because a camera
that moves when you did not ask it to is startling the first time you meet it. Turn it on
under **View → Return to isometric**.
## Keys
| Key | Does |
| --- | --- |
| `H` / `⌘\` | **Hide every panel.** The colony still reads: status lives above the bots' heads |
| `S` | Settings |
| `N` | Fly to the next bot waiting on you — errors first, then replies, longest-waiting first |
| `J` | Next bot |
| `K` | Next zone |
| `B` | Busiest bot that is building or stuck on you, then the next busiest on each press. Idle and sleeping bots are skipped |
| `/` / `F` | Find a thread in the open zone by its title or worktree |
| `R` | Rename the selected bot. The name lives only in this colony; the harness keeps its own |
| `Enter` / `A` | Open / archive the selected thread |
| `V` | Mark the selected thread viewed, so it stops asking |
| `C` | New conversation in the open zone's folder |
| `O` | Orbit mode |
| `Tab` / `⇧Tab` | Next / previous planet |
| `L` | Next time of day |
| `M` | Mute |
| `P` | Screenshot |
| `0` | Reset the view |
| `Esc` | Deselect, and close the zone sidebar |
| `?` | Help |
`N`, `J` and `B` stay inside the open zone; hold shift to look everywhere *else* instead.
With no zone open, all three mean the whole colony. Repeated `N` or `B` presses walk the ranking
as it stood on the first press, so bots do not get skipped or revisited as activity shifts;
click somebody or change zone and the next press starts from the top again.
## Planets and light
Twelve worlds and a full day/night cycle you can scrub, let run, or set to **Live**, which
follows this machine's own clock so the colony's light matches the light out of your window.
| World | What it is |
| --- | --- |
| **Luna**, **Mars**, **Terra** | The originals: airless, rusty, earthlike |
| **Shoreline** | Green ground, white sand, and the sea along one side |
| **Archipelago** | An island the colony's own shape, a few more on the horizon, water everywhere else |
| **Canopy** | Jungle: broad trees, parrots, butterflies, lakes in the hollows, fireflies after dark |
| **Dune** | Sand seas in long wind-bent ridges, cacti, heat haze |
| **Frost** | Snow, snow-dusted pines, frozen lakes, ravens |
| **Harvest** | Autumn: gold light, red leaves on the air, a pond |
| **Blossom** | Cherry trees in full bloom, petals on everything |
| **Cinder** | Ash, embers, and lava pooling in every crater |
| **Aerie** | A floating island, exactly the colony's shape, over a sea of cloud |
A planet is a bag of colours and a few switches — terrain, scatter, sky, water, weather,
wildlife, ambience and lighting all read from the same preset, so a thirteenth world is a data
change rather than a code change. The one structural knob is `shape`: a handful of named
ways of bending the same height field — `island` drops the ground into the sea past a
radius, `coast` past a line, `dunes` lays ridges over everything. Worlds with water but no
sea get their lakes for free: the crater bowls that were already there dip below the
waterline and fill. On Cinder the water is lava, which is the same shader with a glow.
Archipelago's coast is the colony's too: land is the hex footprint plus a beach, and the
sea bed drops away past it, so claiming a tile pushes the waterline out and folding a repo
away lets the water back in. The terrain is rebuilt when that happens.
Aerie goes further: its ground exists only where the colony does. Every hex cell a
repo holds gets a jagged plug of rock hung beneath it (`world/hexisland.js`) — a hex prism
that twists and shrinks to a point, stalactites trailing below, the big ones under the
middle of the island — and the terrain shader throws away every fragment more than a
frayed grass margin from any cell. Claim a tile and the island grows a tile; fold a repo
away and a chunk of rock drops off the edge. Vines hang from whichever edges face open sky.
### Water
Written from scratch rather than taken from a library, and built on `MeshPhysicalMaterial`
through `onBeforeCompile` so it sits inside the scene's lighting — the sun and its shadow map,
the sky-as-HDRI environment, fog, tone mapping, the world curve — for free. Gerstner swell
in the vertex stage; turquoise shallows fading to deep blue, foam bands that breathe along
every shore, sun sparkle pushed past the bloom threshold, and a moon sheen after dark.
Nothing reads the depth buffer. How far the bottom is beneath the surface — which steers the
colour ramp, the opacity and the foam — is baked per vertex from the terrain height field
into an `aDepth` attribute when the plane is built, and costs nothing per frame. The price is
that the shoreline is only as fine as the plane's vertex spacing, which the noise-wobbled
foam edges hide.
Anything can throw a ring on it: a fish landing, a gull skimming, a drone passing low. Rings
are a small uniform array, so sixteen can be in flight for one draw call.
### On a phone
Below 600px the sidebar is a sheet along the bottom: it peeks its brand row and counts,
and a tap or a drag on that row pulls it up over the colony. Opening a repo pulls it up;
picking a bot drops it, and the thread card docks above the peek instead of chasing
its bot round a screen that small. The rail becomes a strip along the top, settings
and help fill the screen, everything keeps clear of the safe area, and a first run on a
phone starts on the Low preset. One finger drags the ground, two pinch to zoom.
`public/dev-mobile.html` (untracked) frames the app at phone size for checking this in a
desktop browser.
### Never getting stuck
The rules the bots move by, which are the ones games settled on:
- **Routes are planned on a grid rasterised with a small travel radius**, so the gaps
between buildings stay routes. A shoulder through a wall for a step is the price. When a
goal is unreachable or the search runs out, the route goes to the closest point reached
rather than nowhere — a straight line into a wall is how bots used to jam.
- **Keep-out is for standing, not walking.** Every building, crate, boulder and scaffold
pole carries a keep radius (`navigation.js`), and a bot that has arrived is
pushed out of it and put back on it after every nudge. Walkers only collide with the grid.
- **Separation only pushes sideways** while walking, and never harder than a lean. A shove
straight back is how a stream going one way cancels itself and mills on the spot.
- **Ghosting.** A bot that gets nowhere for most of a second stops colliding with
the crowd for a couple of seconds, walks through it, and asks for a fresh route.
- **The wobble check.** Every second, total motion is compared with net progress. Half a
metre of the one for none of the other is a glitch, whatever caused it: the bot is
moved to the nearest clear, uncrowded ground and left alone for a moment.
- Whoever owns a leg — a wander, a spot round a building, a walk to a site — gives it up
after a second of no progress and picks somewhere else; a walk that creeps its last
metre for ten seconds counts as arrived.
On a first load every bot comes out of the ship's airlock one at a time, the ones
waiting on you first, and walks down the ramp to its site — a trickle over a minute or so,
never a scrum at the foot of the ramp.
### Checking on it
A thread that is running hammers at its building, walks round it, and hammers from another
side. Every half-minute or so it also stops, gets a folding phone out, flips it open, reads
it for a few seconds — rows of text scrolling by on both panels — folds it shut and puts it
away. The phone is the shape the folding iPhone is expected to be: a 4:3 slab that opens
along its long edge into something wider than it is tall, with a pear on the back. It is
one of what will be several such props; they live in `agents/props.js` and are picked per
check, so more can be added and cycled without touching the bots.
The pose is KayKit's idle with the left arm turned up to hold it, and the hammering is
KayKit's hammering with the swing moved from the wrist to the shoulder. Both are done at
bake time by `TWEAKS` in `agents/crew.js`: per clip, per bone, a `scale` on how far it
strays from a reference keyframe and an `offset` Euler on top, optionally ramped in or out
to make a raise or a lower. `window.__rebakeCrew(tweaks)` (dev only) bakes again with a
different table, which is how the arm was posed: a small coordinate search over the six
joint angles for a hand in front of the visor.
### Wildlife
Birds, butterflies, fish and the cargo drones — the life that carries no information, and is
there so the world is never completely still. Each kind is one `InstancedMesh`, with the
motion that is per-vertex (wing flap, rotor spin, tail wag) done in the vertex shader and only
positions on the CPU. Birds and drones cast shadows, which is a surprising amount of the
Animal Crossing feel: a shadow sliding across the deck before the bird crosses the frame.
Birds are a boids-lite flock — gulls on the shore worlds, parrots in the jungle, crows over
the desert and the snow, swallows over the meadows — and gulls dip to the water. Fish launch
out of it on a ballistic arc every few seconds and ripple it on the way back in. Drones are
the colony's own: they sit on a ring around the lander, pick an active site, fly a crate out,
hover, drop it, and come home. They exist on every world.
### Sound
Off with `M`, on by default but silent until the first click — browsers insist. Three layers:
- **Beds** per world, cross-faded when the planet changes, each with a day and a night gain.
- **Events** on their own clocks: a gull every eight to twenty-five seconds, an owl at night,
distant thunder every couple of minutes. They run independently of the beds and of each
other, so the mix never settles into a loop you can learn.
- **Positional sources**, the way a game engine does it. A site being hammered at, the lander's
hum, a drone going past, the lapping along the nearest stretch of shore — each is a
`PannerNode` with inverse-distance attenuation from the camera, so zooming in on a plot is
turning it up. At most ten voices at once, handed to the nearest sources with fades rather
than cuts.
One sound is allowed to interrupt: a thread that has just started waiting on you gets two soft
notes, once, from where its bot is standing.
Every one of the forty sounds has a procedural fallback on the Web Audio API — filtered noise
for wind and surf, FM glides for birdsong, blips for crickets — so the game is fully audible
with no audio files at all. Real recordings override by name through
`public/audio/manifest.json` ([how](public/audio/README.md)). Samples are gitignored: sample
libraries license their sounds for use *in* a work, not for redistribution on their own.
### The look
Three things borrowed from Animal Crossing, all optional under **Look** in settings:
- **The world curves away.** Ground ahead of the camera drops off with the square of how far
ahead it is, so the horizon bows and the colony reads as a small round world. It is done in
*world* space, keyed off the point the camera is looking at, rather than in view space the
way most curved-world shaders do it — because the shadow pass renders the same geometry
from the sun, and a bend expressed in the main camera's view space would put every shadow
somewhere its building is not. It is wired into every material at once by patching three's
own `project_vertex` chunk, so buildings, bots, terrain, scatter and water all bend without
knowing about it; the handful of shaders that project by hand (billboards, particles) call
the same function. Picking bends the same way on the CPU, so a far bot is clicked
where it was drawn.
- **Cumulus** on the sky dome: the dome direction projected onto a flat sheet overhead — which
is what foreshortens clouds toward the horizon — with a second noise sample nudged toward
the sun for a lit side and a shaded underside. Drifts, and refreshes the environment map as
it goes.
- **A colour grade** on the finished frame: a touch of saturation, a warm or cool cast per
world, lifted blacks with a little blue in them so shade reads as shade rather than as a
hole, a gentle S-curve, and a wide soft vignette.
Weather is the same idea one level down: each world lists what drifts through its air —
dust, pollen, petals, snow, embers, ash, leaves, sea spray, fireflies after dark — and the
particle system spawns it in a ring around the camera.
### The sky is the HDRI
Rather than shipping an HDR environment map, the sky shader **is** the environment map. A
second copy of the sky dome — sharing the same uniforms, so it is always the sky you are
actually standing under — is rendered into a prefiltered radiance map with `PMREMGenerator`
and bound as `scene.environment`. That is what gives metal something to reflect and
dielectrics a directional ambient, and it is why the colony changes *character* through the
day rather than just changing brightness: at dusk on Mars the panels pick up the sky, on the
Moon they stay hard and neutral.
It regenerates only when the sky has actually moved, and never more than a few times a second.
Measured cost: **0.16 ms/frame**. Off on Potato and Low; the intensity is a slider.
Materials are properly PBR underneath it. Roughness and metalness are looked up per atlas
cell, so a single merged building geometry holds painted panel, brushed metal and
photovoltaic glass and each behaves correctly — the ten building recipes never had to learn
about PBR.
### The sun is not overhead
The solar arc is tilted, so noon puts the sun 54° above the horizon and off to one side rather
than at the zenith. That is load-bearing rather than decorative: a sun directly overhead puts
`N·L` at zero on every vertical wall in the colony, and they go black with only ambient to
catch. The old procedural buildings were curved enough to hide it; a kit of flat-walled
modules is not.
### HDR and bloom
Eye colours, lamps, windows, crop rows and plot kerbs are all authored above 1.0 so the bloom
pass picks them out. The threshold is deliberately high (0.92) — only those things clear it,
so lit surfaces stay crisp instead of going hazy.
## Where the art comes from
The colony is built out of two CC0 asset packs by **[Kay Lousberg](https://kaylousberg.com)**,
plus the project's own shaders on top of them.
| Pack | Used for | Licence |
| --- | --- | --- |
| [KayKit : Space Base Bits](https://kaylousberg.itch.io/space-base-bits) | Every building, the landing pads, rovers, and the crates and drums stacked around each plot | CC0 |
| [KayKit : Character Animations](https://kaylousberg.itch.io/kaykit-character-animations) | The bots' bodies and all fifteen animation clips they play | CC0 |
| [KayKit : Forest Nature Pack](https://kaylousberg.itch.io/kaykit-forest) | Terra's trees, bushes and grass, and the boulders on every world | CC0 |
| [Kenney : Nature Kit](https://kenney.nl/assets/nature-kit) | Palms, cacti, pines, autumn and jungle canopies, cherry trees — everything the Forest pack does not have | CC0 |
CC0 asks for nothing, but crediting Kay and Kenney costs nothing either. If you rebuild the
assets, all the packs go in `assets-src/` (see below).
Kenney's kit is built differently from KayKit's: rather than one gradient atlas that every
model UVs into, each model carries two or three flat-colour materials as separate primitives.
`tools/build-nature.mjs` bakes those colours into a vertex attribute and merges every model to
one primitive under one material, so at runtime the kit behaves exactly like the atlased ones
— one material per scatter recipe, `vertexColors` in place of `map`. Baking is also where a
model can be packed twice under two palettes, which is how the same oak is a cherry tree and
the same pine a snow-dusted one without a second pack.
Two things about Space Base Bits make the whole approach work. It is **modular** — a habitat is
a base module with a roof module on it, a workshop is the garage variant with a rover parked
outside — which is why ten building recipes fit on one screen. And all forty-four models share
**one 1024px gradient atlas**, so a nine-part greenhouse still merges to a single geometry and a
single draw call, exactly as the procedural generators it replaced did.
That atlas is an 8×4 grid of swatches, which turns out to be a useful thing to have. A *cell
index* is a stable name for a material, so the building shader can:
- **repaint one swatch into the repo's accent.** Kay's gold trim band is cell 11; the fragment
stage swaps its hue while keeping the swatch's own light-to-dark gradient, so every plot's
buildings wear that plot's colour with no extra material and no extra draw.
- **light that same swatch after dark**, which is what makes the window strips come on at night.
- **give one flat texture real PBR.** Roughness and metalness are looked up per cell, so the
grey structural swatch behaves like painted metal and the photovoltaic swatch like glass.
The Forest pack does double duty. Its boulders are painted neutral grey, which means a
per-instance tint takes exactly the same rock to lunar dust or Martian rust without touching
the atlas — so one scatter recipe dresses a meadow and a crater field. Only sixteen of its 105
models are packed: variety comes from per-instance scale and rotation, and packing every size
and colour variant would be five times the file for no more to look at.
### The surfaces are drawn, not shipped
The plot decks and their kerbs can't be textures from a pack, because they have to take each
repo's accent colour and a painted texture cannot. `world/surfaces.js` draws them to a canvas
at boot instead — a plated metal floor of bolted panels, and a kerb broken into dashes that
reads as runway edge lighting rather than a glowing bar. Both are authored neutral grey so the
material's colour multiplies through cleanly, and both come with a **normal map derived from
their own height field** by Sobel. That relief is doing most of the work: on a surface this
large and this flat, a flat albedo pattern under one directional light reads as wallpaper,
where a seam that catches a shadow along one edge and a highlight along the other reads as
metal.
Both surfaces needed their UVs rebuilt, and both for the same underlying reason: a generated
primitive's unwrap is made for the primitive, not for what you draw on it.
A hex tile is a six-sided cylinder, and a cylinder's cap UVs are a *disc* — which turns a tiling
plate pattern into a medallion, one per tile. The deck's **top** is therefore reprojected from
world XZ, so the seams run straight across a whole plot and seven cells read as one apron. Its
**rim** keeps the cylinder's own side unwrap, which is the one thing that works: a fixed
horizontal axis like `x + z` is *constant* along two of every six sides, leaving those faces
with no UV gradient, a degenerate tangent and — since three builds the normal-mapped shading
frame out of that — solid black; and arc length from `atan2` fixes the gradient but adds a seam
where the wrap crushes a dozen repeats into one panel. The generated unwrap has neither problem,
because it duplicates the vertices at the seam.
A kerb bar is a box, and a box hands all six faces the same 0..1 square, so the dash strip was
stretched down the sides and across the ends as well — which on a bar 14cm tall squashed the
dark gaps between dashes into what read as a solid black edge, worst where six of them gather at
a plot corner. Only the upper face points at the strip now; the rest point at a patch of flat
colour on the same texture.
### Rebuilding them
`npm run assets` packs the raw packs into the two glbs the app loads. The built files are
checked in and the raw packs are not, so this is a no-op unless you have fetched them:
```bash
mkdir -p assets-src && cd assets-src
# download the FREE tier of the KayKit packs and Kenney's Nature Kit from the links above,
# then unzip in place (kenney_nature-kit/Models/GLTF format/ is what the packer reads)
```
`npm run assets` runs `tools/build-assets.mjs`, which drives `build-kit.mjs` once per model
pack — merging a directory of single-model `.gltf` files into one document with one material
and one texture — and then `build-crew.mjs`. That last one keeps the fifteen clips the colony actually plays out of
KayKit's 161 and — the part that matters — **retargets every animation channel onto the
mannequin's own bones**. Merging glTF documents brings each animation file's private copy of the
rig along with it, so without that step the finished file has five skeletons named `hips` and
the clips drive the four nobody is looking at. It loads without a single warning and renders the
every bot frozen in its bind pose.
## Animating the bots
The bodies are hand-animated clips, and hand-animated clips are not instanceable: three skins a
`SkinnedMesh` from a `Skeleton` object, one per character, which for three hundred threads means
three hundred draw calls and three hundred skeletons stepped on the CPU every frame.
So the animation is **baked once, at load, into a bone-matrix texture**. Every clip is sampled at
30 fps and each frame's twenty-one skinning matrices are written into a float texture — 84×723
texels for the whole set. One `InstancedMesh` then carries every bot, and each bot
reads its own row of that texture from a single per-instance float: the frame it is on. Skinning
happens in the vertex shader, upstream of three's own instancing, so the skinned vertex still
goes through `instanceMatrix` and the bots stay one draw whether there are six of them or six
hundred.
Everything a bot *wears* stays procedural and stays the colony's own: helmet, visor,
screen-face, backpack, antenna and lamp. Those are pinned to bones the cheap way — the bake also
writes the head and chest world transforms into a small array on the CPU, so placing a helmet is
one matrix read rather than a skeleton evaluation, and a helmet can never be a frame out of step
with the head under it.
Behaviour maps onto clips directly, and locomotion wins over status — an idler pottering across
its plot walks rather than hammering while it slides:
| Behaviour | Clip |
| --- | --- |
| Running now | `Hammering` |
| Waiting on you | `Waving` |
| Errored | `Hit_A` |
| PR merged | `Cheering` |
| Nothing for three days | `Sit_Floor_Down` → `Sit_Floor_Idle`, and then it holds still |
| Anything else | `Idle_A`, or `Walking_A` / `Running_A` while moving |
The clip is chosen from the distance a bot **actually covered** last frame, not from
the velocity it meant to have. The two come apart the moment something is in the way:
collision refuses the step while velocity stays high, and an agent driven off intent alone
walks on the spot against a wall. The measure rises instantly and falls over a tenth of a
second — so setting off is caught on the frame it happens and nothing ever slides in a
standing pose, while a stride still gets to finish instead of freezing mid-step.
Movement is shaped to match. A wander leg is walked at a decisive pace and stops dead on
arrival rather than easing down through the speeds no standing clip can carry, and a leg that
runs into the side of a building is abandoned at the first refused step. Measured across a
live colony over a minute: **0.4% of agent-frames** disagree with what the body is doing, none
of them by more than 0.12 m/s.
Stride playback follows actual ground speed, so short steps cannot moonwalk. An *idler*
potters around its plot; a *sleeper* does not — it sits where it sat, and the only thing that
can move it is being pushed out of someone it is overlapping, which converges and stops. The
alternative is a cross-legged bot sliding across the deck, standing up to walk two
metres, and sitting down again every few seconds.
Clips that do not loop are baked a millisecond short of their own duration. Sampled at exactly
`duration` the mixer's default loop mode wraps to the start, so the frame a sit-down or a spawn
*holds* would be the pose it began from — and the bot snaps upright on the last frame of
sitting down.
Bots also stand on the ground rather than on `y = 0`. A plot's tiles are a raised slab
and the terrain between plots rolls half a metre either way, so a fixed height buries them for
a good part of the colony. `Colony.groundAt()` answers with the deck height when a point is
over an allocated hex cell — an exact axial lookup, not a nearest-centre radius test — and the
terrain field otherwise. It is sampled only when a bot has actually moved, and eased
into, so walking up onto a deck reads as a step rather than a teleport.
## Performance
Five presets from **Potato** to **Ultra**, and every knob underneath them is individually
adjustable. A dot next to a setting means you have moved it away from its preset.
The knobs that actually matter, and why:
- **Render scale** is the biggest lever there is. The drawing buffer is sized directly rather
than through `setPixelRatio`, which cannot usefully go below 1 on a retina panel. It is a
share of *your display's own resolution*, so 100% is native on a retina panel and native on
a 1× one. Reading it as CSS pixels — which is what it used to do — quietly rendered every
retina machine at half resolution, and the first place that shows is the small stuff that
holds a constant size on screen: the badge glyphs and the zone name plates, which magnify
hardest exactly when you lean in to read them.
- **Adaptive quality** watches the frame time and quietly scales *under* whatever you chose,
one step per second — a governor that reacts per frame makes the resolution visibly breathe.
Its floor is relative too: half of what your display can show, not half a CSS pixel.
- **HDR + bloom** off doesn't just skip the pass, it disposes the composer's float render
targets. Turning it off on a weak machine gives the memory back.
- **Shadows** track the camera rather than covering the whole colony, which is worth roughly a
doubling of effective resolution.
What keeps it cheap at rest:
- The bots' animated bodies are a single instanced, GPU-skinned draw, and each worn part —
helmet, visor, face, pack, antenna, lamp — is one `InstancedMesh` across every bot. The
sixty-fifth bot costs a matrix write and one float, not a draw call. Per-agent suit
colour, eye colour and facial expression ride along as instanced attributes.
Measured on a live colony: **66 bots and 66 buildings in 105 draw calls**.
- Each building merges into a single geometry, and construction progress is a shader offset
rather than a rebuild, so a building rises out of the ground without touching a vertex
buffer. It sinks the structure and discards what falls below the deck rather than slicing
the top off, so a half-built one is a *whole* building partly buried — cutting instead
guts a kit of closed shells, and a two-thirds-finished biodome becomes an empty ring.
- Terrain is displaced and vertex-coloured once at build time; the GPU only ever sees static
geometry.
- Particles live in flat typed arrays and are swap-removed on death — no allocation during play.
### Things that hold their size on screen
Badges and name plates are deliberately near-constant on screen, which inverts the usual
texture problem: they are *minified* when you pull the camera out and *magnified* when you
lean in, and the close end is the one that hurts. Both are sized for the closest you can
get — the badge atlas gives each glyph a 128×256 cell, a plate is drawn at 4× — so at the
tightest zoom on a retina panel there is still about one texel per device pixel, and mipmaps
plus anisotropy carry the far end where a plate is sixty pixels tall and would otherwise
crawl. Everything in both is drawn from paths, so the only cost of more texels is memory.
## The faces
Each visor is a little rounded screen — the patch is a rectangle in UV space, so its rounded
silhouette is cut in the fragment shader with a rounded-box SDF, which gives soft corners a
rectangular patch can never have and lets the white helmet show through where the screen ends. All sixteen expressions are drawn once into a single 4×4
canvas atlas as a white-on-black **mask** — never as finished artwork — and the colour arrives
per-bot at draw time, so one 512px texture gives every agent its own eye colour without
a second byte of memory. The shader reads the mask out of the red channel, blends between the
dark screen and that bot's glow, and adds scanlines and a vignette so it reads as a
screen rather than a decal.
They blink on their own clocks, so a crowd never blinks in unison.
## A note on which side gets drawn
The ship is procedural, and its bowls, engine bells and airlock collars are **open shells**.
Two things bite there: single-sided rendering lets you look straight through them, and a
one-sided bowl cannot shadow-map — from the sun its concave interior is a back face at exactly
its own depth, so it self-shadows to solid black whichever cull mode the depth pass uses. So
the ship draws double-sided with a `BackSide` shadow side.
The buildings want the exact opposite, and for the exact opposite reason. The model kit's
pieces are **closed solids**, so there is nothing to see through — and being closed is why they
must not be drawn double-sided. They are modelled as stacked boxes, which leaves a floor and
the ceiling underneath it sharing a plane all over the kit: a landing pad and the lander
standing on it put 38 up-facing and 17 down-facing triangles at one height, and a habitat has
two such planes, a lab four. Drawn double-sided, both halves of every one of those pairs
rasterise at identical depth and the winner is settled by floating-point noise — which is a
whole colony of surfaces flickering as the camera moves. Back-face culling throws the downward
half away before it can fight, so buildings render `FrontSide`.
Worth knowing if you add a kit: the tell is that *every* clash is an up/down pair. Not one is
up/up, which is what makes culling a complete fix rather than a partial one.
## Turning things
Turbine rotors spin in the **vertex shader**, not as child meshes, so a turbine is still one
merged geometry and one draw call. Each spinning vertex carries the hub it turns about and how
fast, which is what lets one building hold several of them, and one uniform write a frame turns
every rotor in the colony. The tower is taken from the kit *solo* — without the sub-node the
pack names separately — precisely so the rotor can be put back on as a part that moves.
Two things to watch if you add another: `BufferGeometry.scale()` transforms position and normal
and nothing else, so an attribute that holds a *position* has to be scaled by hand or the blades
orbit a hub left behind at the unscaled height. And the shadow pass needs the same rotation, or
the blade's shadow lags the blade.
## Keeping it local
The server reads your agent transcripts and can ask the OS to open things, which makes it a
more interesting target than a localhost toy usually is. Four things hold it in:
- **It binds `127.0.0.1`.** Nothing outside the machine can reach it, unless you deliberately
change that — see below.
- **It checks `Host`.** Binding to loopback is not on its own enough. An attacker who points
a domain they control at `127.0.0.1` — DNS rebinding — reaches the server *as a same-origin
page* and can then read every reply. Those requests still arrive carrying
`Host: their-domain`, and are refused.
- **It checks `Origin`.** A cross-site `fetch` with a `text/plain` body is not preflighted, so
without this any page you happened to have open could POST here — spawning sessions, opening
Finder windows, or overwriting the colony layout — even while unable to read the response.
Requests from anywhere but this server's own page are refused.
- **The built page says what it may do.** The page draws text it did not write: thread titles
are your prompts. Every place that text reaches the page escapes it, and `npm start` and
`npm run serve` add a Content-Security-Policy as the backstop for the day one does not. Only
the bundle's own script runs, the page cannot be framed, and it fetches nothing but this
server. `npm run dev` does not send the policy, because Vite injects styles for hot reload.
The practical cost: a bare `curl` POST is refused too, since browsers always send `Origin` on
POST and its absence means the caller is not the page. Add `-H 'Origin: http://localhost:5274'`
if you are scripting against the API.
### Serving it to your network
`BOT_CROSSING_HOST` changes what `npm run serve` binds to, so you can watch the colony from a
tablet on the sofa:
```bash
BOT_CROSSING_HOST=0.0.0.0 npm start
```
Serving it from WSL2 needs one more thing. The server only learns the addresses of its own
interfaces, and inside WSL2 that is the virtual adapter. A phone reaches the Windows side, so
its requests arrive with the Windows address in `Host`, and the check above refuses them.
Name those addresses and they pass the same Host and Origin checks as everything else:
```bash
BOT_CROSSING_HOST=0.0.0.0 BOT_CROSSING_ALLOWED_HOSTS=192.0.2.10,my-pc.example npm start
```
Comma separated, hostnames or addresses. Nothing else about the checks changes.
**Understand what that hands out before you do it.** The two checks above stop a *web page* from
driving the server; they are not access control, and they do nothing about another device asking
directly. Anyone who can reach the port gets every thread title, every opening prompt, every
working directory and branch — a fairly complete picture of what you have been working on — plus
the ability to open threads, reveal folders and start sessions on your machine. There is no
password, because there was never meant to be anything to guard.
Fine on a network you own. Not something to leave running on café wifi, and worth remembering
that a machine on a VPN or a mesh network is reachable by everything else on it too.
### Picking the terminal
`BOT_CROSSING_TERMINAL` names the emulator a thread opens in, ahead of `$TERMINAL` and whatever the desktop has:
```bash
BOT_CROSSING_TERMINAL=kitty npm start
```
A name on `PATH` or an absolute path, and it has to be one whose flags are known — gnome-terminal, konsole, kitty, alacritty, ghostty, wezterm, foot, xterm and their relatives — since one that is not is skipped rather than guessed at.
On macOS only a named terminal works; point it at a real binary rather than an `.app`.
On Windows the variable is not read: a thread opens in Windows Terminal when it is installed, and in `cmd.exe` otherwise.
What it touches on disk, in full:
| | |
| --- | --- |
| Reads | Your harness's own session records and transcripts |
| Writes | `data/colony.json`, and nothing else |
| Sends | Nothing. No network calls, no telemetry, no account |
`data/colony.json` holds the names and paths of the repos you work in, so it is gitignored —
worth knowing before you copy one into an issue. For the same reason it is written readable by
your account only (`0600`), not by every account on the machine.
## Layout
```
server/
harnesses/ one adapter per agent harness — README.md is the contract
index.mjs the registry: add your harness to the list here
claude-code.mjs
lib/ filesystem helpers the adapters share
scan.mjs harness-agnostic: asks every detected harness, merges, sorts
api.mjs /api/threads, /api/harnesses, /api/state, /api/open, /api/new-session,
/api/reveal
serve.mjs static server for the built app
src/
core/ settings, renderer + post chain + colour grade, the Google Earth camera,
the world-curve shader patch
world/ planets, terrain, sky and clouds, water, wildlife, hex plots, the model
kits, buildings, the ship
agents/ the crew rig and its bake, instanced bots, faces, badges, particles
audio/ the ambience engine, the sound registry, the synths
game/ threads → colony, and the API client
ui/ the HUD
tools/ asset packers — raw packs in, the four glbs the app loads out
public/assets/ spacebase.glb, crew.glb, forest.glb, nature.glb
public/audio/ optional sound samples + manifest.json (gitignored; see its README)
```
Everything that knows what a *particular* harness's files look like lives in
`server/harnesses/`. Everything else — the scanner, the API, the whole of `src/` — is written
against the thread shape and never against a harness.
Colony state lives in `data/colony.json` — where each zone sits and what you archived.
Deleting it only loses the archive list and the map's arrangement; the threads themselves are
untouched, and the colony lays itself out again from scratch.
## Building your own
Bot Crossing is one shape this idea can take. `.claude/skills/agent-session-world/` is a skill for
building the others — fish in a reef, animals in a forest, villagers, ants, boats in a harbour.
Whatever inhabits it, the structure underneath is the same: a layout that stays put so you can
learn the map, one draw call for the whole crowd, a single source of truth for what a thread is
doing, and a camera with weight.
It is written to take somebody's idea and fill in the frame around it, rather than to reproduce
this particular colony. Four reference files carry the detail, and stand on their own whether or
not you build anything like this:
- [making it feel alive](.claude/skills/agent-session-world/references/making-it-feel-alive.md) —
ambience and interaction, written to translate into any metaphor
- [rendering traps](.claude/skills/agent-session-world/references/rendering-traps.md) — the
graphics problems in roughly the order you meet them
- [harness adapters](.claude/skills/agent-session-world/references/harness-adapters.md) — reading a
coding agent's sessions without disturbing them
- [asset pipeline](.claude/skills/agent-session-world/references/asset-pipeline.md) — decent art
without an artist
## Who made this
Built by **[Jarren Rocks](https://jarren.rocks)**, mostly as a side effect of building
**[Emra](https://emra.app)** — which is where most of the threads in the screenshots come from,
and why a tool for keeping track of a lot of them at once existed in the first place.
## Licence
[MIT](LICENSE) © Jarren Rocks. Do what you like with it — including forking it, which
[CONTRIBUTING.md](CONTRIBUTING.md) explains is a first-class option rather than a last resort.
The art is not mine. Three CC0 packs by **[Kay Lousberg](https://kaylousberg.com)** — [Space
Base Bits](https://kaylousberg.itch.io/space-base-bits), [Character
Animations](https://kaylousberg.itch.io/kaykit-character-animations) and [Forest Nature
Pack](https://kaylousberg.itch.io/kaykit-forest) — and **[Kenney](https://kenney.nl)**'s
[Nature Kit](https://kenney.nl/assets/nature-kit) are built into the `.glb` files in
`public/assets/` and are covered by [CC0](https://creativecommons.org/publicdomain/zero/1.0/),
not by the MIT licence above. CC0 asks for nothing; crediting them costs nothing either.
The status badges above each bot's head are
[Material Design Icons](https://pictogrammers.com/library/mdi/), bundled via `@mdi/js` and
licensed [Apache-2.0](https://github.com/Templarian/MaterialDesign/blob/master/LICENSE).
Everything else you see and hear — the shaders, the terrain, the sky and its clouds, the water,
the birds and drones, the ship, the bots' helmets and faces, the plot decks and their kerbs,
and every synthesised sound — is made by this project and is MIT along with the code.
Two things sit outside that: the name **Bot Crossing**, and character design work from here on.
Everything in the repository today stays MIT; new designs, models and physical forms of the bots
do not. The code that draws the bots stays MIT either way — see [TRADEMARKS.md](TRADEMARKS.md).
Not affiliated with Anthropic, OpenAI, Google, or any of the other harness vendors listed above.