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
| State | Description |
|---|---|
REGISTRATION | Open for player sign-up. |
RUNNING | Hands are being played. |
PAUSED | Admin-paused. Timer state is persisted; resumes without losing level progress. |
PRIZES_PENDING | Play 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. |
COMPLETED | Terminal. All prizes credited or awarded. |
CANCELED | Terminal. 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
startTimeand aminPlayersrequirement.canAutoStart()passes once the start time has been reached andminPlayersare registered. checkAndStartTournament()andgetTournamentsReadyToStart()implement that check. The server code path that starts a scheduled tournament is the admin routePOST /api/v1/admin/tournaments/:id/start.- Supports late registration, rebuy, re-entry, and add-on windows.
SIT_AND_GO
- Requires
minPlayers === maxPlayersand starts when the field is full. - A 5-second countdown is scheduled via
scheduleSnGStart(), and lobby subscribers get atournament_startingevent. - Late registration, rebuy, re-entry, and add-ons are forced off (
isSnGflag increateTournament). SNG_PRESETSandSNG_PRIZE_STRUCTURESintournament-config.service.tsdefine 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:
- Calculates
tableCount = ceil(playerCount / playersPerTable). - Shuffles player order with crypto-secure randomness.
- Creates the tables in a database transaction.
- Splits players evenly (
calculateSeatDistribution), with any extras on the first tables, and assigns random seats. - Returns
TableCreationResult, whosetablesareTournamentTableWithSeats[].
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:
- Increments
currentLeveland resetscurrentLevelStartedAtin the database (optimistic locking). - Calls
calculateBlindLevel()to get new values. - Updates the database via
updateTournamentBlinds(), except for tables with a hand in progress. - Queues the config on in-memory
Tableinstances withtable.queueConfigUpdate(). Idle tables apply it at once; tables mid-hand apply it when the hand completes. - Broadcasts
tournament_blind_changeto each table, witheffectiveAt(next_handorimmediate),appliedTableIdsandpendingTableIds.
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 source | Purpose |
|---|---|
TournamentSeatingAudit | Initial seating, balance moves, table breaks, late-registration seating, re-entry seating, busted removals. |
TournamentEntryAudit | Initial registration, late registration, rebuy, add-on, and re-entry. |
TournamentFloorAudit | Admin/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 endpoints | Level advance, break, pause, resume, and clock state visible to admins. |
| Game/deck procedure audits | Misdeal, fouled deck, duplicate card, premature board/street, missing burn, repair/void controls, and settlement blockers. |
| Hand history/replay metadata | Showdown 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:
- Set
isEliminated = true,eliminatedAt = now. - Count remaining non-eliminated players.
- Set
finishPosition = remainingCount + 1. - 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+targetTournamentIdrequired.- Buy-in must be less than target tournament buy-in.
- The target cannot be completed, canceled, or itself a satellite.
prizeDistributionis 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 awardsTournamentTicketrecords 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:
- Calculates each position’s amount as
totalPrizePool * percentage / 100. - Finds finishers whose
finishPositionis within the paid positions. - Shares payouts between tied finishers: the payouts for the places they cover are added together and divided equally.
- Credits the tournament’s wallet currency (
USDandGOLDcredit Gold,DIAMONDcredits Diamond) and writesTOURNAMENT_PRIZEledger rows with an idempotency key. - Creates
TournamentResultrows and setspayoutAmounton the registration. - 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
- Add the scenario or helper in
src/game/verification/tournament-compliance.tsor an existing focused verification helper. - Keep the scenario deterministic: seed cards, players, table ids, tournament ids, clocks, and audit inputs.
- Assert engine state plus any persisted DB, API, WebSocket, lobby/table UI, or timing contract affected by the rule.
- Run focused service/UI tests for the changed behavior, then run
npm run gameplay:verifywith 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
- Add the format to the Prisma
TournamentFormatenum and migrate. - Add auto-start logic in
canAutoStart()intournament-state.service.ts. - Add any format-specific restrictions in
createTournament()intournament.service.ts.
Adding a new prize type
- Add the type to the
PrizeTypeenum in the Prisma schema. - Update
calculatePrizePool()intournament-prize.service.tsto 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
| File | Purpose |
|---|---|
src/services/tournament-state.service.ts | State machine definition and transitions |
src/services/tournament-flow.service.ts | Lifecycle orchestration |
src/services/tournament-table.service.ts | Table creation, balancing, final table |
src/services/tournament-prize.service.ts | Prize pool calculation and distribution |
src/services/tournament-registration.service.ts | Player registration |
src/services/tournament-rebuy.service.ts | Rebuy, add-on, and re-entry processing |
src/services/tournament-rule-profile.service.ts | Tournament rule-profile behavior resolver |
src/services/tournament-hand-for-hand.service.ts | Hand-for-hand activation, table state, and synchronization |
src/services/tournament-entry-audit.service.ts | Entry/rebuy/add-on/re-entry audit persistence |
src/services/tournament-seating-audit.service.ts | Seating, balancing, breaking, and move audit persistence |
src/services/tournament-floor-control.service.ts | Admin/floor controls, payout holds, idempotency, and audit trail |
src/services/tournament-ticket.service.ts | Satellite ticket management |
src/services/satellite-prize.service.ts | Satellite seat and remainder calculation |
src/services/tournament-lock.service.ts | In-process concurrency lock |
src/services/tournament.service.ts | CRUD, blind math, config accessors |
src/game/verification/tournament-compliance.ts | Tournament compliance verification scenarios |