Game config
| Field | Default | |
|---|---|---|
key | — | A URL path segment |
kind | — | The model discriminator |
mode | match | Or table |
players | 2 | Match: 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 | |
|---|---|
open | Waiting for the roster |
active | Play underway |
resolved | Terminal |
abandoned | Terminal — 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 | |
|---|---|
sessionId | Build stable ledger refs from it |
config | Already validated |
players | Join order — and turn order |
now | From the object. Never read a clock in a model |
random | The seeded stream |
Purity rules
| May draw randomness | May touch a database | |
|---|---|---|
init | Yes | No |
apply | Yes | No |
resolve | No | No |
redact | No | No |
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
| Seed | 128 bits at creation. Secret until terminal |
seedHash | SHA-256, committed up front in the view’s fairness |
| Cursor | Advanced 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/action | Body forwarded untouched |
POST /multiplayer/sessions/:id/leave | Table |
POST /multiplayer/sessions/:id/close | Table |
GET /multiplayer/sessions/:id | Your redacted view |
GET /multiplayer/sessions/:id/result | Once terminal |
GET /multiplayer/sessions/:id/socket | WebSocket |
Error codes
| Code | |
|---|---|
multiplayer/session_not_found | |
multiplayer/game_not_found | |
multiplayer/not_a_member | |
multiplayer/session_full | |
multiplayer/invalid_move | The action is illegal |
multiplayer/invalid_transition | Legal in general, wrong right now |
Bindings and storage
SESSIONS | The MultiplayerSession Durable Object, with its class-migration tag |
DB | pithy_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 messages | 20:1 billing ratio |
| Duration | The full 128 MB, and only while awake |
| Hibernation | A 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.