Tournament Engine

This guide covers the internals of the poker engine’s tournament system — how tournaments are created, run, balanced, and completed. It is intended for developers extending or integrating tournament functionality.

Architecture Overview

The tournament system is composed of several co-operating services:

tournament.service.ts          — CRUD, blind calculation, config accessors
tournament-state.service.ts    — State machine, optimistic locking, transitions
tournament-flow.service.ts     — Lifecycle orchestration (start, eliminate, complete)
tournament-table.service.ts    — Table creation, balancing, final-table formation
tournament-table-runtime.service.ts — Live table pause/resume and runtime wiring
tournament-registration.service.ts — Player registration and late registration
tournament-prize.service.ts    — Prize pool calculation and credit distribution
tournament-ticket.service.ts   — Satellite ticket awards and refunds
satellite-prize.service.ts     — Satellite seat and remainder calculation
tournament-lock.service.ts     — In-process lock (prevents concurrent completion)
tournament-monitor.service.ts  — External monitoring / health checks
tournament-rebuy.service.ts    — Rebuy and re-entry processing
tournament-renewal.service.ts  — Auto-renewal (recurring SnG/scheduled)
tournament-config.service.ts   — Limits, validation, speed and Sit & Go presets, password hashing
tournament-invariant.service.ts — Structural invariant checks (for testing)

The primary integration point for game-flow code is tournament-flow.service.ts. Everything else is called from there or from HTTP route handlers.

Rule Profiles And Compliance Scope

Tournament behavior must be described through versioned rule profiles, not through ad hoc feature checks. The production tournament default is tournament-tda-wsop-v1, a casino/TDA-inspired online procedure profile for behavior that has been implemented and verified.

These rule profiles are product/gameplay procedure contracts, not official TDA/WSOP endorsements. Jurisdiction-specific legal compliance remains outside this engine layer and belongs to operator licensing, AML/KYC, responsible gaming, tax, and market-specific controls.

Profile Responsibilities

src/services/rule-profile.service.ts resolves the product-level rule authority and compatibility. src/services/tournament-rule-profile.service.ts resolves tournament-specific behavior from the active profile.

Tournament profile settings cover:

  • tournament family behavior: freezeout, Sit & Go, MTT, rebuy, and re-entry;
  • late registration, rebuy, re-entry, add-on, cash top-up, and bot cash-rebuy restrictions;
  • level clocks, blinds, antes, Short Deck button blind, Stud bring-in/completion, synchronized breaks, and pause/resume;
  • table balancing, table breaking, dead-button, heads-up transition, and hand-for-hand triggers;
  • absence policy for sit-out, disconnect/reconnect, timeout, time bank, and forced-bet participation;
  • showdown, all-in reveal, automatic muck, deck-procedure, and repair policies surfaced to runtime/history.

Fixed values in the resolved settings include a maximum table difference of 1 player, hand-for-hand triggers at the money bubble, the final-table bubble and payout jumps of at least 5%, sit-out after 3 timeouts, a 30-second tournament time bank, level changes at the next hand, and no cash top-ups or bot cash rebuys.

Official named mixed formats are intentionally gated. CUSTOM_MIXED is the supported product format on the internal MIXED_GAME engine. Exact HORSE and exact 8-Game profiles remain unavailable until Razz, 2-7 Triple Draw, fixed/mixed-limit transitions, button procedures, and certification are implemented.

Tournament State Machine

States and valid transitions are defined in tournament-state.service.ts:

REGISTRATION ──► RUNNING | CANCELED
RUNNING      ──► PAUSED | PRIZES_PENDING | COMPLETED | CANCELED
PAUSED       ──► RUNNING | CANCELED
PRIZES_PENDING ──► COMPLETED | CANCELED

completeTournament() always goes through PRIZES_PENDING. COMPLETED and CANCELED are terminal.

State definitions

