atlas/drift

Public: anyone can read this; nobody can change it.

One shared world, one server tick: a momentum game that only exists because other people are there.

forked from trevato/drift

workbenchthis machine

This repository has no app yet.

following trevato/drift in step

Everything here is up to date with trevato/drift.

Proposes updates from trevato/drift.

History

README.md

drift

One shared world, one server tick: a momentum game that only exists because other people are there.

This is an app: the forge runs it. Production follows the default branch; every open pull gets a preview at its own address with its own copy of the data. Everything the app keeps lives under FORGE_APP_DATA, which is what makes "production data" and "preview data" two different things.

PORT=8080 FORGE_APP_DATA=./data bun run src/server.ts   # run it here
bun install                                             # the one dev dependency
bun run test                                            # the check every pull runs
bun run typecheck                                       # just the types
bun test                                                # just the tests
FORGE_APP_DATA=./data bun run src/tty.ts                # the terminal
FORGE_APP_DATA=./data bun run src/db.ts --check         # schema version, integrity

The check

bun run test is one command with two halves — tsc --noEmit, then bun test — and it is exactly the string forge.json hands the forge on every push; src/check.test.ts fails if the two ever drift apart. The two halves are not the same check: Bun strips types to run a file, so without the first half a wrong type deploys green. It begins with bun install --frozen-lockfile so that a bare checkout has a typechecker to run at all; where node_modules is already there that step is two milliseconds and no network.

tsconfig.json is strict, and noUncheckedIndexedAccess with it, because most of this app is arithmetic over buffers where an index out of range is the bug worth catching. Nothing in it is turned down to make the tree pass, there is no @ts-expect-error anywhere in src/, and src/check.test.ts holds the strict flags on so that a red check cannot be made green by editing the config.

TypeScript is the only tool this repository depends on, and it earns its place the way a compiler does: nothing else can tell you the types you wrote are the types you have, and none of it ships — a dev dependency, noEmit, no runtime import, the app still starts with bun run src/server.ts and nothing else. @types/bun rides along so the check knows what Bun hands a program.

What is real, and what is not yet

The page is the arena and nothing else: a square the sea will be drawn in, a round clock, one line of the rule, a roster and a ladder — all rendered on the server, all legible with JavaScript off, none of them fed by a simulation yet. There is no world tick and no score behind them; the regions are shells with honest empty states, and the round loop, the canvas and the ladder fill them in the issues that follow. POST /mcp answers and lists no tool. GET /ws upgrades and enforces every rule a connection has — origin, identity, one seat per player key, a ration, backpressure — but nothing publishes frames on it yet, so a socket that opens stays quiet until the round drives it. No door sets the guest cookie yet either, so today every browser reaching /ws is a spectator and is told so; the page that calls session and hands out a key lands with the swimmer's view.

The sea, on the other hand, is real arithmetic that nothing is plugged into yet. src/world.ts advances one tick — motes, momentum, the current field, the rim and the gate — with no clock, no database and no network, and the same world always yields the same next one because every source of chance is the seed in the state. bun test drives it by hand and proves every invariant the record states about the sea, each named by its number.

The round is the same kind of thing one level up. src/round.ts is the clock, the end condition, the quiet between rounds, the next world and the seat bookkeeping — plain data and one tick(round) that returns the next round and a list of effects: started, ended with its outcome, and a frame every second tick. It holds no timer, no database and no socket, so its whole behaviour is reached by calling a function: 3600 calls is exactly one round, a bloom put through the gate ends it sooner, and one seed replays to the same state, gate after gate. What an effect means — a row, an emit, bytes on a socket — belongs to the loop that spends them, which is the issue after this one.

The game

The only terrain is the wake other players leave. Your thruster is feeble; the currents other swimmers deposit are not, and the bloom only reaches its gate when several people braid one lane. docs/wake.md is the design record — the mechanic, the starting numbers and the eight invariants every build issue is measured against. It is not decoration: the numbers the page states live in src/concept.ts and bun test fails if they and the record drift apart. A seat in the sea belongs to a player key (the forge id, or the guest id behind the cookie) and one key holds one seat: a second tab steers the same mote, a client presenting no session watches, and a seat nobody steers is let go — the record's A seat section says why an address can never be the rule here.

