Skip to main content

The game session — one Durable Object per game

BaseGameDO is the abstract base an implementor subclasses. Each instance is one game, addressed deterministically by idFromName(gameId).

The per-game SQLite schema

The DO's own SQLite database is the game. Six tables:

TableLifetimePurpose
metapermanentThe single game row (id, status, access, schema_version, config, timing, rated, pool, roster bounds, creator, rng seed). Copied once from D1 at lazy-init, then DO-owned.
rosterpermanentOne row per seat (player_index, user_id/bot_id, type). The authoritative roster — D1's copy is a display mirror.
transitionspermanentAppend-only, immutable. One row per version: the opaque state, the action that produced it, the pending set, deadline, per-player clocks. This table is the game's history.
frameslive-onlyPer-seat projected observations, for socket gap-recovery and the same-view compare. Drained by the finish compaction (replay re-projects instead).
commandslive-onlycommand_id → stored response for idempotent retries. Drained by the finish compaction.
outboxtransientWhat the D1 finish-apply needs, written atomically with the finishing transition and cleared only after the apply succeeds. Its presence is the recovery signal.

The schema is engine-owned and self-applying: a drizzle durable-sqlite migration bundle is compiled into the Worker and runs inside blockConcurrencyWhile on first activation — so even a finished game woken years later migrates itself before serving anything.

Lazy initialization

A game's D1 row is written before its DO exists (creation is a direct Worker → D1 write; see The game lifecycle). The DO is created lazily on first contact (first command or socket): it reads the game + participants from D1 once, inside blockConcurrencyWhile, and copies them into meta + roster. From then on the DO owns status and rng_seed; D1's copy becomes a display read-model updated from DO effects. If no game row exists in D1, first contact resolves to a clean unknownGame.

The command pipeline & idempotency

Every command that crosses the Worker → DO boundary is a self-contained, pre-authenticated value (Command): the kind, the game id, a commandId, the acting Principal (a user id or a bot id, never both), and the payload. The Worker has already verified the token and run every policy check before minting it; the DO enforces integrity (seat occupancy, status, versions) under its gate. This clean split — policy at the edge, integrity in the DO — means a command is loggable, replayable, and a CI fixture is just a JSON array of them.

Two idempotency keys keep the pipeline exactly-once:

  • commandId (client → DO): the DO stores each accepted command's response and replays it verbatim for a duplicate, so a client retry never double-applies a move. (Rejections are recomputed fresh — re-evaluating one is always sound.)
  • finish_id (DO → D1): the finishing transition mints one; the D1 apply is a no-op if the games row already carries it, so a re-poked finish is safe.

Serialization orders commands but cannot identify duplicates — that is what the ids are for.

Versions are strictly serial

Every accepted command commits as the next integer version, in arrival order, with no gaps, ever. The same-view rule governs acceptance only; it never reorders or skips versions. This invariant is what lets the client recover any gap by a simple version-range fetch and lets replay walk the log linearly.