StateDescription
REGISTRATIONOpen for player sign-up.
RUNNINGHands are being played.
PAUSEDAdmin-paused. Timer state is persisted; resumes without losing level progress.
PRIZES_PENDINGPlay is complete and final standings are known, but prize credits are still being applied. If prize crediting fails, the tournament remains here for retry/reconciliation.
COMPLETEDTerminal. All prizes credited or awarded.
CANCELEDTerminal. cancelRunningTournament() refunds buy-ins, rebuys and re-entries by default; the entry rake is not part of the refund.

Running tournaments also carry orthogonal procedure state that does not replace the primary status:

  • current level, next level, level-start time, pause remaining time, and break pending/active metadata;
  • hand-for-hand active flag, reason, round, completed/waiting table counts, and per-table round rows;
  • payout hold/release metadata used to block prize crediting while a floor review is active;
  • seating and table-move state stored in TournamentTable, TableSeat, and audit rows;
  • per-registration entry, rebuy, add-on, re-entry, elimination, payout-order, and tie-group metadata.

Optimistic locking

Every state transition uses a version field to prevent lost updates:

// tournament-state.service.ts
await prisma.tournament.update({
  where: { id: tournamentId, version: currentVersion },
  data: { status: targetStatus, version: { increment: 1 } },
});

If the update finds no row (version mismatch), it throws TournamentConcurrentModificationError. updateTournamentWithVersion() wraps this with exponential-backoff retry (100ms → 200ms → 400ms, up to 3 attempts).

Registering state callbacks

import { registerStateCallbacks } from './tournament-state.service.js';

registerStateCallbacks({
  onEnterRunning: async (event) => {
    // Start blind level timer, emit WebSocket event, etc.
    console.log(`Tournament ${event.tournamentId} started`);
  },
  onEnterCompleted: async (event) => {
    // Send completion emails, update leaderboards, etc.
  },
});

Tournament Formats

SCHEDULED (Multi-Table Tournament)

  • Has a future startTime and a minPlayers requirement. canAutoStart() passes once the start time has been reached and minPlayers are registered.
  • checkAndStartTournament() and getTournamentsReadyToStart() implement that check. The server code path that starts a scheduled tournament is the admin route POST /api/v1/admin/tournaments/:id/start.
  • Supports late registration, rebuy, re-entry, and add-on windows.

SIT_AND_GO

  • Requires minPlayers === maxPlayers and starts when the field is full.
  • A 5-second countdown is scheduled via scheduleSnGStart(), and lobby subscribers get a tournament_starting event.
  • Late registration, rebuy, re-entry, and add-ons are forced off (isSnG flag in createTournament).
  • SNG_PRESETS and SNG_PRIZE_STRUCTURES in tournament-config.service.ts define the standard fields of 2, 6, 9 and 18 players.
  • If a player unregisters during the countdown, cancelSnGStart() aborts the start.

Auto-start flow

checkAndStartTournament(id)
  └── canAutoStart(id)          — checks format/time/player count
  └── startTournament(id)
        ├── acquireTournamentLock(id, 'start')
        ├── getActiveRegistrationsForSeating(id)
        ├── createTournamentTables(id, playerIds)
        ├── transitionState(id, 'RUNNING')
        └── evaluateAndActivateHandForHand(id)

Multi-Table Coordination

Table creation and seating

createTournamentTables(tournamentId, playerIds) in tournament-table.service.ts:

  1. Calculates tableCount = ceil(playerCount / playersPerTable).
  2. Shuffles player order with crypto-secure randomness.
  3. Creates the tables in a database transaction.
  4. Splits players evenly (calculateSeatDistribution), with any extras on the first tables, and assigns random seats.
  5. Returns TableCreationResult, whose tables are TournamentTableWithSeats[].

Each tournament table maps to a regular in-memory Table via the WebSocket table manager. Tournament tables share the same blind structure, enforced by updateTournamentBlinds().

Table balancing

After each player elimination, handlePostElimination() calls checkAndBalanceTables():

