Multiplayer reference

Game config

FieldDefault
key—A URL path segment
kind—The model discriminator
modematchOr table
players2Match: exact roster. Table: maximum seats
rules—Opaque to the session; validated by the model
turnTimeoutMs—Alarm-enforced. Null waits indefinitely
leaderboard—{ board, points: { win, draw, loss } }

Session phases

What each one admits, and how a session moves between them, is sessions and lifecycle.

Phase
openWaiting for the roster
activePlay underway
resolvedTerminal
abandonedTerminal — the deadline passed unfinished

The config is snapshotted at creation. A later edit cannot change a session in flight.

The model interface

Writing your own is the page on implementing it. Routes below are shaped like every route in the kit and refuse with an ordinary error code.

interface GameModel<Config, State> {
  kind: string;
  config: z.ZodType<Config>;
  state: z.ZodType<State>;
  minPlayers?: number;              // default 2
  maxPlayers?: number;              // undefined = unbounded

  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
  onLeave?(ctx, state, playerId): ApplyResult<State>;   // table
}

The context

Field
sessionIdBuild stable ledger refs from it
configAlready validated
playersJoin order — and turn order
nowFrom the object. Never read a clock in a model
randomThe seeded stream

Purity rules

May draw randomnessMay touch a database
initYesNo
applyYesNo
resolveNoNo
redactNoNo

A model declares ledger effects; the object settles them.

Ledger effects

{ op: "hold",     userId, currency, amount, ref }
{ op: "capture",  ref, amount?, memo? }
{ op: "release",  ref }
{ op: "credit",   userId, currency, amount, ref, memo? }
{ op: "debit",    userId, currency, amount, ref, memo? }
{ op: "transfer", … }

Every effect carries a ref. A deterministic model re-emits the same refs on a replay, so a payout pays once.

A hold a player cannot cover rejects the action, and the transition is never persisted.

Randomness

Seed128 bits at creation. Secret until terminal
seedHashSHA-256, committed up front in the view’s fairness
CursorAdvanced as a model draws; persisted by the object
ctx.random.next()        // float in [0, 1)
ctx.random.int(1, 6)
ctx.random.pick(items)

Routes

Every one requires authentication.

Route
POST /multiplayer/games/:game
POST /multiplayer/sessions/:id/join
POST /multiplayer/sessions/:id/actionBody forwarded untouched
POST /multiplayer/sessions/:id/leaveTable
POST /multiplayer/sessions/:id/closeTable
GET /multiplayer/sessions/:idYour redacted view
GET /multiplayer/sessions/:id/resultOnce terminal
GET /multiplayer/sessions/:id/socketWebSocket

Error codes

Code
multiplayer/session_not_found
multiplayer/game_not_found
multiplayer/not_a_member
multiplayer/session_full
multiplayer/invalid_moveThe action is illegal
multiplayer/invalid_transitionLegal in general, wrong right now

Bindings and storage

SESSIONSThe MultiplayerSession Durable Object, with its class-migration tag
DBpithy_multiplayer_results — the durable result row

Session and game state live in the object’s own SQLite-backed storage, capped at 10 GB per object, Zod-validated on every read.

Cost facts

WebSocket messages20:1 billing ratio
DurationThe full 128 MB, and only while awake
HibernationA session waiting on a turn bills no duration

As of 2026-07-16. Cloudflare’s pricing page is the authority.

A single setInterval forfeits hibernation entirely.

License

MIT.

ESC