This guide covers the internals of the poker game engine: cards, hand evaluation, game state machine, pot management, action validation, and variant-specific behaviour.
The engine, WebSocket contract, and Table V2 align on all production-supported variants listed below. The GameType values come from the Prisma schema.
Production Runtime Support Matrix
This table describes the supported live runtime, not just evaluator coverage.
| Game Type | Exact production behavior |
|---|---|
TEXAS_HOLDEM | 52-card deck, 2 private hole cards, blinds, standard preflop/flop/turn/river progression, standard showdown, side pots, rake/no-flop-no-drop rules, reconnect-safe client state, and Table V2 board/chip/winner rendering. |
OMAHA | 4 private hole cards, board runout, exactly 2 hole plus 3 board evaluation, and pot-limit default. |
OMAHA_5 | 5 private hole cards, board runout, exactly 2 hole plus 3 board evaluation, pot-limit default, 8-seat cap for deck capacity, and Table V2 five-card private-card layout. |
OMAHA_HI_LO | 4 private hole cards, Omaha high-hand rules, pot-limit default, plus 8-or-better low qualification, split/scoop/no-low/tied-low showdown parity, and Table V2 Hi-Lo display. |
SHORT_DECK | 36-card deck with ranks 2-5 removed, 2 hole cards, no-limit default, ante-for-all plus button blind forced posts, flush beats full house, A-6-7-8-9 is the lowest straight, no compatibility SB/BB role badges, and normalized forced-bet animations. |
STUD_7_CARD | Third street deals two down and one up card, the lowest door card posts the bring-in (ties broken by suit: clubs, diamonds, hearts, spades, lowest first), fixed-limit default with completion through seventh street, per-card visibility is serialized in table state, and Table V2 renders BI/up-card/down-card state with no board. |
STUD_HI_LO | Stud runtime behavior plus 8-or-better low qualification, split/scoop/no-low/tied-low showdown parity, and Table V2 Hi-Lo display. |
MIXED_GAME | Ordered runtime rotation across supported component variants, rotating after handsPerVariant hands and only at hand boundaries, persisted current/next-game state, per-variant betting/dealing/card-count/seat-cap overrides, reconnect-safe mixed-game snapshots, and Table V2 active/next game labels with clean board/Stud layout resets. |
Card System
Source: src/game/engine/card.ts
Cards are typed strings: {Rank}{Suit} where:
- Rank:
2 3 4 5 6 7 8 9 T J Q K A - Suit:
S(spades),H(hearts),D(diamonds),C(clubs)
import { Card, parseCards, RANK_VALUES, RANKS, SUITS } from './game/engine/card.js';
// Card string type (compile-time safe)
type CardString = `${Rank}${Uppercase<Suit>}`;
// Examples: 'AS', 'KH', 'TD', '2C'
// Rank values for comparison
RANK_VALUES['A'] // 14
RANK_VALUES['T'] // 10
RANK_VALUES['2'] // 2
// Parse card strings to Card objects
const cards = parseCards(['AS', 'KH', 'QD']);
// cards[0].rank === 'A', cards[0].suit === 's', cards[0].value === 14
The Card class carries .rank, .suit, .value (numeric), and .toString().
Deck
Source: src/game/engine/deck.ts
import { Deck } from './game/engine/deck.js';
const deck = new Deck(); // 52-card standard deck
deck.shuffle(); // Fisher-Yates with crypto randomInt
const card = deck.deal(); // One card
const hand = deck.dealMany(4); // Array of 4 cards
deck.remaining // Cards left
// Short Deck: use createDeckForGameType('SHORT_DECK') for the 36-card deck
createDealingPattern(gameType) in dealing-engine.ts returns the pattern for the variant: HoldEmDealingPattern, OmahaDealingPattern (4 or 5 hole cards) or StudDealingPattern.
Hand Evaluation
Standard Evaluation
Source: src/game/hand-evaluator/evaluator.ts
import { evaluate5CardHand, evaluate7CardHand } from './game/hand-evaluator/evaluator.js';
import { HandRank, compareHands } from './game/hand-evaluator/hand-rank.js';
// Evaluate 5 cards exactly
const result = evaluate5CardHand(['AS', 'KS', 'QS', 'JS', 'TS']);
// { rank: HandRank.ROYAL_FLUSH, name: 'Royal Flush', values: [14], cards: [...] }
// Evaluate 7 cards — returns best 5-card combination from all C(7,5)=21
const result7 = evaluate7CardHand(['AS', 'KS', 'QS', 'JS', 'TS', '2H', '3D']);
// Finds best 5-card hand among all 21 combinations
// Compare two hands
const comparison = compareHands(handA, handB);
// > 0: handA wins, < 0: handB wins, 0: tie
Hand Rankings
| HandRank | Name | Example |
|---|---|---|
| 10 | Royal Flush | A K Q J T (suited) |
| 9 | Straight Flush | 9 8 7 6 5 (suited) |
| 8 | Four of a Kind | K K K K 3 |
| 7 | Full House | J J J 5 5 |
| 6 | Flush | A J 8 4 2 (suited) |
| 5 | Straight | T 9 8 7 6 |
| 4 | Three of a Kind | 7 7 7 K 2 |
| 3 | Two Pair | Q Q 6 6 A |
| 2 | One Pair | 9 9 A K J |
| 1 | High Card | A K J 7 4 |
The HandResult type:
interface HandResult {
rank: HandRank;
name: string;
values: number[]; // Tiebreaker values (high to low)
cards: string[]; // The 5 cards making up the hand
}
Evaluator Factory
Source: src/game/hand-evaluator/evaluator-factory.ts
Each poker variant has a dedicated evaluator. The factory returns the right one:
import { HandEvaluatorFactory } from './game/hand-evaluator/evaluator-factory.js';
import { GameType } from '@prisma/client';
const evaluator = HandEvaluatorFactory.create(GameType.OMAHA);
const result = evaluator.evaluate(holeCards, communityCards);
| Game Type | Evaluator | Rule |
|---|---|---|
TEXAS_HOLDEM | HoldEmEvaluator | Best of C(7,5)=21 from 2 hole + 5 community |
OMAHA | OmahaEvaluator | Must use exactly 2 of 4 hole cards + exactly 3 of 5 community — 60 combos |
OMAHA_5 | OmahaEvaluator | Must use exactly 2 of 5 hole cards + exactly 3 of 5 community — 100 combos |
OMAHA_HI_LO | OmahaEvaluator | 4 hole cards; same rule for high hand; low uses evaluateOmahaLowHand |
SHORT_DECK | ShortDeckEvaluator | Modified rankings: flush > full house; A-6-7-8-9 is lowest straight |
STUD_7_CARD | StudEvaluator | Best 5 of the player’s 7 cards, no community cards |
STUD_HI_LO | StudEvaluator | Same as Stud for high; low uses evaluateLowHand |
MIXED_GAME | Variant-specific evaluator | HandEvaluatorFactory.create has no MIXED_GAME case and throws; the table evaluates with the active component variant |
This table describes evaluator routing. Live runtime support is summarized in the production matrix above; Mixed Game does not have a standalone evaluator because it rotates among supported component variants and uses the active variant’s evaluator for each hand.
Short Deck Rankings
In Short Deck (6+ Hold’em), 2–5 are removed from the deck. The hand rankings change:
Royal Flush > Straight Flush > Four of a Kind > Flush > Full House > Straight > ...
Flush beats full house (flush is harder to make with only 36 cards). The lowest straight is A-6-7-8-9. Compare Short Deck hands with compareHandsForVariant(a, b, GameType.SHORT_DECK), because the plain compareHands uses standard rankings.
Lo-Hand Evaluation (Hi-Lo Variants)
Source: src/game/hand-evaluator/low-evaluator.ts
Uses "8-or-better" rules: 5 cards of distinct rank, all 8 or lower, with ace counting as 1.
import { evaluateLowHand, evaluateOmahaLowHand, compareLowHands } from './game/hand-evaluator/low-evaluator.js';
// For Stud Hi-Lo — any 5 cards from hole cards
const lowResult = evaluateLowHand(holeCards);
// null unless the cards contain 5 distinct ranks at 8 or below (ace = 1)
// { values: [5, 4, 3, 2, 1], name: 'Wheel (A-2-3-4-5)', cards: [...] }
// For Omaha Hi-Lo — enforces 2 hole + 3 community rule for low too
const lowResult = evaluateOmahaLowHand(holeCards, communityCards);
// Compare two low hands (lower is better)
compareLowHands(lowA, lowB); // > 0 if lowA is worse, < 0 if better
The best possible low is the "wheel": A-2-3-4-5.
Game State Machine
Source: src/game/flow/state-machine.ts, src/game/flow/game-state.ts
States
enum GameState {
WAITING = 'waiting', // No hand in progress
STARTING = 'starting', // Posting blinds/antes
PREFLOP = 'preflop', // Hole cards dealt
FLOP = 'flop', // 3 community cards
TURN = 'turn', // 4th community card
RIVER = 'river', // 5th community card
SHOWDOWN = 'showdown', // Cards revealed
COMPLETE = 'complete', // Pot distributed
PAUSED = 'paused', // Admin pause
CLOSED = 'closed', // Table closed
}
State Transitions
WAITING → STARTING → PREFLOP → FLOP → TURN → RIVER → SHOWDOWN → COMPLETE → WAITING
Any betting round → COMPLETE (early, all folded)
Any betting round → SHOWDOWN (all-in)
Any state except SHOWDOWN or CLOSED → PAUSED → (previous state)
WAITING/COMPLETE/PAUSED → CLOSED
Usage
import { createGameStateMachine, TransitionReason } from './game/flow/state-machine.js';
const machine = createGameStateMachine();
// Start a new hand
machine.startNewHand(); // WAITING → STARTING
machine.beginPreflop(); // STARTING → PREFLOP
// Progress through betting rounds
machine.progressToNextRound(); // PREFLOP → FLOP
machine.progressToNextRound(); // FLOP → TURN
machine.progressToNextRound(); // TURN → RIVER
machine.progressToNextRound(); // RIVER → SHOWDOWN
// Complete the hand
machine.completeHand(); // SHOWDOWN → COMPLETE
// Early end (everyone folded)
machine.endHandEarly({
reason: TransitionReason.ALL_FOLDED
});
// All-in showdown
machine.goToShowdown({
reason: TransitionReason.ALL_IN_SHOWDOWN
});
// Pause/Resume
machine.pause(); // → PAUSED (rejected from SHOWDOWN and CLOSED)
machine.resume(); // PAUSED → previous state
// Listen to transitions
machine.onStateChange((prev, next, context) => {
console.log(`${prev} → ${next}`, context?.reason);
});
Variant-Specific State Configs
Source: src/game/flow/variant-state-config.ts
Stud games have different street progressions (3rd street through 7th street). Each variant has a VariantStateConfig that maps round names to game states:
- Hold’em / Omaha / Short Deck:
preflop → flop → turn → river - 7-Card Stud:
third → fourth → fifth → sixth → seventh
Stud streets are mapped onto the base GameState enum for compatibility (third uses PREFLOP, fourth uses FLOP, fifth uses TURN, sixth uses RIVER, and seventh uses SHOWDOWN). That mapping is now an internal compatibility layer only; the supported production contract includes explicit street and bettingRound fields in hand-critical payloads so clients do not infer Stud semantics from raw GameState values.
Betting System
Betting Limits
Source: src/game/betting/bet-validator.ts
Three betting limit types:
type BettingLimit = 'no-limit' | 'pot-limit' | 'fixed-limit';
- No-Limit: any raise up to stack size
- Pot-Limit: the maximum is
calculatePotLimitMax(pot, callAmount), which ispot + 2 * callAmount - Fixed-Limit: pre-set bet sizes per street. The small bet applies to preflop and flop (Stud: third and fourth street), and the big bet to turn and river (Stud: fifth to seventh street, or fourth street with a pair showing). Raises are capped at
DEFAULT_MAX_RAISES_PER_ROUND(4) unless configured otherwise.
Default limits by variant come from getVariantBettingLimit: no-limit for TEXAS_HOLDEM and SHORT_DECK, pot-limit for OMAHA, OMAHA_5 and OMAHA_HI_LO, fixed-limit for STUD_7_CARD and STUD_HI_LO.
Action Validation
Source: src/game/betting/action-validator.ts
All player actions are validated server-side before being applied:
import { ActionValidator } from './game/betting/action-validator.js';
const validator = new ActionValidator();
const result = validator.validateAction(player, action, amount, context);
// result.valid: boolean
// result.error: string (if invalid)
// result.errorCode: ActionValidationErrorCode
// result.adjustedAmount: number (for all-in situations)
// result.isAllIn: boolean
Validation checks:
- Is it this player’s turn?
- Is the game in a betting round?
- Is the game paused?
- Is the bet amount valid for the current betting limit?
- Does the player have sufficient stack?
- Is the raise amount at least the minimum raise?
Pot Management
Source: src/game/betting/pot.ts
The Pot class tracks contributions and calculates main pot / side pots for all-in situations:
import { Pot } from './game/betting/pot.js';
const pot = new Pot();
// Add contributions (isAllIn=true creates a side pot cap)
pot.addContribution('player1', 100, false);
pot.addContribution('player2', 50, true); // All-in for 50
pot.addContribution('player3', 100, false);
// Get pots with eligible players
const pots = pot.getPots();
// [
// { amount: 150, eligiblePlayers: ['player1','player2','player3'], name: 'Main Pot' },
// { amount: 100, eligiblePlayers: ['player1','player3'], name: 'Side Pot 1' }
// ]
pot.total // Total amount in all pots
Levels come from all-in totals only. Each pot is contested only by players who reached that level, so a player can never win more from an opponent than they put in themselves. A pot with a single eligible player is an uncalled bet: it goes back to that player, is not raked, and is flagged isUncalledReturn in the showdown result.
Odd chip distribution: When a pot splits unevenly, the odd chips go to the first winner clockwise from the dealer button (tracked via PositionInfo). In Hi-Lo pots, the odd chip of a high/low split goes to the high side.
Rake
Source: src/game/betting/rake.ts
Each table is configured with rakePercent, rakeCap, headsUpRakePercent and noFlopNoDrop. The table takes rake once per hand:
- Rake is calculated on the contested total. Uncalled returns are excluded and paid back at face value.
- The amount is
floor(contested * percent / 100), limited by the cap. A cap of 0 means no cap. headsUpRakePercentreplacesrakePercentwhen exactly 2 players are still in the hand at settlement.- No flop, no drop is on by default: no rake unless the hand saw a flop. In Stud, fourth street counts as the flop.
- Each winner’s share of the contested pot is reduced in proportion, rounded down, and any remainder goes to the first contested winner after the button.
calculateMultiplePots and calculateWithCumulativeCap also exist on RakeCalculator, but the live table flow uses calculate.
Showdown
Source: src/game/flow/showdown.ts
ShowdownManager has two showdown paths. Cards speak: hands are evaluated by the server, and any declaredHand text is ignored.
All-In Runout
When at most one player can still act and the others are all-in, the table stops betting and runs out the remaining streets. Before dealing them, it emits a showdown event with revealTiming: 'all_in_before_runout' that reveals the hole cards of every player still in the hand. A second showdown event follows the runout with hand names and winners.
Reveal Rules
Show order starts with the last aggressor and continues clockwise. With no aggressor, the earliest seat shows first. ShowdownOptions controls who has to show:
| Option | Default | Effect |
|---|---|---|
allowMuck | true | When false, every hand is shown |
showAllHandsAtShowdown | false | When true, every hand is shown |
autoMuckEnabled | false in DEFAULT_SHOWDOWN_OPTIONS; Table defaults it to true | Losing hands that are not required to show are mucked |
lastAggressorShowsFirst | true | Last aggressor leads the show order |
allInShowdownRequiresShow | true | All-in hands are always shown |
contestedWinnerMustShow | true | Winners of contested pots are always shown |
muckedHandRevealDelayMs | 5000 | Client delay for mucked-hand UI |
Each decision is returned in revealActions, with reasons such as last_aggressor, all_in_required, winner_required and auto_muck_loser. If only one player is left, no hand is evaluated or shown and the other hands are reported as folded_hidden.
Standard Showdown (runShowdown)
Used for Texas Hold’em, Omaha, Short Deck, 7-Card Stud:
import { createShowdownManager } from './game/flow/showdown.js';
const showdown = createShowdownManager({ autoMuckEnabled: true });
const result: ShowdownResult = showdown.runShowdown(
players, // ShowdownPlayer[]
communityCards,
pot, // Pot
lastAggressor, // string | null
positionInfo, // PositionInfo
gameType // GameType
);
// result.evaluatedHands: EvaluatedHand[]
// result.winners: PotWinner[]
// result.winningHand: HandResult | null
// result.muckedPlayers: string[]
// result.totalDistributed: number
// result.showOrder: string[]
// result.revealActions: ShowdownRevealAction[]
// result.allInPlayerIds: string[]
// result.oddChips: ShowdownOddChip[]
Hi-Lo Showdown (runHiLoShowdown)
Used for Omaha Hi-Lo and Stud Hi-Lo. Splits each pot between best high hand and best qualifying low hand (8-or-better):
const result: HiLoShowdownResult = showdown.runHiLoShowdown(
players,
communityCards,
pot,
gameType,
positionInfo,
lastAggressor
);
// result.winners: HiLoPotWinner[] (winType: 'high' | 'low')
// result.winningHighHand: HandResult | null
// result.winningLowHand: LowHandResult | null
// result.hasQualifyingLow: boolean
Each pot is judged separately among its eligible players. If none of them has a qualifying low, the high hand takes that whole pot. Otherwise the pot splits in half, with the odd chip to the high side, and each half is divided among its own tied winners.
Mixed Games
Source: src/game/flow/mixed-game-engine.ts
Mixed games (MIXED_GAME) are supported runtime behavior. The mixed-game engine owns ordered variant rotation, current/next-game tracking, and hand-boundary-only variant changes.
Current production behavior:
- table creation accepts
MIXED_GAMEwith an ordered rotation config - rotation happens only after a hand completes
- the active hand keeps one concrete variant from start to finish
- current and next variant state is persisted for reconnect and restart recovery
- per-variant overrides can change blinds, antes, bring-in/completion, betting limits, card/dealing rules, and seat caps
table_stateincludes mixed-game metadata for seated players and spectators- Table V2 renders active/next-game labels and swaps between board and Stud layouts as the rotation changes
The supported component variants in rotation are Hold’em, Omaha, PLO5, Omaha Hi-Lo, Stud, Stud Hi-Lo, and Short Deck. CUSTOM_MIXED_ROTATION is the default order and leaves out PLO5. OMAHA_5 and Stud components cap at 8 seats, all others at 10. Named formats such as HORSE and 8-Game are not available, because they need components the engine does not have (Razz, 2-7 Triple Draw).
Action Timer
Source: src/game/flow/action-timer.ts
Manages per-player action timeouts and time banks. When a player’s timer expires, the server checks if checking is free and folds otherwise (src/game/flow/auto-fold.ts). Three consecutive timeouts mark the player for sit-out by default (sitOutAfterTimeouts). Timer settings come from the database via game-settings.service.ts; the defaults are defaultMoveTimeSeconds 30, timeBankSeconds 60 and warningTimeSeconds 10.
Turn Management
Source: src/game/flow/turn-manager.ts
Determines seat order for betting rounds:
- Post-flop: action starts with the first active player after the dealer button
- Pre-flop: action starts with the first player after the big blind
- Short Deck pre-flop: action starts with the first player after the button
- Stud third street: action continues clockwise from the bring-in
- Stud fourth to seventh street: the highest visible hand acts first
- Skips folded and all-in players
Back to Developer Guide