sentinel/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
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.
main
- docs
- src
- static
- .gitignore 37 B · ~11 tok
- AGENTS.md 4.9 KB · ~1.2k tok
- bun.lock 6.5 KB · ~2.2k tok
- forge.json 165 B · ~55 tok
- package.json 437 B · ~146 tok
- README.md 15.8 KB · ~4.0k tok
- tsconfig.json 588 B · ~196 tok
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.
| file | what it is |
|---|---|
src/actions.ts | every operation, and who may: players, rounds, the ladder |
src/world.ts | the simulation: motes, momentum and the current field |
src/round.ts | the round: its clock, its end, its seats, and effects as data |
src/protocol.ts | the wire: a frame or a sentence down, a steer up, quantised and sized |
src/frame.ts | one world and one clock in, one frame out, pure and measured |
src/who.ts | who is asking, from the forge's signed headers |
src/session.ts | the guest key in a cookie, and the player id it derives |
src/db.ts | SQLite under FORGE_APP_DATA, WAL, forward-only migrations |
src/live.ts | the in-process bus and GET /events |
src/socket.ts | GET /ws: who may open one, what it costs, what closes it |
src/mcp.ts | the actions as MCP tools at POST /mcp — none yet |
src/routes.ts | every path, in one table |
src/concept.ts | the design record as data: the rule, the round, the invariants |
src/page.ts | the HTML of the arena, rendered on the server |
src/meta.ts | what a pasted link and a crawler see: the card tags, robots.txt |
src/card.ts | the card and the touch icon, drawn by the app |
src/png.ts | pixels to a PNG, in about a hundred lines |
src/http.ts | the floor: headers, body cap, origins, rate limit, files |
src/server.ts | boot: open the database, serve |
src/tty.ts | the terminal — a placeholder until the world runs |
src/env.ts | what the forge told this process |
static/app.css, static/live.js | the look, and the regions that stay current |
static/icon.svg | the icon: a wake, a mote and the bloom it carries |
tsconfig.json | the 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.