To design a poker game engine that supports multiple variants (Texas Hold'em, Omaha, Short Deck, OFC) without code duplication, you must adopt a Component-Based Architecture driven by a Strategy Pattern for variant-specific logic. The core engine handles universal game flow (deal, betting rounds, pot management, side pots), while variant-specific rules are injected as pluggable modules.
Core Architecture: The "Universal Engine" Pattern
The fundamental challenge is separating game state from game rules. The engine should treat a "Hand" as a generic sequence of actions and cards, while a "Variant Controller" interprets those actions based on specific rules.
1. The Domain Model (Shared Layer)
This layer contains entities common to all poker variants. These are immutable data structures or state machines that never change regardless of the variant.
-
Card: Rank, Suit. -
Player: ID, Stack, Seat position, CurrentBet. -
Pot: Main pot, side pots, contribution map. -
GameRound: Pre-flop, Flop, Turn, River, Showdown. -
Action: Type (Check, Call, Raise, Fold, All-in), Amount, Timestamp.
2. The Strategy Pattern (Variant Abstraction)
Define abstract interfaces that every poker variant must implement. The engine calls these interfaces without knowing the specific variant.
// Interface definition
interface IPokerVariant {
getInitialDealCount(): number;
getCommunityCardDistribution(): number[]; // e.g., [3, 1, 1] for Hold'em
validateHand(playerCards: Card[], communityCards: Card[]): HandEvaluation;
getValidActions(gameState: GameState, player: Player): Action[];
calculateWinners(pot: Pot, hands: HandEvaluation[]): Winner[];
}
3. Implementation Workflow
A. State Machine & Event Sourcing
The core engine runs a deterministic state machine. It does not contain if (variant == 'Holdem') logic. Instead, it emits events based on the current state.
- Initialization: Engine loads
IPokerVariantimplementation (e.g.,HoldemVariant). - Deal Phase: Engine calls
variant.getInitialDealCount(). It deals that many cards to players. - Community Dealing: Engine iterates through
variant.getCommunityCardDistribution(). It deals 3 cards (Flop), pauses, then 1 (Turn), etc. - Betting Logic: The engine manages the "Acting Player" and "Bet Amount" but delegates validity to the variant.
- Engine: "Player X wants to raise."
- Variant:
validateActions()checks if the raise meets the minimum (e.g., 2x big blind in Hold'em vs. specific rules in OFC).
- Showdown: Engine calls
variant.calculateWinners().
B. Hand Evaluation Engine
This is the most complex part. Do not hardcode logic for every variant.
- Hold'em/Omaha: Use a pre-calculated lookup table or a SIMD-optimized evaluator (e.g.,
replay-jsorpoker-eval). - OFC (Open Face Chinese): The logic is completely different (scoring rows). This is treated as a separate evaluator module.
- Short Deck: Adjust the rank mapping (remove 2-5) and flush/straight rules before passing to the standard evaluator.
Pseudocode for the Engine Loop:
class PokerEngine {
constructor(variant: IPokerVariant, roomConfig) {
this.variant = variant;
this.state = new GameState();
}
async processAction(playerId: string, action: Action) {
// 1. Validate generic constraints (time, turn order)
if (!this.state.isTurn(playerId)) throw new Error("Not your turn");
// 2. Delegate variant-specific validation
const validActions = this.variant.getValidActions(this.state, this.state.getPlayer(playerId));
if (!validActions.includes(action.type)) throw new Error("Invalid move for this variant");
// 3. Apply action to state (Universal logic)
this.state.applyAction(action);
this.emit('action_applied', action);
// 4. Check for round completion
if (this.state.isBettingRoundComplete()) {
await this.dealNextStreet();
}
}
async dealNextStreet() {
const communityCount = this.variant.getCommunityCardDistribution()[this.state.streetIndex];
const newCards = this.deck.draw(communityCount);
this.state.addCommunityCards(newCards);
// Variant might have special rules here (e.g., burn card)
if (this.variant.needsBurnCard()) {
this.deck.discard();
}
this.resetBettingRound();
}
async resolveShowdown() {
const hands = this.state.players.map(p => ({
player: p,
evaluation: this.variant.evaluate(p.cards, this.state.communityCards)
}));
const winners = this.variant.calculateWinners(this.state.pot, hands);
this.state.distributePot(winners);
}
}
Technical Deep Dive: Avoiding Duplication
1. Rule Configuration via JSON/Schema
Instead of hardcoding logic, define variant rules in a schema.
- Deck Composition:
["A", "K", "Q", ...](Standard) vs["A", "K", "Q", "J", "T", "9", "8", "7", "6", "A"](Short Deck). - Hand Rankings: Weighted scores for pairs, straights, etc.
- Betting Structures: No-limit, Pot-limit, Fixed-limit logic.
This allows you to add a new variant (e.g., "6+ Hold'em") by loading a config file and a small evaluator override, rather than rewriting the engine.
2. The Evaluator Pattern
For high-performance hand evaluation, use a Lookup Table approach.
- Generate all possible hand combinations for the specific variant.
- Hash the 7 cards (2 hole + 5 board) into an integer.
- Look up the hand strength in a pre-computed array.
- Optimization: For variants like OFC, the evaluation is not just "best 5 cards" but a scoring algorithm. Implement this as a separate service injected into the engine.
3. Networking & Real-Time Sync
- Protocol: Use WebSockets (Socket.IO or raw
wsin Node.js) or gRPC-Web for low latency. - State Synchronization: The server is the source of truth. Clients send intent (e.g., "Raise 100"), not state updates.
- Determinism: If the server crashes, the game state can be reconstructed by replaying the event log (Event Sourcing) using the same
IPokerVariantlogic. This is crucial for compliance and dispute resolution.
Scalability & Performance Considerations
- Memory Management:
- Game state objects should be lightweight.
- Use Object Pooling for
CardandActionobjects to prevent Garbage Collection (GC) pauses in Node.js or Go, which can cause lag in real-time games.
- Concurrency:
- In Node.js, use
worker_threadsfor hand evaluation if the CPU is the bottleneck (e.g., evaluating thousands of hands for fairness audits or AI training). - In Go, leverage goroutines for each table; the engine logic is CPU-bound but I/O is minimal per action.
- In Node.js, use
- Database Design:
- Hot Path (Redis): Store current game state, player actions, and active pots. Use Redis Streams for action logging.
- Cold Path (PostgreSQL): Store finalized hand histories, player statistics, and transaction logs.
- Sharding: Shard by
table_idorregionto distribute load.
Security & Anti-Collusion
- RNG Certification: The RNG (Random Number Generator) must be cryptographically secure (e.g.,
crypto.randomBytesin Node.js,math/rand/v2with hardware seed in Go). It must be independently certified (GLI-11, eCOGRA). - Card Distribution: Never send hole cards to other players. The server sends only the cards relevant to the client.
- Collusion Detection:
- Track "Seat History": If Player A and Player B always fold when Player C raises, flag it.
- Analyze "Chip Transfer": Detect abnormal chip flow between accounts.
- Implement a background analytics service that consumes the event stream and runs anomaly detection algorithms (e.g., clustering analysis).
Example: Adding "Short Deck" (6+ Hold'em)
- Define Deck: Remove 2, 3, 4, 5.
- Override Rules:
- Flush beats Full House.
- A-6-7-8-9 is a straight (lowest).
- Ante is usually mandatory.
- Inject:
- Create
ShortDeckVariantclass implementingIPokerVariant. - Update the evaluator to use the new hand hierarchy.
- No changes to the core
PokerEngineclass.
- Create
This architecture ensures that the core logic remains stable while variant rules are modular, allowing rapid iteration and deployment of new poker games without risking the stability of the entire platform.
Top comments (0)