checkAndBalanceTables(id)
  └── getActiveTournamentTables(id)        — count seats per table
  └── if max - min > maxDifference (DEFAULT_MAX_DIFFERENCE: 1):
        balanceTables(id)
          ├── break the smallest table if its players fit elsewhere
          └── move players from fullest to emptiest tables

movePlayerToTable() refuses to move a player while either table has a hand in progress, and balanceTables() skips those moves. Balancing runs only while more players remain than fit on one table.

Final table formation

When remainingPlayers <= playersPerTable:

checkAndFormFinalTable(id)
  └── isFinalTable(id, playersPerTable)   — true if player count fits one table
  └── consolidateToFinalTable(id)
        ├── pick the final table
        ├── move all other players to it
        └── retire the now-empty tables

Blind Level Management

Blind calculation

calculateBlindLevel(tournament, level) computes exponential growth:

const multiplier = Math.pow(1 + blindIncrease / 100, level - 1);
const smallBlind = Math.round(tournament.startingSmallBlind * multiplier);
const bigBlind   = Math.round(tournament.startingBigBlind  * multiplier);
// Ante is 0 unless startingAnte > 0; it grows by anteIncrease the same way
const ante       = Math.round(tournament.startingAnte * anteMultiplier);
// smallBet = bigBlind, bigBet = 2 * bigBlind
// Stud with lowerLimit/higherLimit configured uses those as the bet sizes instead

blindIncrease and anteIncrease are percentages stored as strings (e.g., "25" = 25% per level). buttonBlind, bringIn and completionBet are read from the tournament unchanged at every level. The implementation is calculateTournamentBlindLevel() in tournament-rule-profile.service.ts.

Advancing levels

advanceToNextBlindLevel(tournamentId) in tournament-flow.service.ts:

  1. Increments currentLevel and resets currentLevelStartedAt in the database (optimistic locking).
  2. Calls calculateBlindLevel() to get new values.
  3. Updates the database via updateTournamentBlinds(), except for tables with a hand in progress.
  4. Queues the config on in-memory Table instances with table.queueConfigUpdate(). Idle tables apply it at once; tables mid-hand apply it when the hand completes.
  5. Broadcasts tournament_blind_change to each table, with effectiveAt (next_hand or immediate), appliedTableIds and pendingTableIds.

The caller in the server code is the admin route POST /api/v1/admin/tournaments/:id/advance-level.

Timer persistence

currentLevelStartedAt is stored in the database so that level timers survive server restarts. getTimeToNextLevel(tournament) uses this field:

const elapsedSeconds   = Math.floor((now - startTime) / 1000);
const secondsRemaining = Math.max(0, tournament.levelDuration - elapsedSeconds);

Pausing / resuming

pauseTournament(id, adminId, reason?) captures remaining seconds in pausedTimeRemaining, sets status = 'PAUSED', and pauses the live tables. resumeTournament(id, adminId) reconstructs currentLevelStartedAt from the saved remaining time so the level timer is exactly where it was left.

Procedure And Audit Events

Tournament procedure changes must leave durable evidence. Avoid hidden state edits that bypass service-level audit paths.

Audit/event sourcePurpose
TournamentSeatingAuditInitial seating, balance moves, table breaks, late-registration seating, re-entry seating, busted removals.
TournamentEntryAuditInitial registration, late registration, rebuy, add-on, and re-entry.
TournamentFloorAuditAdmin/floor controls such as H4H override/release, payout hold/release, seating repair, player warning/penalty/sit-out, admin notes, hand repair, and hand void.
Tournament history/clock endpointsLevel advance, break, pause, resume, and clock state visible to admins.
Game/deck procedure auditsMisdeal, fouled deck, duplicate card, premature board/street, missing burn, repair/void controls, and settlement blockers.
Hand history/replay metadataShowdown order, all-in required reveal, muck/folded-hidden policy, winners, Hi-Lo splits, side pots, and odd chips.

Floor-control mutations should accept a correlation ID and be idempotent. A repeated correlation ID returns the original result instead of creating another audit row.

