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.