The shape

One implementation, many doors. src/actions.ts is every operation as a plain function — (db, who, input) in, data or a named failure out — and the page, the JSON API, the MCP server and the terminal all call it and decide nothing themselves. AGENTS.md says how to add to it.

filewhat it is
src/actions.tsevery operation, and who may: players, rounds, the ladder
src/world.tsthe simulation: motes, momentum and the current field
src/round.tsthe round: its clock, its end, its seats, and effects as data
src/protocol.tsthe wire: a frame or a sentence down, a steer up, quantised and sized
src/frame.tsone world and one clock in, one frame out, pure and measured
src/who.tswho is asking, from the forge's signed headers
src/session.tsthe guest key in a cookie, and the player id it derives
src/db.tsSQLite under FORGE_APP_DATA, WAL, forward-only migrations
src/live.tsthe in-process bus and GET /events
src/socket.tsGET /ws: who may open one, what it costs, what closes it
src/mcp.tsthe actions as MCP tools at POST /mcp — none yet
src/routes.tsevery path, in one table
src/concept.tsthe design record as data: the rule, the round, the invariants
src/page.tsthe HTML of the arena, rendered on the server
src/meta.tswhat a pasted link and a crawler see: the card tags, robots.txt
src/card.tsthe card and the touch icon, drawn by the app
src/png.tspixels to a PNG, in about a hundred lines
src/http.tsthe floor: headers, body cap, origins, rate limit, files
src/server.tsboot: open the database, serve
src/tty.tsthe terminal — a placeholder until the world runs
src/env.tswhat the forge told this process
static/app.css, static/live.jsthe look, and the regions that stay current
static/icon.svgthe icon: a wake, a mote and the bloom it carries
tsconfig.jsonthe half of the check that reads the types

The link you send a friend

A game whose whole mechanic is other people spreads by one person pasting the address to another, so the head says what drift is and the picture shows it. GET /card.png is 1200×630, drawn by src/card.ts at boot — the arena, three wakes braided into one lane, the bloom going through the gate — and GET /icon-180.png is the same sea for a phone's home screen. Both are addressed by the hash of their own bytes (/card.png?v=0f2c8dd2) and cached for a year at that address; neither can carry a player's name, because the drawing has no idea who asked.

Every address in the head — canonical, og:url, og:image — is built from FORGE_APP_URL (publicOrigin) and never from Host, X-Forwarded-Host or anything else a client sent; a test in src/server.test.ts holds it there. GET /robots.txt leaves the page crawlable and closes /events, /ws, /mcp and /__forge/: a crawler has no business holding a stream open, and joining a round is a socket event so that no fetch can ever take a seat.

The PNGs are encoded by src/png.ts — signature, IHDR, one deflated IDAT, adaptive row filters — using node:zlib, which Bun ships. No dependency was added to draw them.

Who you are

The forge says who is asking on every request it proxies, and the app verifies the signature (src/who.ts); nothing else is trusted. Three tiers:

  • anon — anyone. Watches, and will swim as a guest.
  • user — signed in to the forge and able to see this app. Swims under

their forge name, and is the only kind of swimmer the ladder names.

  • admin — able to write this repository.

"Sign in" is /__forge/signin?to=<path> and "Sign out" is /__forge/signout; the forge answers both on the app's own host.

Playing as a guest

Half wired: the socket reads the cookie and keys a seat by it, so a guest who has one is the same swimmer in every tab — but no door issues one yet, and a browser carrying no cookie watches. What follows is how guests work the moment a door hands one out.

Nobody has to sign in to swim. A visitor the forge cannot name is given a guest key — 32 random bytes in an HttpOnly; SameSite=Lax cookie — and a handle the app generates, like Quiet Tide 47. It is title case with spaces, which no forge username is, so a guest is never mistaken for a person the forge knows, and no name a client sent is ever stored.

Over https the cookie is named __Host-guest. Every app and every preview on this forge lives under one parent domain, and cookies do not respect the host boundary that CSP and CORS do: without the prefix a neighbour could set guest=…; Domain=<the parent> and have you swim as a guest whose key they hold. A browser stores a __Host- cookie only from the exact host that set it, over https, with Path=/ and no Domain at all. On localhost, where no browser would store the prefix, the name is plain guest.

