The game lifecycle, end to end
Creation — the one Worker-direct write
POST /api/engine/games is the single place the Worker writes game state to D1
directly, because the DO does not exist yet. The Worker runs all creation policy
(guest gates, config parse against the version schema, the ratingPool
decision, and validation of the client's concrete rated assertion), generates
a unique shortCode (a readable 6-char code with a retry loop on the UNIQUE
index), and writes the games + participants rows with the creator in seat 0.
The DO is not touched; it will lazy-init on first command or socket.
rated is a validated assertion, never a coercionThe client computes it too (via the Dart twin of ratingPool), and a mismatch is
rejected rather than silently "corrected" — that catches twin drift and forged
clients.
The waiting room
Before a game starts, the roster is mutable. Join / leave / cancel / add-bot / start are Commands to the DO, with policy checked at the Worker before minting (guest-vs-rated, friends-access, schema gate — no D1 reads inside the gate) and integrity enforced in the DO (status, seat occupancy, creator-only rules). Highlights:
- Join by id or by shortCode. Creating with
minPlayersalready satisfied makes a gameready; otherwisewaiting. - Leave compacts seat indexes (safe pre-start, since no transition references a seat yet). The creator cannot leave — they cancel.
- Add-bot is creator-only and passes the bot seating gates.
- Cancel is creator-only, drops the DO's storage, and marks the D1 row
aborted(the D1 write is awaited here, unlike other lobby effects, because the aborted row is the only survivor). - Start is creator-only, commits version 0 via the kernel, and arms the first deadline.
The client opens its WebSocket before start. Pre-game, the DO pushes unversioned, idempotent roster snapshots on every change (a reconnect just gets the current one); versioned frames begin at v0. D1's participants copy is updated post-commit and is allowed to be briefly stale — a stale lobby just means a join can fail cleanly at the DO.
create-solo (POST /api/engine/games/solo) collapses "create a private game
seated with me + bots, and start it" into one call, returning the caller's
opening v0 frame so the client can render immediately. Guests may play bots
(unrated).
Active play
A move is POST /api/engine/games/{id}/action carrying the caller's own seat,
the expectedVersion it computed against, and the game-defined data. The DO
verifies the seat belongs to the caller against its authoritative roster (a seat
you don't hold is a clean 403), runs the kernel, and — on accept — commits the
next version and rides the caller's own projected frame back on the response.
Every other seat's frame arrives over its socket. Forfeit is the same shape with
a lifecycle/forfeit intent.
Humans and bots submit a seat uniformly; the DO resolves the actor (user id from the token, bot id from the HMAC claim) against the roster the same way for both. There is no server-side "figure out my seat" fallback.
Finish, and history compaction
When a hook returns an outcome, the finishing transition commits status = finished and writes an outbox row in the same SQLite transaction. Then,
post-commit and off the response path:
- The D1 finish-apply writes the game summary + outcomes, and (for rated
games) runs the rating CAS. It is idempotent via
finish_id. - On success, a final ratings transition (version N+1) is appended for
rated games — carrying each seat's rating delta — and the compaction rides
the outbox clear: one SQLite transaction empties the live-only
framesandcommandstables and deletes theoutboxrow. ~20–40 KB of permanenttransitions+meta+rosterremain.
The outbox row is the recovery signal: if the D1 apply fails, it survives, and a gated admin re-poke re-runs the apply (idempotent). DO storage is never dropped at finish — only at cancel/abort. The finished DO is the game's history.
Cancel & abort
Cancel (creator, pre-start) and abort (the cron reap of abandoned games; see
Account lifecycle & the cron) mark the D1 row
aborted and drop the DO's storage entirely — there is no history object for a
game that never really happened. Abort is unconditional (no creator gate, works
even on a never-initialized DO).