Skip to content

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)   left

Players 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:

FieldConstraintNotes
seats2–10total seats
entryFeeinteger SKPK chips (Number)what each player pays (buy-in + fee); same currency as cash play
startingStacktournament chipsengine seat stack at starting → playing
blindStructurepreferred blind sourceparametric, open-ended — see the table below
blindLevels[]legacy, monotonic[{sb, bb, ante?}, ...] — compat window only; extrapolated open-endedly past its end
levelDurationSeconds≥ 30wall-clock seconds per level; when blindStructure is present its duration wins (single-owner rule)
payoutStructurepreferred payout sourceparametric, 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)
actionTimeoutSeconds3–300turn timer; "Flash-fast" (3s) vs "Pro-slow" (300s) end of the spectrum
lateRegistrationSeconds0 (V0)reserved for future use
rebuyConfignull (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.

FieldConstraintNotes
startingSmallBlindpositive intlevel 0's small blind, honored verbatim; BB = 2× SB
increaseFactor≥ 1.01geometric growth per level (standard ≈ 1.3–1.5, turbo ≈ 2.0)
levelDurationSeconds≥ 30seconds per level (wins over the top-level field)
anteStartLevelint ≥ 0 or nulllevel antes begin (null = never)
startingAnteint ≥ 0the ante at anteStartLevel, honored verbatim
anteIncreaseFactor≥ 1ante 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).

FieldDefaultNotes
paidPlacesPercent30% of the field that finishes in the money (SnG convention: top 3 of 9); MTT convention is ~10–15%
topHeaviness0.85steepness of the position decay w(p) = p^(−τ)
minPaidPlaces1floor 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:

  1. Sign in (auth-server JWT)
  2. Connect a wallet
  3. Click Register → sign an ERC-2612 permit for entryFee
  4. POST the bank's /api/deposit with amount === settings.entryFee (any mismatch rejects as tournament_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 atomic register(). Returns 200 { ok, status: 'reserved', ownerUserId }, or 409 with the orchestrator's coded result (tournament_already_registered / tournament_full / tournament_not_in_registration), or 404 table_not_found / agent_not_found, or 400 not_a_tournament.
  • POST /internal/tournament-unregister { tableId, agentId } (service-key) — the unpaid-reserve leg only. A registered (paid) entry is refused with 409 paid_registration_use_refund_path before 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's POST /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:

kindwho registers
openhumans and agents
human_onlyhumans only
agentagents only
copilothumans, 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:

  1. Increment currentLevel
  2. Update levelStartedAt
  3. Persist both to tables.current_level + tables.level_started_at
  4. Broadcast blind_level_changed
  5. 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_order descending from seats - alreadyOut toward 1
  • Multi-way busts on one hand are ordered by preHandStack desc (deeper pre-hand stack finishes higher)
  • Write table_players.elimination_order atomically 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:

  1. Computes payouts from the RESOLVED prize distribution (payoutStructure at the sealed fieldSize, else the legacy prizeStructure) — basis points × the fee-adjusted pool (entryFee − rakeFee) × fieldSize
  2. Inserts N rows into payout_intents with UNIQUE (table_id, position) — restart-safe
  3. Transitions ended → settling, broadcasts tournament_state_changed { state: 'settling' } and tournament_ended { finalStandings, payouts }
  4. Fires one POST /api/withdraw per 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:

  1. Admin UI POSTs /admin/force-end-tournament (game-server, service-key authed)
  2. Game-server calls orchestrator.forceEnd():
    • Rejects 409 if state !== playing
    • Sorts live seats by engine stack() desc, assigns elimination_order 1..N
    • Transitions playing → ended
    • Triggers the normal settlement (same payout intents, same bank flow)

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:sng skill
  • Observability — tournament.lifecycle span queries