Skip to content

Table Lifecycle

Every table moves through one state machine, and every player journey at a table moves through another. Both are owned by the server, both are durable, and both survive restarts at any stage. This page describes the system as it is today.

Table states

transitionTableState() in apps/game-server/src/shared/table-state.ts is the only writer of tables.status / freeze_reason / closed_at. It validates the transition, writes all three columns in one atomic statement (two-flag drift — status frozen with a null reason — is unrepresentable), emits a table.state_changed span, and notifies the loaded room.

On every transition, connected clients receive a table_state_changed message (live overlay, no reload), a fresh game_state whose settings carry tableStatus + tableStatusReason, and the lobby is re-reported — frozen and closed tables are dropped from the live list.

Cross-process writers (the bank's unfreeze, close-on-empty, escape) drive transitions via POST /internal/table-state/:tableId (service-key) so the machine stays in game-server's process.

Frozen is terminal (with one verified exception)

A table freezes when the custody invariant detects a conservation divergence. Frozen means: no new seats, no deposits, no actions — funds will be returned. The only path back to open is the admin unfreeze, which re-verifies balance first. Every other disposition for a frozen table is the escape: the bank snapshots per-player engine chips from game-server, pays the exact split on-chain via tableEscape(tableId, players, amounts, token), and transitions the table to closed (reason: escaped).

Abandoned tables reach a conclusion by themselves

The idle reaper (apps/game-server/src/shared/idle-reaper.ts) sweeps periodically:

ConditionDisposition
No chips on any non-gone row, no live connections, idle > idleReaperEmptyCloseSeconds (default 30 minutes)closed (idle_reaped)
Chips present, no game activity > idleReaperAbandonedEscapeSeconds (default 24 hours)escape first (funds → claimable custody), then closed
Chips present, idle past the empty threshold but inside the 24h window, AND the table is broken (on-chain pool ≠ DB chips)escape→custody→closed on discovery (reap.reason='broken_divergent')

Waiting vs broken — why the 24h courtesy is conditional

The 24-hour window exists for real parked funds: a player stepped away and may come back, so their money waits at the table rather than being swept the moment the table goes quiet. That courtesy is not owed to a broken table — one whose on-chain pool no longer matches the DB chip totals (phantom rows, stale/reset chain state). A broken table has nothing to wait for; making it sit for 24 hours just leaves a dead table on the board and money in limbo.

So past the empty threshold (30 min), the reaper probes each funded table with POST /escape/:id?ifDivergent=1 (table-page-resilience, 2026-07-13). Both banks compute the divergence they already compute at escape and answer:

  • Clean (owed + rakeAllowance == pool) → 204, nothing moves, the 24h courtesy stands. A clean verdict is memoized in-process for 6h so a genuinely parked table isn't re-checked every sweep.
  • Broken (owed ≠ pool) → the escape proceeds to its disposition (table-move → a needs_reconciliation custody hold → closed). Any real funds surface as claims in the owner's profile; the operator reconciles the hold.

The game-server stays chain-unaware — the bank answers the divergence question. A bank error (4xx/5xx) is logged reap.reason='probe_failed' and retried next sweep, never mistaken for a conclusion.

The divergence check is rake-aware. On-chain, rake never leaves the pool counter (EVM settle accrues to a separate accumulatedRake; the Solana vault holds it until collection), so a healthy raked cash table has pool > Σ chip_balance permanently. Clean is therefore owed + rakeAllowance == pool, where the allowance is the table's house-row rake history (EVM: all house rows; Solana: unclaimed house rows). Without this, every raked table read "divergent" and got swept into a held custody hold 30 minutes after its last hand.

The escape snapshot never strands a funded table

The escape's per-player split is normally read from the live engine. When the engine can't supply it — an unloadable table (corrupt settings) or a reconstruct race (a restart's loadOrCreate resolves before its async ready-chain seats the engine) — the route falls back to table_players.chip_balance, which is the authoritative split at rest (a table with an empty engine can't have a live hand). Before this fallback, an escape right after a restart snapshotted an empty engine and closed a funded table, stranding the whole pool. A table with no DB row at all is refused (404) before any of this, so escaping a non-existent table can never fabricate an empty close.

Activity protects a table — an open socket does not (table-page-resilience, 2026-07-13). A live connection defers reaping only inside the deadline: a funded table past the 24h activity deadline is concluded even if an idle seated socket is still open (rail/spectators ride SSE, so the participant WS only carries seat-holders — before this, one forgotten seated tab pinned a table open forever). Playing tables are safe by construction: every hand boundary writes a participant checkpoint, so last_activity stays fresh. Empty tables keep the connection veto — no funds at stake, and a connected empty felt is someone about to sit.

A table is never reaper-eligible when: it isn't a poker table (the liveness probe can only see the poker namespace — never reap what you can't check), its tournament_state is mid-flight (the orchestrator owns that conclusion), or it has a deposit intent in a non-terminal state less than an hour old (money is between the chain and the felt).

