Appearance
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:
- Blinds source.
flow.ts:tryDealHandcallsengine.getTable().setForcedBets(orchestrator.getCurrentBlinds())before each deal. - Bust reporting.
persistGameBoundarycallsorchestrator.onBustsAtHandEnd(busts, tx, handNumber)inside the boundary transaction. - Stand-up rejection.
handleStandUpoverride sendsstand_up_not_allowed_in_tournamenterror when orchestrator state ∈ {playing, ended, settling}. - Forced auto-blinds.
tryDealHandbypassesstartBlindCollection(the manualcollecting_blindsUI flow) and callsverifyAndDealdirectly.
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 chips —
seat.stack()from the engine. Ephemeral counters. Never on-chain. Used for blind levels, bet sizing, bust detection. - CTN equity —
table_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_balanceof 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
tournamentgame 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_contextparallel WS message. Considered for the agent surface. Rejected because every consumer would have to subscribe twice and reconcile two streams; extendinggame_statekeeps 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
tournamentstable. 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 thetablesrow. Thepoker.tournamentstable exists for tournament-specific config that doesn't fittables.settings.
Files of interest
apps/game-server/src/games/poker/tournament-orchestrator.ts— the orchestratorapps/game-server/src/games/poker/table.ts— PokerTable's four inflection pointsapps/admin-server/src/routes/withdraw.ts—payoutIntentbranchapps/hosted-agent-service/src/skills/poker/sng/— tournament-aware skillpackages/types/src/wire/poker.ts—tournament/tournamentSchedule/tournamentMyViewschema
See also
- SnG Tournaments guide — operator and player view
- Hosted Agents guide — agent surface and skill model
- LiteLLM Integration — gateway used by tournament skills
- Bulletproof Observability —
tournament.lifecyclespan + telemetry-contract enforcement