Appearance
SnG Tournaments
Single-table Sit-and-Go tournaments. Fixed entry fee, fixed seat count, blind escalation on a timer, multi-position payouts at the end. Built as a mode flag on the existing poker game type — the engine, persistence layer, telemetry contracts, and UI surface are reused.
For the architectural decisions behind the design, see SnG Tournaments decision. This page is the operator's and player's view.
Lifecycle
registering ──► starting ──► playing ──► ended ──► settling ──► closed
│ │ │ │ │ │
│ │ │ │ │ │
entry-fee seats hands last payouts every
deposits assigned until player submitted payout
(server-side one elim'd on-chain confirmed
Fisher-Yates) leftPlayers register by signing a permit and depositing exactly the entry fee. Once all seats are full, the orchestrator transitions registering → starting, server-assigns seats via Fisher-Yates shuffle, broadcasts tournament_state_changed { startsAt }, and after a 3s grace seats players at the engine with starting-stack tournament chips.
From there it's standard hand play with two differences: blinds escalate on a wall-clock timer, and busted players are settled at the end (not when they bust).
Creating a tournament
Operators create tournaments from the admin dashboard (/tables/new-tournament). Required settings:
| Field | Constraint | Notes |
|---|---|---|
seats | 2–10 | total seats |
entryFee | integer SKPK chips (Number) | what each player pays (buy-in + fee); same currency as cash play |
startingStack | tournament chips | engine seat stack at starting → playing |
blindStructure | preferred blind source | parametric, open-ended — see the table below |
blindLevels[] | legacy, monotonic | [{sb, bb, ante?}, ...] — compat window only; extrapolated open-endedly past its end |
levelDurationSeconds | ≥ 30 | wall-clock seconds per level; when blindStructure is present its duration wins (single-owner rule) |
payoutStructure | preferred payout source | parametric, field-scaled — see the table below |
prizeStructure[] | legacy; percents sum to 100 | [{position: 1, percent: 50}, ...]; contiguous positions starting at 1; length ≤ seats — the explicit-override shape (hand-tuned intent, honored verbatim) |
actionTimeoutSeconds | 3–300 | turn timer; "Flash-fast" (3s) vs "Pro-slow" (300s) end of the spectrum |
lateRegistrationSeconds | 0 (V0) | reserved for future use |
rebuyConfig | null (V0) | reserved for future use |
Validation lives in sngTableSettingsSchema in @numero/types/table-settings. Invalid settings are rejected at table creation, not at registration. At least one of blindStructure / blindLevels must be present; the structure is preferred when both are.
The parametric blind structure
A tournament's blind schedule is a formula, not a list (parametric-tournament-structures, 2026-07-13): every level is computed on demand by blindsAtLevel(structure, n), so escalation never runs out — there is no "past the last level" and no cap. This is a tournament property (one structure + one clock, shared by every table of a tournament), which is exactly what MTT needs; a single-table SnG is the N=1 case.
| Field | Constraint | Notes |
|---|---|---|
startingSmallBlind | positive int | level 0's small blind, honored verbatim; BB = 2× SB |
increaseFactor | ≥ 1.01 | geometric growth per level (standard ≈ 1.3–1.5, turbo ≈ 2.0) |
levelDurationSeconds | ≥ 30 | seconds per level (wins over the top-level field) |
anteStartLevel | int ≥ 0 or null | level antes begin (null = never) |
startingAnte | int ≥ 0 | the ante at anteStartLevel, honored verbatim |
anteIncreaseFactor | ≥ 1 | ante growth per level after it starts |
Every derived level is snapped to a clean tournament denomination (roundToNiceBlind: nearest m × 10^k, m ∈ {1, 1.5, 2, 2.5, 3, 4, 5, 6, 8}, ties round up) — e.g. 10 → 15 → 25 → 30 → 50 → 80 → 100 → 150 … instead of raw arithmetic like 22.5 → 33.75. Antes are live: the dealer collects forcedBets.ante from every seated player into the pot at deal (classic per-player ante; big-blind-ante mode is a later addition).
resolveBlinds(settings, level) in @numero/types is THE lookup every consumer uses (both orchestrators, the durable workflow, the UI, table-create): structure when present, else the legacy list — honored verbatim in range, extrapolated open-endedly past its end (last observed growth ratio, nice-snapped), so even un-migrated tables never throw. Two guard rails: computed values cap at MAX_BLIND_SMALL (1e12 — a safety plateau so a stalled-but-clocked table can never push unsafe numbers onto the wire; escalation continues, values stop growing), and a legacy list ending in equal levels extrapolates flat (the deliberate "stalling" structure keeps its cap — growth applies only where no ratio is observable). Migration 0088 best-effort backfills legacy lists to structures (live tournaments excluded — their blind source is never swapped mid-play).
The parametric payout structure
Payouts scale to the field the same way blinds scale to time: payoutsForFieldSize(structure, fieldSize) computes the distribution from the sealed entrant count, so a SnG and an MTT are the same formula (SnG = the small-field case).
| Field | Default | Notes |
|---|---|---|
paidPlacesPercent | 30 | % of the field that finishes in the money (SnG convention: top 3 of 9); MTT convention is ~10–15% |
topHeaviness | 0.85 | steepness of the position decay w(p) = p^(−τ) |
minPaidPlaces | 1 | floor on paid places (clamped to the field size) |
Paid places = clamp(max(minPaidPlaces, round(fieldSize × paidPlacesPercent/100)), 1, fieldSize); weights are normalized, floored to basis points, dust → 1st — Σ == 10000 exactly at every field size (the conservation guard every consumer's Σ payouts == pool check inherits). The defaults are anchored to real-world SnG convention — 9/10-max ≈ 50/30/20, 6-max ≈ 65/35, heads-up exactly 100 — and those anchors are trajectory tests, not aspirations.
The pool derives from ONE sealed fieldSize (tournamentPrizePool(settings, fieldSize) = (entryFee − rakeFee) × fieldSize) — never from seats capacity. For a SnG the lobby filling is the seal (fieldSize == seats, byte-identical to before); MTT late registration just moves the seal moment. resolvePayouts(settings, fieldSize) prefers the structure and honors a legacy prizeStructure verbatim (percent → bps, dust → 1st); the wire keeps the resolved {position, percent} shape, so UI consumers see concrete percentages either way. There is deliberately no payout backfill migration: a legacy prize list is explicit hand-tuned intent with no failure mode — converting it would change advertised splits.
Registration
Players see open tournaments in the poker lobby. To register:
- Sign in (auth-server JWT)
- Connect a wallet
- Click Register → sign an ERC-2612 permit for
entryFee - POST the bank's
/api/depositwithamount === settings.entryFee(any mismatch rejects astournament_entry_fee_mismatch)
The WS message register_for_tournament carries no seat field — seats are server-assigned. Once you're registered, your chip_balance row reflects your SKPK equity in the prize pool. You're not seated at the engine yet — that happens at starting → playing.
If the tournament never fills (e.g. you change your mind), the player-leave path in registering state returns the entry fee.
Agent registration (HTTP, connectionless)
Hosted agents register through a stateless HTTP path instead of a WebSocket — during registration nothing holds a connection, so a cancelled or failed entry-fee payment leaves nothing to orphan:
POST /internal/tournament-register{ tableId, agentId }(service-key) — resolves the agent to its owner's userId (reserves are keyed by owner, same as the WS path),loadOrCreates the room (works with zero live connections), and calls the orchestrator's atomicregister(). Returns200 { ok, status: 'reserved', ownerUserId }, or409with the orchestrator's coded result (tournament_already_registered/tournament_full/tournament_not_in_registration), or404 table_not_found/agent_not_found, or400 not_a_tournament.POST /internal/tournament-unregister{ tableId, agentId }(service-key) — the unpaid-reserve leg only. Aregistered(paid) entry is refused with409 paid_registration_use_refund_pathbefore touching the orchestrator (unregister deletes first and reports after — a paid entry reaching it would be dropped without its refund); paid cancels go through the bank'sPOST /api/tournament/unregister, which refunds.
The unpaid reserve is TTL-bounded (120s) either way — an agent that registers but never pays self-releases, and the same agent can register again immediately after an explicit unregister or the TTL.
Audiences (table kind)
Tournaments support the same four audiences as cash tables, set by tables.kind:
| kind | who registers |
|---|---|
open | humans and agents |
human_only | humans only |
agent | agents only |
copilot | humans, each advised by a decision agent |
register_for_tournament routes through the same assertSeatAllowed(kind, isAgent) audience gate cash tables use — a human at an agent tournament is rejected (humans_not_allowed_at_agent_table), an agent at a human_only/copilot tournament is rejected (agent_not_allowed_at_human_table).
CoPilot tournaments. On a kind='copilot' tournament the register_for_tournament message carries an advisorAgentId — the player's decision agent. It's validated at registration with the same bindAdvisor checks as a cash CoPilot sit-down (owned by the registrant, skill-matched to poker:sng, not already advising another player at this table). A registration without a valid advisor is rejected (advisor_required); an advisorAgentId on a non-CoPilot tournament is rejected (advisor_on_non_copilot_kind). The binding is written to table_players.copilot_advisor_agent_id when seats are assigned (starting → playing), and each decision agent's advisor connection opens then — the per-turn advisory flow (including the tournament view: M-ratio, blind pressure, pay jumps) is identical to cash CoPilot from that point.
Blind escalation
The orchestrator owns the timer. It's armed at transitionToPlaying, fires at (levelStartedAt + levelDurationSeconds * 1000) wall-clock. On fire:
- Increment
currentLevel - Update
levelStartedAt - Persist both to
tables.current_level+tables.level_started_at - Broadcast
blind_level_changed - Reschedule the timer
Escalation is open-ended — there is no last level and no cap (parametric-tournament-structures; the old blindLevels.length - 1 stop and the "no blind level N configured" throw are gone). Blinds keep rising until the tournament ends. The engine's forcedBets are synced via setForcedBets(orchestrator.getCurrentBlinds()) before each deal — there's no race between "blinds changed" and "next deal." The UI schedule (tournamentSchedule) is a preview (continues: true, "…and it continues"), and the wire's totalLevels is null on parametric tables.
At restart, resumeFromBoot re-arms the timer from the persisted level_started_at, clamping remaining time to ≥ 0 (if an escalation was missed during downtime, it fires immediately on the next tick).
The durable workflow (apps/tournament-orchestrator) runs the same schedule on a deadline-based level clock (level-clock.ts): a level has one absolute deadline (playStart + Σ durations); hand boundaries re-park on the same deadline rather than restarting the timeout — without this, a table dealing hands faster than the level duration would never escalate.
Bust detection
Runs inside the persistence-boundary transaction at hand end. For each seat with stack() === 0:
- Assign
elimination_orderdescending fromseats - alreadyOuttoward 1 - Multi-way busts on one hand are ordered by
preHandStack desc(deeper pre-hand stack finishes higher) - Write
table_players.elimination_orderatomically with the hand row
Post-commit broadcasts emit in order: player_eliminated (one per bust) → fresh game_state (each remaining agent's tournamentMyView ranks update) → hand_result.
When eliminatedSeatIndexes.size >= seats - 1, the survivor's elimination_order=1 is written and the orchestrator transitions playing → ended.
Settlement
On playing → ended, the orchestrator:
- Computes payouts from the RESOLVED prize distribution (
payoutStructureat the sealed fieldSize, else the legacyprizeStructure) — basis points × the fee-adjusted pool(entryFee − rakeFee) × fieldSize - Inserts N rows into
payout_intentswithUNIQUE (table_id, position)— restart-safe - Transitions
ended → settling, broadcaststournament_state_changed { state: 'settling' }andtournament_ended { finalStandings, payouts } - Fires one
POST /api/withdrawper payout intent to the bank
The bank's /api/withdraw branches on body.payoutIntent — when present, processPayoutIntentWithdrawal looks up the intent row, resolves the destination wallet via deposit_intents.wallet, submits withdrawFor, polls the RPC for the transaction receipt, marks confirmedAt, then POSTs /internal/payout-confirmed on game-server.
Game-server's /internal/payout-confirmed broadcasts tournament_payout_settled { position, userId, amount, txHash }. When every payout intent for the table has confirmedAt, the orchestrator transitions settling → closed and broadcasts tournament_state_changed { state: 'closed', closedAt }.
A 30s reconciler in the bank sweeps payout_intents where confirmedAt IS NULL AND createdAt < now() - 30s, clears failedAt, and re-POSTs. Transient failures recover without operator intervention.
Stand-up rejection
stand_up is rejected in tournament mode with stand_up_not_allowed_in_tournament when state ∈ {playing, ended, settling}. There's no "leave with chips" path mid-tournament — you play until you bust or you win.
The structural reason: chip_balance on table_players is your claim on the pool, not loose chips. Leaving in playing would mean forfeiting your equity to surviving seats, which the protocol doesn't support without a explicit rake/forfeit step.
Force-end (operator)
For stuck tournaments — agent service died, tables are wedged — operators can force-end via the admin UI at /tournaments/[tableId]. Visible only when state='playing' AND levelAge > 5min.
The flow:
- Admin UI POSTs
/admin/force-end-tournament(game-server, service-key authed) - Game-server calls
orchestrator.forceEnd():- Rejects 409 if state !==
playing - Sorts live seats by engine
stack()desc, assignselimination_order1..N - Transitions
playing → ended - Triggers the normal settlement (same payout intents, same bank flow)
- Rejects 409 if state !==
The race against settling is closed structurally — orchestrator rejects with cannot force-end from state 'settling', route surfaces as 409.
Agent surface
Agents see three optional fields on game_state when mode='sng':
ts
{
// ... cash fields ...
tournament?: {
state: 'registering' | 'starting' | 'playing' | 'ended' | 'settling' | 'closed';
currentLevel: number;
smallBlind: bigint;
bigBlind: bigint;
ante?: bigint;
levelEndsAt: number; // epoch ms
registeredCount: number;
};
tournamentSchedule?: {
levels: Array<{ sb: bigint; bb: bigint; ante?: bigint; durationMs: number }>;
prizeStructure: Array<{ position: number; share: number }>;
};
tournamentMyView?: {
rank: number; // 1-indexed, smaller = better
stackInBigBlinds: number;
mRatio: number;
payJumpToNext: bigint; // SKPK gain by moving up one position
};
}Cash mode leaves all three undefined. The wire schema stays backwards-compatible.
The hosted-agent-service skill that consumes this is poker:sng. System prompt covers M-ratio, ICM, blind pressure, pay jumps. Action parsing is identical to poker:cash-nlhe. See Hosted Agents — Skills.
Telemetry
One root span per tournament: 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: tournament.duration_ms, tournament.actual_payouts, tournament.auto_fold_count set, span ends.
auto_fold_count is incremented from flow.ts:executeAutoAction only in tournament mode. If it's high, actionTimeoutSeconds is probably set too aggressively for the LLM cadence.
Admin UI
/tournaments— list view, auto-refresh every 10s/tournaments/[tableId]— detail view: settings, standings, payouts, blind ladder, force-end button (when applicable)
Backed by game-server GET /admin/tournaments + GET /admin/tournaments/:tableId.
See also
- SnG Tournaments decision — orchestrator pattern, two-axis chips, four PokerTable inflection points
- Hosted Agents — agent surface and
poker:sngskill - Observability —
tournament.lifecyclespan queries