Player Elimination

eliminatePlayer(tournamentId, playerId) uses a SERIALIZABLE transaction to atomically:

  1. Set isEliminated = true, eliminatedAt = now.
  2. Count remaining non-eliminated players.
  3. Set finishPosition = remainingCount + 1.
  4. Remove the player from their table seat.

If seat removal fails, the entire transaction rolls back — the registration is NOT marked eliminated.

Pending eliminations and simultaneous busts

A busted player can first be held in a pending state with markPlayerPendingElimination(), so they can decide on a rebuy or re-entry. The window is TOURNAMENT_PENDING_ELIMINATION_DECISION_WINDOW_MS (60 seconds). finalizePendingElimination() then assigns finish positions, and reconcileExpiredPendingEliminations() finishes any left over at server startup.

Players who bust together (same hand, or same hand-for-hand round) are ranked by the stack they started the hand with, larger stack first. Equal starting stacks share a finish position and an eliminationTieGroup, and they split the payouts for the places they cover equally.

What happens next

After elimination, handlePostElimination() decides what comes next:

tournamentEnded?      → completeTournament()
isFinalTable?         → checkAndFormFinalTable()
needsBalancing?       → checkAndBalanceTables()

Satellite Tournaments

A satellite tournament awards seats (tickets) to its target tournament rather than cash prizes.

  • isSatellite: true + targetTournamentId required.
  • Buy-in must be less than target tournament buy-in.
  • The target cannot be completed, canceled, or itself a satellite.
  • prizeDistribution is auto-set to [{ position: 1, percentage: 100 }], but payouts on completion come from the seat calculation below rather than this distribution.
  • On completion: awardAllSatelliteTickets(tournamentId) calculates how many seats the prize pool can buy (floor(prizePool / targetBuyIn)) and awards TournamentTicket records to finish positions 1 through that number.
  • A seat winner already registered for the target gets the target buy-in in cash instead of a duplicate ticket.
  • The next finisher after the last seat (position seatsAwarded + 1) receives any cash remainder.
  • If any award fails, the tournament stays in PRIZES_PENDING.

Prize Distribution

Prizes are configured as PrizeDistribution[]:

type PrizeDistribution = { position: number; percentage: number };

validatePrizeDistribution() requires that percentages are positive and sum to 100 (within 0.01), and that positions are unique, consecutive from 1, and at most MAX_PRIZE_POSITIONS (50).

Prize pool

calculatePrizePool() adds up player contributions:

totalContributions = buyIn * registrations
                   + rebuyCost * rebuys
                   + reentryCost * reentries
                   + addOnCost * addOns

tournament.rake is charged on top of buyIn at registration and goes to the house, so it is not part of the pool. Rebuys, re-entries and add-ons contribute their full cost.

prizeType is PLAYER_POOL (the pool is the contributions) or FIXED (the pool is the higher of fixedPrize and the contributions; the difference is the overlay).

Crediting

creditAllPrizes(tournamentId) in tournament-prize.service.ts runs only after the tournament has been moved to PRIZES_PENDING. It refuses to run while payoutHeld is set. In one Serializable transaction it:

  1. Calculates each position’s amount as totalPrizePool * percentage / 100.
  2. Finds finishers whose finishPosition is within the paid positions.
  3. Shares payouts between tied finishers: the payouts for the places they cover are added together and divided equally.
  4. Credits the tournament’s wallet currency (USD and GOLD credit Gold, DIAMOND credits Diamond) and writes TOURNAMENT_PRIZE ledger rows with an idempotency key.
  5. Creates TournamentResult rows and sets payoutAmount on the registration.
  6. Moves the tournament to COMPLETED.

Because it is atomic, a failure pays nobody. The tournament then stays in PRIZES_PENDING, where operators can reconcile and retry. A retry after completion returns the existing results rather than paying again.

getSuggestedPrizeStructure(playerCount) returns a default distribution by field size. For example, it pays 100% for 2 players, 70/30 for 3-6 players, and 50/30/20 for 7-18 players. Larger fields pay more places, up to 15.

