Writing your own game model

What the seam separates

The Durable Object knows nothing about any particular game. Membership, the lifecycle, hidden state, the deadline alarm, the durable result, the leaderboard publish, the CLI wiring — all game-agnostic.

Everything a specific game is lives behind a GameModel, resolved by kind from a registry. Most games never write one: the three pattern helpers build it for you, and writing one by hand is the escape hatch.

The interface

Every member is typed in the reference.

interface GameModel<Config, State> {
  kind: string;
  config: z.ZodType<Config>;     // the `rules` block. Validated at assembly
  state: z.ZodType<State>;       // validated on every read from storage

  minPlayers?: number;           // defaults to 2
  maxPlayers?: number;           // undefined for no upper bound

  init(ctx): State;
  apply(ctx, state, playerId, action): ApplyResult<State>;
  isComplete(ctx, state): boolean;
  resolve(ctx, state): ResolveResult;
  redact(ctx, state, viewerId, revealed): unknown;

  onJoin?(ctx, state, playerId): ApplyResult<State>;   // table mode
  onLeave?(ctx, state, playerId): ApplyResult<State>;  // table mode
}

The seam is code, not data

A model’s logic is imported into the Worker bundle. Only its state — a serializable, Zod-validated blob — lives in Durable Object storage.

That is what lets the Durable Object, which is constructed by the runtime and cannot be handed a closure, delegate to a model it never imported directly.

The context

interface GameContext<Config> {
  sessionId: string;             // stable, for building idempotent ledger refs
  config: Config;                // already validated
  players: readonly string[];    // join order — and turn order for a sequential game
  now: number;                   // supplied by the DO. Never read a clock inside a model
  random: RandomSource;          // the seeded stream
}

Never read a clock inside a model. now comes from the object, because a model that reads its own clock cannot be replayed.

Purity is a hard requirement, not a style

apply and resolve cannot touch a database. A wagering game does not move money itself — it declares ledger effects that the object settles afterwards.

Draw randomness only in init and apply — the transitions the object commits. resolve and redact must stay pure, or a replay would diverge.

A game with no chance simply never touches random.

What apply returns

{ state, effects? }

A rejected action is never persisted — and neither is one whose effects the ledger cannot settle, such as a hold a player cannot cover.

Throw multiplayer/invalid_move for an illegal action, or multiplayer/invalid_transition for one that is legal in general and wrong right now.

Register it

registerGameModel(myGame);

Called at module load. The Worker entry and the Durable Object share one isolate, so a model registered when the Worker imports is present by the time the object handles a request.

Re-registering the same kind replaces it — last write wins, which is what lets you override a built-in.

You rarely start here

Three pattern helpers own the reusable lifecycle plumbing, and you layer a game on one of them by supplying only the game-specific rules.

Reach for the raw seam when none of the three fits the shape — a game with a hidden rack and a dictionary, say. Pick the closest pattern first.

ESC