Thresholds live in game_settings (gameType global), falling back to IDLE_REAPER_* env defaults (operator knobs: idleReaperEmptyCloseSeconds / idleReaperAbandonedEscapeSeconds in game_settings, or the IDLE_REAPER_EMPTY_CLOSE_SECONDS / IDLE_REAPER_ABANDONED_ESCAPE_SECONDS env vars). Every reap is a normal transitionTableState call — span, live broadcast, and lobby removal come for free; decision logs carry reap.reason

  • reap.had_connections so "why did/didn't this table get reaped" is answerable from Dash0. The reaper's escape call sends BOTH bank auth headers (x-service-key + x-admin-key) — the two banks currently disagree on /escape auth (unification is a flagged follow-up).

A companion sweep on the bank (admin.close_table_reconcile) converges the ON-CHAIN record: a DB-closed table whose on-chain config still reads active (pool empty, not escaped) gets closeTable retried until the chain agrees. A DB-closed table still holding on-chain funds is alarmed as stranded — a custody disposition, never force-closed.

When a cash player busts

A cash player whose stack reaches 0 at a hand's end busts. Busts are detected from the committed hand-end boundary payload (endStack === 0), never the live engine seats — the engine drops a busted seat at showdown, so an engine-seat scan would miss it. That miss is the class this disposition closes: before it, the boundary checkpoint persisted the busted seat's stale pre-hand stack, which broke chip conservation (table_players sum vs the on-chain pool) and spuriously froze → escaped the whole table on any heads-up bust.

The busted seat's balance is written to a true 0 in the same boundary as the hand, then the seat transitions seated → sitting_out and a rebuy window opens (per-table bustRebuyWindowSeconds, default 60s). Every client receives bust_rebuy_window { seatIndex, deadline } and the seat owner sees a live countdown. The window resolves one of two ways:

  • Rebuy — a deposit credited to the held seat inside the window re-seats the player (sitting_out → seated) and cancels the timer. Play resumes.
  • Expiry — no rebuy by the deadline frees the seat: a local 0-chip stand-up (no bank round-trip — a 0-chip seat has nothing to withdraw), the row reaches gone, and player_left broadcasts. Another player can take the seat.

The expiry defers if a rebuy is already between the chain and the felt — a non-terminal deposit_intents row for that player re-arms the window instead of freeing the seat (the same in-flight exclusion the idle reaper uses). Freeing a seat while a deposit is confirming would strand the funds: the credit would land on a gone player and be refused. A failed read defers too — never strand.

The window is an in-process timer, so a restart re-arms it at reconstruction from last_checkpoint_at + bustRebuyWindowSeconds (a deadline already past disposes immediately) — a bounce never strands the seat.

Tournament busts are a separate path — the orchestrator records an elimination and assigns elimination_order; there is no rebuy window.

The player journey, with restart points

*reserved is the only intentionally ephemeral state — a restart releases all in-flight holds and the seats reopen.

Restart guarantees, by stage (verified by test/lifecycle/ on every merge):

  • reserved — hold released; seat reopens; a fresh reserve succeeds.
  • seated — reconstruction restores the participant row AND mirrors the engine seat (stack = chip_balance). On reconnect the deal loop finds quorum and a pending post_blind_request is replayed with its original deadline. Dealing resumes with no deposit and no player action.
  • mid-hand — the hand voids to the last checkpoint; chips return to their pre-hand values; the conservation alarm stays silent; the next hand deals.
  • sitting-out — state and balance write through to table_players on the transition, so a restart reconstructs sitting_out, not seated.
  • cashing-out — a stand-up that hasn't withdrawn yet survives; the withdraw completes after the bounce and the row reaches end-of-life (gone, 0 chips, seat and advisor binding cleared).

Telemetry: the trace IS the history

Every transition in both machines is queryable:

SpanWhen
table.state_changedevery table-state transition (from/to/reason; ERROR on illegal)
table.reconstructionboot-path rebuild (seats_restored, engine_seated)
participant.seated / participant.stood_upPM transitions
table.rejectionEVERY coded WS rejection (error.code) — emitted at the sendTo chokepoint
participant.settled (event)row end-of-life inside the settle flow

See Observability for the wider span catalog, and table-frozen-recovery for the operator runbook.