Enterprise-grade Texas Hold'em poker game engine
A production-ready poker game engine featuring immutable state management, chip conservation auditing, side pot calculation, rake handling, tournament support, and comprehensive rule enforcement.
- β¨ Features
- π¦ Installation
- π Quick Start
- ποΈ Architecture
- π API Reference
- π° Money Handling
- π Tournament Mode
- π Browser Usage
- β Error Handling
- π΄ Action Types
- π Hand History Export
- π§ Utilities
- π§ͺ Testing
- π State Structure
- π Related Packages
- π License
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β ENGINE FEATURES β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β π° Complete Texas Hold'em Implementation β
β β»οΈ Immutable State Machine (Redux-style) β
β π° Chip Conservation Auditing β
β π¦ Side Pot Calculation β
β π Rake Support (% + cap + noFlopNoDrop) β
β π Tournament Mode (blind structure) β
β π View Masking (anti-cheat) β
β πΎ Snapshot Serialization β
β β©οΈ Undo Support β
β π Hand History Export (JSON, PokerStars) β
β π Browser Support (Web Crypto RNG) β
β π Type-safe Error Handling β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
npm install @pokertools/engineyarn add @pokertools/enginepnpm add @pokertools/engineimport { PokerEngine, ActionType } from "@pokertools/engine";
// Create a cash game table
const engine = new PokerEngine({
smallBlind: 1,
bigBlind: 2,
maxPlayers: 6,
});
// Seat players
engine.sit(0, "alice", "Alice", 200);
engine.sit(1, "bob", "Bob", 200);
// Deal a hand
engine.deal();
// Get current state
console.log(engine.state.street); // "PREFLOP"
console.log(engine.state.actionTo); // 0 (Alice's turn)
// Execute actions
engine.act({ type: ActionType.CALL, playerId: "alice" });
engine.act({ type: ActionType.CHECK, playerId: "bob" });
// Get player view (masked for opponents)
const aliceView = engine.view("alice");βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β ARCHITECTURE β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
ββββββββββββββββββββββββββββββββ
β PokerEngine β
β (Stateful API Wrapper) β
ββββββββββββββββ¬ββββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββββββ
β gameReducer β
β f(state, action) => state β
β (Pure Function) β
ββββββββββββββββ¬ββββββββββββββββ
β
βββββββββββββββββββββββββββΌββββββββββββββββββββββββββ
β β β
βΌ βΌ βΌ
βββββββββββββββββββ βββββββββββββββββββ βββββββββββββββββββ
β Actions β β Rules β β Utilities β
βββββββββββββββββββ€ βββββββββββββββββββ€ βββββββββββββββββββ€
β β’ betting.ts β β β’ actionOrder β β β’ viewMasking β
β β’ dealing.ts β β β’ blinds β β β’ serialization β
β β’ management.ts β β β’ headsUp β β β’ invariants β
β β’ showdown.ts β β β’ showdown β β β’ rake β
β β’ special.ts β β β’ sidePots β β β’ deck β
β β’ tournament.ts β βββββββββββββββββββ β β’ cardUtils β
βββββββββββββββββββ βββββββββββββββββββ
βββββββββββ βββββββββββ βββββββββββ βββββββββββ ββββββββββββ
β PREFLOP β βββΆ β FLOP β βββΆ β TURN β βββΆ β RIVER β βββΆ β SHOWDOWN β
βββββββββββ βββββββββββ βββββββββββ βββββββββββ ββββββββββββ
β β
β All but one fold β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
(Award pot)
import { PokerEngine } from "@pokertools/engine";
const engine = new PokerEngine(config: TableConfig, timeProvider?: () => number);TableConfig Options:
| Option | Type | Default | Description |
|---|---|---|---|
smallBlind |
number |
required | Small blind amount |
bigBlind |
number |
required | Big blind amount |
ante |
number |
0 |
Ante per player |
maxPlayers |
number |
9 |
Maximum seats (2-10) |
blindStructure |
BlindLevel[] |
- | Tournament blind levels |
timeBankSeconds |
number |
30 |
Time bank per player |
rakePercent |
number |
0 |
Rake percentage (0-100) |
rakeCap |
number |
- | Maximum rake per pot |
noFlopNoDrop |
boolean |
true |
No rake if hand ends preflop |
randomProvider |
() => number |
getSecureRandom() |
RNG function (Node.js crypto; never Math.random) |
validateIntegrity |
boolean |
true |
Enable chip auditing |
isClient |
boolean |
false |
Client/optimistic mode |
Add a player to the table.
engine.sit(0, "user123", "Alice", 1000);Remove a player from the table.
engine.stand("user123");Deal a new hand.
engine.deal();Execute a game action.
// Fold
engine.act({ type: ActionType.FOLD, playerId: "user123" });
// Check
engine.act({ type: ActionType.CHECK, playerId: "user123" });
// Call
engine.act({ type: ActionType.CALL, playerId: "user123" });
// Bet (opening bet)
engine.act({ type: ActionType.BET, playerId: "user123", amount: 100 });
// Raise
engine.act({ type: ActionType.RAISE, playerId: "user123", amount: 200 });
// Show cards at showdown
engine.act({ type: ActionType.SHOW, playerId: "user123", cardIndices: [0, 1] });
// Muck cards at showdown
engine.act({ type: ActionType.MUCK, playerId: "user123" });
// Activate time bank
engine.act({ type: ActionType.TIME_BANK, playerId: "user123" });Get full unmasked game state.
const state = engine.state;
console.log(state.street); // "PREFLOP" | "FLOP" | "TURN" | "RIVER" | "SHOWDOWN"
console.log(state.actionTo); // Seat number of current actor
console.log(state.board); // Community cards ["As", "Kd", "Qh"]
console.log(state.pots); // Array of pot objects
console.log(state.winners); // null or Winner[] after showdownGet player-specific view with opponent cards masked.
// Player view (sees own cards)
const aliceView = engine.view("alice");
// Spectator view (all hole cards hidden)
const spectatorView = engine.view();
// With version number for sync
const versioned = engine.view("alice", 42);Check if action is valid without executing.
const result = engine.validate({
type: ActionType.BET,
playerId: "alice",
amount: 100,
});
if (result.valid) {
// Action can be executed
} else {
console.log(result.error); // "Cannot bet, there's already a bet to call"
console.log(result.code); // "CANNOT_BET"
}Get serializable snapshot.
const snapshot = engine.snapshot;
localStorage.setItem("game", JSON.stringify(snapshot));Restore from snapshot.
const saved = JSON.parse(localStorage.getItem("game")!);
const engine = PokerEngine.restore(saved);Subscribe to state changes.
const unsubscribe = engine.on((action, oldState, newState) => {
console.log(`Action: ${action.type}`);
console.log(`Street: ${oldState.street} -> ${newState.street}`);
});
// Later: unsubscribe
unsubscribe();Undo last action.
const success = engine.undo();
if (success) {
console.log("Action undone");
}Advance to next blind level.
engine.nextBlindLevel();
console.log(engine.state.blindLevel); // 1
console.log(engine.state.bigBlind); // UpdatedExport hand history in various formats.
// JSON format (default)
const json = engine.history();
// PokerStars format
const ps = engine.history({ format: "pokerstars" });
// Compact JSON
const compact = engine.history({ format: "compact" });Get structured history object.
const history = engine.getHandHistory();
console.log(history.handId);
console.log(history.winners);
console.log(history.streets);Preview action result without modifying state.
const preview = engine.optimisticAct({
type: ActionType.BET,
playerId: "alice",
amount: 100,
});
// preview contains new state
// engine.state is unchangedMerge server state into client engine.
// After receiving state from server
engine.reconcile(serverState);The engine enforces strict chip conservation:
β(player.stack) + β(pot.amount) + β(currentBets) + rake = constant
Any violation throws CriticalStateError.
Automatic side pot calculation for all-in scenarios:
// Example: 3 players with different stacks
// Alice: 100 (all-in)
// Bob: 300 (all-in)
// Charlie: 500 (active)
// Results in:
// Main Pot: 300 (100 Γ 3) - Alice, Bob, Charlie eligible
// Side Pot: 400 (200 Γ 2) - Bob, Charlie eligible
// Uncalled: 200 - returned to Charlieconst engine = new PokerEngine({
smallBlind: 1,
bigBlind: 2,
rakePercent: 5, // 5% rake
rakeCap: 10, // Max $10 per pot
noFlopNoDrop: true, // No rake if ends preflop
});const tournament = new PokerEngine({
smallBlind: 25,
bigBlind: 50,
ante: 5,
maxPlayers: 9,
blindStructure: [
{ smallBlind: 25, bigBlind: 50, ante: 5 },
{ smallBlind: 50, bigBlind: 100, ante: 10 },
{ smallBlind: 75, bigBlind: 150, ante: 15 },
{ smallBlind: 100, bigBlind: 200, ante: 25 },
{ smallBlind: 150, bigBlind: 300, ante: 50 },
],
});
// Seat players with their starting stacks
tournament.sit(0, "alice", "Alice", 10000);
tournament.sit(1, "bob", "Bob", 10000);
// Advance blinds (e.g., on timer)
tournament.nextBlindLevel();Tournament-specific rules:
- Sitting-out players must post blinds/antes
- Dead button rule for empty seats
- No rake
import { createBrowserEngine } from "@pokertools/engine/browser";
// Uses Web Crypto API for secure RNG
const engine = createBrowserEngine({
smallBlind: 1,
bigBlind: 2,
});| Error | Description |
|---|---|
IllegalActionError |
Invalid game action (send to client) |
CriticalStateError |
Engine invariant violated (should never happen) |
ConfigError |
Invalid configuration |
import { ErrorCodes } from "@pokertools/engine";
try {
engine.act({ type: ActionType.CHECK, playerId: "alice" });
} catch (err) {
if (err instanceof IllegalActionError) {
switch (err.code) {
case ErrorCodes.NOT_YOUR_TURN:
showMessage("Wait for your turn");
break;
case ErrorCodes.CANNOT_CHECK:
showMessage("You must call or fold");
break;
case ErrorCodes.BET_TOO_SMALL:
showMessage(`Minimum bet is ${err.context.minBet}`);
break;
}
}
}Available Error Codes:
| Category | Codes |
|---|---|
| Player | PLAYER_NOT_FOUND, NOT_YOUR_TURN, PLAYER_NOT_ACTIVE, NOT_SEATED, NO_CHIPS |
| Betting | CANNOT_CHECK, NOTHING_TO_CALL, BET_TOO_SMALL, CANNOT_RAISE, CANNOT_RERAISE, RAISE_TOO_SMALL |
| Deal | CANNOT_DEAL, NOT_ENOUGH_PLAYERS |
| Seat | INVALID_SEAT, SEAT_OCCUPIED, INVALID_STACK |
| Validation | INVALID_ACTION, INVALID_AMOUNT, INVALID_TIMESTAMP |
import { ActionType } from "@pokertools/engine";
enum ActionType {
// Management
SIT = "SIT",
STAND = "STAND",
ADD_CHIPS = "ADD_CHIPS",
RESERVE_SEAT = "RESERVE_SEAT",
// Dealing
DEAL = "DEAL",
// Betting
FOLD = "FOLD",
CHECK = "CHECK",
CALL = "CALL",
BET = "BET",
RAISE = "RAISE",
// Showdown
SHOW = "SHOW",
MUCK = "MUCK",
// Special
TIMEOUT = "TIMEOUT",
TIME_BANK = "TIME_BANK",
UNCALLED_BET_RETURNED = "UNCALLED_BET_RETURNED",
// Tournament
NEXT_BLIND_LEVEL = "NEXT_BLIND_LEVEL",
}const history = engine.history({ format: "json" });{
"handId": "hand-1734012345678-123456",
"timestamp": 1734012345678,
"tableName": "Table 1",
"gameType": "Cash",
"stakes": { "smallBlind": 1, "bigBlind": 2, "ante": 0 },
"buttonSeat": 0,
"players": [
{ "seat": 0, "name": "Alice", "startingStack": 200, "endingStack": 220 },
{ "seat": 1, "name": "Bob", "startingStack": 200, "endingStack": 180 }
],
"streets": [
{
"street": "PREFLOP",
"board": [],
"actions": [...]
}
],
"winners": [
{ "seat": 0, "playerName": "Alice", "amount": 20, "hand": ["As", "Kd"], "handRank": "Two Pair" }
],
"totalPot": 40
}const history = engine.history({ format: "pokerstars" });PokerStars Hand #hand-1734012345678: Hold'em No Limit ($1/$2 USD)
Table 'Table 1' 6-max Seat #1 is the button
Seat 1: Alice ($200 in chips)
Seat 2: Bob ($200 in chips)
Alice: posts small blind $1
Bob: posts big blind $2
*** HOLE CARDS ***
Dealt to Alice [As Kd]
Alice: calls $1
Bob: checks
*** FLOP *** [Qh Jc Ts]
...
import { createPublicView } from "@pokertools/engine";
// Create masked view for specific player
const aliceView = createPublicView(state, "alice");
// Spectator view (all cards hidden)
const spectatorView = createPublicView(state, null);import { calculateTotalChips, auditChipConservation } from "@pokertools/engine";
// Get total chips in game
const total = calculateTotalChips(state);
// Verify chip conservation (throws on failure)
auditChipConservation(state, expectedTotal);import { createSnapshot, restoreFromSnapshot } from "@pokertools/engine";
// Serialize
const snapshot = createSnapshot(state);
const json = JSON.stringify(snapshot);
// Deserialize
const restored = restoreFromSnapshot(JSON.parse(json));The engine includes 362 tests across 38 suites:
| Category | Files | Description |
|---|---|---|
| Unit | 27 | Individual component tests |
| Integration | 4 | Full game flow tests |
| Property | 3 | Randomized invariant testing |
| Bug Regression | 2 | Fixed bug verification |
| Security | 2 | Anti-cheat/exploit tests |
npm test -w @pokertools/engineinterface GameState {
// Configuration
config: TableConfig;
players: (Player | null)[];
maxPlayers: number;
// Hand State
handNumber: number;
buttonSeat: number | null;
deck: number[]; // Card codes (server only)
board: string[]; // Community cards
street: Street;
// Betting
pots: Pot[];
currentBets: Map<number, number>;
minRaise: number;
lastRaiseAmount: number;
actionTo: number | null;
lastAggressorSeat: number | null;
// Progress
activePlayers: number[];
winners: Winner[] | null;
rakeThisHand: number;
// Blinds
smallBlind: number;
bigBlind: number;
ante: number;
blindLevel: number;
// Time Bank
timeBanks: Map<number, number>;
timeBankActiveSeat: number | null;
// History
actionHistory: ActionRecord[];
previousStates: GameState[]; // For undo
// Chip Conservation
initialChips?: number; // Baseline for auditing (set at deal)
// Metadata
timestamp: number;
handId: string;
}| Package | Description |
|---|---|
| @pokertools/types | Type definitions |
| @pokertools/evaluator | Hand evaluation |
| @pokertools/api | REST/WebSocket API |
MIT Β© A.Aurelius