The cookie is the only thing that makes a guest the same person twice. Clear it, use a private window, or open the app on another device and you are a new player with a new handle — that is the honest answer, not a bug: the app has nothing else to recognise you by. Sign in and that stops being true: a guest who signs in keeps what they scored, because the guest's rows move to their forge player and the guest is let go, leaving one identity.

The key itself never leaves the browser. What the world, the database and the ladder see is a one-way derivation of it (src/session.ts) — and the actions take the key, never that id: they derive the id themselves, so the id is only ever an output. That is the point. The id is published, in every frame the world sends and every row of the ladder, and if offering it back were enough to claim a guest then reading one frame would be enough to play as anybody in it, and to walk off with their score at sign-in. Holding the cookie is the only way to be a guest. Only forge players appear on the ladder; a guest sees their own round.

A guest who scored nothing and has not been seen for seven days — the ladder's own window — is forgotten, so the table holds the people playing rather than every cookie ever sent. A guest with a score stays: a finished round still refers to them.

Any interface

The same actions, the same rules, at every door. A script is the person whose forge grant it carries; a bare script is anonymous. The MCP endpoint is already here — its tools arrive with the round:

claude mcp add --transport http drift https://<app>/mcp
{
  "mcpServers": { "drift": { "type": "http", "url": "https://<app>/mcp" } }
}

The page stays current over /events: a region marked data-live is refetched when the round says something on the bus. Without JavaScript it is the same page, one reload behind. A stream is a connection held open, so it is rationed like a write — six at once for a person, two hundred and fifty-six for one address, which behind the forge is every guest at once — and over the cap the answer is 429 rather than another socket held open. Every response, the stream included, carries the floor in src/http.ts.

The world itself travels over one websocket, GET /ws (src/socket.ts). It is the one door the request rate limit never sees, so its rules are its own and they are strict. An upgrade must carry an Origin that is ours — a missing one is refused, unlike a POST's, because a websocket has no preflight. The identity is fixed at the upgrade and kept for the life of the connection: no message ever names its own sender. Every message is validated by parseSteer and by nothing else, and a message that fails closes the socket with 1008. Steering is rationed per socket — 20 Hz passes forever, a burst over the bucket is dropped where it lands rather than queued, and a client that keeps flooding is closed. A seat is keyed by the player key and by nothing the connection invents — the forge id for a signed-in person, the guest id derived from the HttpOnly cookie for everybody else (playerKey in src/session.ts), never an address and never a key a query string, a header or a message offered. A seat is a person, not a tab: every socket under one key is another window on the same seat and steers the same mote, so the sixth tab is refused no more than the first and none of them closes another; the roster shows one swimmer because there is one. Eight windows per key is the ration, and a connection past it is not refused either — it watches. A request carrying no session has no key, so it gets no seat: it is admitted as a spectator, told so in the record's own sentence (SPECTATOR in src/concept.ts) as its first message, and every frame still goes down it while everything it steers is dropped. The upgrade has no response to set a cookie on, so a first visit is a page load and then a socket — one round trip — and nothing is ever minted at the upgrade to avoid it. The one socket ever closed for something its person did not do is the guest window still open when that browser signs in: signing in is a change of key, so the guest seat is released with 4000 and the page comes back as who it is now. A socket is pinged every 25 seconds and closed with 4001 after 90 seconds of silence, and a client whose send buffer is backed up is skipped for that frame rather than waited on, so a slow phone degrades alone.

The terminal opens with the exact command in FORGE_APP_SSH when the app runs (the app is the login name, the forge the host). It is src/tty.ts on a real pty, as the account at the keyboard; today it prints where it is and quits on q, and the sea drawn in text arrives with the world.

The forge sets PORT, FORGE_APP_DATA, FORGE_APP_ENV (production or preview), FORGE_APP_URL, FORGE_APP_COMMIT, FORGE_APP_SSH and FORGE_APP_IDENTITY_KEY when it starts the app. forge.json says how.

↑↓ navigate ↵ open esc close

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs