Sessions and lifecycle

Four phases

Phase
openCreated, waiting for the roster to fill
activeThe roster is full and play is underway
resolvedTerminal. A terminal position was reached and the server computed a result
abandonedTerminal. The action deadline passed with the game unfinished

resolved and abandoned never transition again. Every field is in the reference.

Two modes

Both are created and driven through the same routes.

match — the default. Fill the roster, play one game, end.

table — long-lived. Active from creation, players join and leave between rounds, the model settles each round, and the table stays open until it is closed or empties.

The config is snapshotted at creation

The session resolves against that snapshot and never reads live app config again — including what each player is allowed to see, which your model decides per viewer.

So a later config edit cannot change a session already in flight. Change your rules and games in progress finish under the rules they started with.

The snapshot carries the game key, the model kind, the mode, the roster size, the turn deadline, the leaderboard target, and the model-specific rules block — kept opaque to the session infrastructure, which treats every game the same.

Membership binds to the authenticated user

members: string[]   // authenticated user ids, in join order. Never client-asserted.

Join order is also turn order for a sequential game.

Every route requires authentication, and membership comes from core’s auth seam — never a client-supplied id.

The Durable Object holds nothing important in memory

Metadata is read fresh on each request, from the object’s own storage.

That is not fastidiousness — it is the whole cost model. A Durable Object that holds state in memory cannot hibernate, and a session that cannot hibernate bills duration continuously while a player thinks about their move.

Game-specific state lives separately under the model’s own storage key, Zod-validated on every read — defense in depth against a corrupt blob.

The deadline is an alarm, never a timer

deadline: number | null   // absolute ms-epoch, or null to wait indefinitely

Enforced by a Durable Object alarm.

A passed deadline moves the session to abandoned.

The result is durable, and it is in your D1

The session lives in a Durable Object; the result is a single row in your own app database, joinable beside your own tables.

The publish to a leaderboard is one-way. The session writes; it never reads a board back.

Play

POST /multiplayer/games/:game            create a session — you are its first member
POST /multiplayer/sessions/:id/join      another player joins
POST /multiplayer/sessions/:id/action    take your action
POST /multiplayer/sessions/:id/leave     leave a table seat        (table mode)
POST /multiplayer/sessions/:id/close     close a table             (table mode)
GET  /multiplayer/sessions/:id           your redacted view
GET  /multiplayer/sessions/:id/result    the durable result, once terminal
GET  /multiplayer/sessions/:id/socket    live play over a hibernation-safe WebSocket

An action’s body is whatever the game’s model defines, and the route forwards its JSON untouched.

What a session costs

A session waiting on a player’s turn hibernates and bills no duration.

Two platform facts shape the rest — WebSocket messages bill at 20:1, and duration bills the full 128 MB regardless of how little the session uses. Both are documented, dated as of 2026-07-16, and Cloudflare’s pricing page is the authority.

Cheap idle sessions are a Durable Object property, not a Pithy feature. Anyone building on a DO gets them. What this adds is the wiring and the authority on top.

ESC