You need: multiplayer composed.
Start from a pattern, not from the seam
The pattern helpers are those patterns, the example games are each built on one, the game model seam is what you implement when none fits, and hidden state is the part a client must never be trusted with.
Three pattern helpers own the reusable plumbing:
| Your game | Helper |
|---|---|
| Everyone chooses at once, then it resolves | simultaneous |
| One player acts, then the next | turnBased |
| A table open across rounds, with money on it | wageringTable |
The plumbing a helper owns — turn advancement, the collect-then-reveal boundary, a bet book with its holds — is where the mistakes live, and it is identical for every game of that shape.
Reach for the raw seam only when none of the three fits.
The interface, when you do need it
const myGame: GameModel<Rules, State> = {
kind: "my-game",
config: RulesSchema, // the `rules` block. Validated at assembly
state: StateSchema, // validated on every read from storage
minPlayers: 2,
maxPlayers: 4,
init(ctx) { … },
apply(ctx, state, playerId, action) { … },
isComplete(ctx, state) { … },
resolve(ctx, state) { … },
redact(ctx, state, viewerId, revealed) { … },
};Four rules you cannot bend
1. No database, ever. apply and resolve are pure. A game that moves money declares ledger effects and the session settles them.
2. Randomness only in init and apply. Those are the transitions the object commits. Drawing in resolve or redact advances the cursor on a read, so two players fetching their views would produce different game states.
3. Never read a clock. ctx.now comes from the object. A model that reads its own clock cannot be replayed.
4. redact is the hidden-state boundary. Everything a viewer may not see is removed there, and revealed only when revealed is true.
Throw the right error
throw new PithyError({ code: "multiplayer/invalid_move" }) // illegal, full stop
throw new PithyError({ code: "multiplayer/invalid_transition" }) // legal in general, wrong right nowA rejected action is never persisted. Neither is one whose effects the ledger cannot settle.
Build refs from the session and the state
`${ctx.sessionId}:round-3:alice:stake`Never from a clock or a random source. Because a model is deterministic, a replayed transition re-emits the same refs — which is what makes a payout pay once.
Register it
// worker entry, at module load
registerGameModel(myGame);The Worker and the Durable Object share one isolate, so a model registered on import is present by the time the object handles a request.
Re-registering an existing kind replaces it, which is how you override a built-in rather than fork it.
Then it is config
multiplayer({
games: [{ key: "mine", kind: "my-game", players: 3, rules: { … } }],
})Validated at assembly. A roster your model does not support, or rules it refuses, fails on deploy rather than at the first session.
What you inherit for free
Membership bound to an authenticated user. The lifecycle. The deadline alarm. The durable D1 result. The leaderboard publish. The provably-fair random stream. The ledger settlement.
Only the game logic is yours.