Skip to content

SnG Tournaments

Status

Accepted 2026-05-13, amended 2026-05-15 (orchestrator pattern). All six phases closed by 2026-05-19.

Single-table Sit-and-Go tournaments on top of the existing poker engine. One game type (poker), two modes (cash, sng). Tournament-specific lifecycle, blind schedule, eliminations, and multi-position settlement live in a separate TournamentOrchestrator class so PokerTable stays a card-dealing engine.

Y-Statement

In the context of expanding from cash-only poker to tournament play, facing the need to support fixed-entry-fee multi-position-payout games without forking PokerTable into two parallel implementations or letting tournament concerns leak into the cash code path, we decided to add a mode flag on the tables row and hang tournament behavior off a dedicated TournamentOrchestrator class instantiated only when mode='sng', to achieve clean separation of cash and tournament lifecycles while reusing every line of the engine, persistence, telemetry, and settlement infrastructure, accepting that PokerTable carries four small mode-aware inflection points (blind source, bust reporting, stand_up rejection, forced auto-blinds) as the cost of keeping the orchestrator owning everything else.

Decisions

D1. Tournament mode is a flag on poker, not a new game type

tables.mode = 'cash' | 'sng'. Same gameType='poker', same PokerTable, same engine. The mode marker dispatches to an orchestrator at construction time. A new game type would have meant a parallel TournamentTable class plus duplicated room handling, WS routing, persistence, and engine wiring — every line of which is identical to cash.

D2. Orchestrator owns tournament lifecycle; PokerTable is mostly mode-blind

TournamentOrchestrator (one instance per sng table, in-process) owns:

  • Lifecycle state machine: registering → starting → playing → ended → settling → closed
  • Blind schedule + escalation timer
  • Bust detection + elimination ordering
  • Multi-position payout settlement (via payout_intents)
  • Tournament root telemetry span (tournament.lifecycle)

PokerTable's tournament-aware diff is exactly four inflection points:

  1. Blinds source. flow.ts:tryDealHand calls engine.getTable().setForcedBets(orchestrator.getCurrentBlinds()) before each deal.
  2. Bust reporting. persistGameBoundary calls orchestrator.onBustsAtHandEnd(busts, tx, handNumber) inside the boundary transaction.
  3. Stand-up rejection. handleStandUp override sends stand_up_not_allowed_in_tournament error when orchestrator state ∈ {playing, ended, settling}.
  4. Forced auto-blinds. tryDealHand bypasses startBlindCollection (the manual collecting_blinds UI flow) and calls verifyAndDeal directly.

Everything else — registration, seat assignment, payout dispatch, force-end, restart reconstruction — is the orchestrator's.

D3. Tournament chips ≠ CTN equity

Two distinct unit systems. Conflating them is the load-bearing bug class for tournaments.

  • Tournament chipsseat.stack() from the engine. Ephemeral counters. Never on-chain. Used for blind levels, bet sizing, bust detection.
  • CTN equitytable_players.chip_balance. Player's claim on the prize pool, in real chips. Set at registration (to entry fee), redistributed at settlement (to position payouts), zero for eliminated seats.

The custody invariant dispatches on tables.mode:

  • Cash: sums engine totalChips() against on-chain pool.
  • SnG: sums chip_balance of unsettled seats (elimination_order IS NULL) against on-chain pool.

This dispatch lives in PokerTable.doRunCustodyReconcile. It is the structural check that the two unit systems can't drift.

D4. Registration is server-assigned, not client-picked

register_for_tournament WS message carries no seat field. The orchestrator's assignSeats() runs at the registering → starting transition: Fisher-Yates shuffles registered userIds across the seat range. This is closer to how real card rooms work and removes a class of races between concurrent registrations.

Registration funding follows the existing cash deposit shape: client signs ERC-2612 permit, POSTs admin-server /api/deposit with amount === settings.entryFee (rejected as tournament_entry_fee_mismatch otherwise), admin-server submits registerAndDeposit on-chain, game-server /internal/credit-chips credits CTN equity. The engine is not seated at registration — seat materialization happens at the starting → playing transition with starting-stack tournament chips.

D5. Elimination ordering at hand-end is multi-bust-safe

Bust detection runs inside persistGameBoundary's transaction. For each seat with stack() === 0, the orchestrator assigns elimination_order descending from seats - alreadyOut toward 1. Multi-way busts on a single hand are ordered by preHandStack desc — deeper pre-hand stack finishes higher (standard tournament rule). Writes to table_players.elimination_order are atomic with the hand row.