Compliance Suite

Tournament compliance scenarios live in src/game/verification/tournament-compliance.ts and are executed by the gameplay verification command:

./scripts/game.sh start
./scripts/game.sh status
npm run gameplay:verify
./scripts/game.sh stop

Current compliance fixture groups cover:

  • seeded playable hands and scripted action prompts;
  • official/custom mixed rotation and button-freeze policy;
  • tournament seating, balancing, breaking, dead button, and heads-up transition;
  • hand-for-hand and simultaneous eliminations;
  • late registration, rebuy, add-on, and re-entry;
  • sit-out, disconnect, timeout, and time-bank behavior;
  • showdown, muck, all-in reveal, deck procedure, Stud history labels, and admin floor audit;
  • Table V2/browser surfaces and animation/timing gates.

Adding A Tournament Compliance Scenario

  1. Add the scenario or helper in src/game/verification/tournament-compliance.ts or an existing focused verification helper.
  2. Keep the scenario deterministic: seed cards, players, table ids, tournament ids, clocks, and audit inputs.
  3. Assert engine state plus any persisted DB, API, WebSocket, lobby/table UI, or timing contract affected by the rule.
  4. Run focused service/UI tests for the changed behavior, then run npm run gameplay:verify with the runtime up.

Concurrency and Locking

In-process lock

acquireTournamentLock(tournamentId, operation) in tournament-lock.service.ts is an in-memory lock (not distributed) with a 30-second TTL. Operations are start, unregistration, balance, consolidation and completion. It stops them running concurrently, for example two eliminations racing to trigger completeTournament().

If the lock is held when completion is attempted, handlePostElimination() registers a post-release hook that retries completion after the holder releases.

Serializable transactions

Player elimination, finish-position assignment and prize crediting use withSerializableTransaction() (from utils/retryable-transaction.ts). This automatically retries on serialization failures with exponential backoff.

Extending the Tournament System

Adding a new tournament format

  1. Add the format to the Prisma TournamentFormat enum and migrate.
  2. Add auto-start logic in canAutoStart() in tournament-state.service.ts.
  3. Add any format-specific restrictions in createTournament() in tournament.service.ts.

Adding a new prize type

  1. Add the type to the PrizeType enum in the Prisma schema.
  2. Update calculatePrizePool() in tournament-prize.service.ts to handle the new type.

Hooking into lifecycle events

Use registerStateCallbacks() for state-level hooks. For more granular hooks (per-hand, per-elimination), modify handlePostElimination() in tournament-flow.service.ts.

Key Files Reference

FilePurpose
src/services/tournament-state.service.tsState machine definition and transitions
src/services/tournament-flow.service.tsLifecycle orchestration
src/services/tournament-table.service.tsTable creation, balancing, final table
src/services/tournament-prize.service.tsPrize pool calculation and distribution
src/services/tournament-registration.service.tsPlayer registration
src/services/tournament-rebuy.service.tsRebuy, add-on, and re-entry processing
src/services/tournament-rule-profile.service.tsTournament rule-profile behavior resolver
src/services/tournament-hand-for-hand.service.tsHand-for-hand activation, table state, and synchronization
src/services/tournament-entry-audit.service.tsEntry/rebuy/add-on/re-entry audit persistence
src/services/tournament-seating-audit.service.tsSeating, balancing, breaking, and move audit persistence
src/services/tournament-floor-control.service.tsAdmin/floor controls, payout holds, idempotency, and audit trail
src/services/tournament-ticket.service.tsSatellite ticket management
src/services/satellite-prize.service.tsSatellite seat and remainder calculation
src/services/tournament-lock.service.tsIn-process concurrency lock
src/services/tournament.service.tsCRUD, blind math, config accessors
src/game/verification/tournament-compliance.tsTournament compliance verification scenarios
Try the live demo Pricing

Have questions about Poker Engine? We reply fast.