Post-commit broadcasts emit in order: player_eliminated (one per bust) → fresh game_state (refreshes each remaining agent's tournamentMyView ranks) → hand_result. The survivor's elimination_order=1 is written when eliminatedSeatIndexes.size >= seats - 1 triggers playing → ended.

D6. Multi-position settlement via payout_intents

On playing → ended, computeActualPayouts(entryFee * seats) walks settings.prizeStructure (basis-points, 10000 = 100%) and inserts N rows into payout_intents with UNIQUE (table_id, position) for restart-safety. Each row triggers a POST /api/withdraw to admin-server with body.payoutIntent: { position, amount }.

Admin-server branches at the top of /api/withdraw: when payoutIntent is present, processPayoutIntentWithdrawal looks up the intent row, validates amount, resolves recipient wallet via deposit_intents.wallet + positions.currentHash, submits withdrawFor, marks submittedAt/txHash/confirmedAt through Ponder fast-path + RPC fallback, then POSTs /internal/payout-confirmed on game-server. Game-server broadcasts tournament_payout_settled { position, userId, amount, txHash } and — when every row in the table is confirmed — transitions settling → closed.

A 30s reconciler at admin-server sweeps payout_intents where confirmedAt IS NULL AND createdAt < now() - 30s, clears failedAt, and re-POSTs /api/withdraw. Transient failures recover without operator intervention.

D7. Agent surface extends game_state, not a parallel message

pokerGameStateSchema is extended with three optional fields (only populated when mode='sng'):

  • tournament — table-wide state (current level, blinds, big-blind ante, level ends-at, registered count)
  • tournamentSchedule — full blind ladder (for ICM and pressure reasoning)
  • tournamentMyView — per-agent: rank, stack-in-bb, M-ratio, pay jump to next position

Per-agent tournamentMyView is computed server-side via orchestrator.getStateForAgent(seatIndex) so each agent sees the right perspective. Cash mode leaves all three fields undefined. The wire schema stays backwards-compatible.

A new apps/hosted-agent-service/src/skills/poker/sng/ skill (sibling to cash-nlhe/) carries tournament-aware system prompt (M-ratio, ICM, blind pressure, pay jumps) plus a three-layer game-state formatter ([Tournament State] / [Tournament Schedule] / [Your Tournament View]). Action parsing re-exports cash-nlhe's (poker actions are identical). agent-connection.ts routes per turn on state.tournament !== undefined.

D8. Restart safety is owned by the orchestrator + DB

tables.tournament_state, tables.current_level, tables.level_started_at persist through restarts. At boot, TableManager.loadOrCreate loads the snapshot, the orchestrator constructor accepts the restored payload, and resumeFromBoot() (called by PokerTable after attachTable) re-arms the blind escalation timer via scheduleBlindTimer — which clamps remaining time to ≥ 0 when an escalation was missed during downtime.

Operators get a force-end path for stuck states. orchestrator.forceEnd() rejects with 409 unless state === 'playing'; on success, sorts live seats by engine stack desc, assigns elimination_order 1..N, transitions to ended, triggers normal settlement. Wired through admin-server POST /admin/force-end-tournament (service-key authed) and game-server POST /internal/force-end-tournament (409 on invalid state). Admin UI exposes it as a two-step button on /tournaments/[tableId] when state='playing' AND levelAge > 5min.

D9. Tournament telemetry is a single root span per lifetime

tracer.startSpan('tournament.lifecycle', ...) opens at transitionToStarting with seven config attrs (tournament.table_id, seats, entry_fee, starting_stack, level_duration_seconds, action_timeout_seconds, prize_structure_summary). Each transition emits a state_transition event. On close, final attrs tournament.duration_ms, tournament.actual_payouts, tournament.auto_fold_count are set and the span ends.

The startSpan call lives at the orchestrator's transition site (not buried in a helper) so the telemetry-contract checker's grep finds the literal. F14 safety: TELEMETRY_FORCE_THROW=true (triple-gated test flag) makes the open throw synchronously; the catch path nulls lifecycleSpan and downstream safeAddEvent / safeSetAttributes / safeEndSpan helpers are no-ops on null. Tournament progression continues regardless.

auto_fold_count is incremented from flow.ts:executeAutoAction only when in tournament mode — surfaces "actionTimeoutSeconds set too aggressively" as a queryable lifecycle attribute.

Rejected alternatives

  • A separate tournament game type. Would have meant a parallel TournamentTable, duplicated room/WS/persistence wiring, two engine-binding code paths. The four PokerTable inflection points are the entire diff; that's clearly cheaper than the fork.
  • PokerTable owns tournament lifecycle directly via if (mode === 'sng') branches. Original D1 mechanism. Rejected at the D16 amendment because it would have leaked blind-escalation timers, bust detection ordering, payout dispatch, and the lifecycle state machine into PokerTable — destroying the "PokerTable is a card-dealing engine" simplicity that makes cash easy to reason about.
  • tournament_context parallel WS message. Considered for the agent surface. Rejected because every consumer would have to subscribe twice and reconcile two streams; extending game_state keeps the wire single-stream.
  • Client-picked seats at registration. Considered for parity with cash sit_down. Rejected because it adds a concurrent-registration race (two clients racing for the same seat) without simplifying anything — tournament play doesn't depend on starting-seat choice.
  • One large tournaments table. Considered storing the entire tournament state in a sibling table. Rejected because the per-table fields (mode, tournament_state, current_level, level_started_at) are small, queried alongside other table fields, and live naturally on the tables row. The poker.tournaments table exists for tournament-specific config that doesn't fit tables.settings.

Files of interest

  • apps/game-server/src/games/poker/tournament-orchestrator.ts — the orchestrator
  • apps/game-server/src/games/poker/table.ts — PokerTable's four inflection points
  • apps/admin-server/src/routes/withdraw.tspayoutIntent branch
  • apps/hosted-agent-service/src/skills/poker/sng/ — tournament-aware skill
  • packages/types/src/wire/poker.tstournament/tournamentSchedule/tournamentMyView schema

See also