Modeling betting rounds using a Finite State Machine (FSM) is the industry standard for ensuring deterministic, bug-free poker logic. The core concept is to treat every possible game situation as a distinct State and every player action as a Transition that moves the system to a new state. This eliminates "if-else" hell and guarantees that invalid actions (like raising before the flop in a specific variant) are mathematically impossible to execute.
1. The Core FSM Architecture
In a poker engine, the betting round is not a loop; it is a state machine where the current state dictates valid transitions.
State Definition
The state must capture the exact context of the round, not just the current pot size. A robust state tuple looks like this:
type BettingState = {
street: 'preflop' | 'flop' | 'turn' | 'river';
currentActorId: string;
highestBet: number; // The current best bet in the round
minRaise: number; // Minimum legal raise amount
activePlayers: Set<string>; // Players still in the hand
lastAggressorId: string | null; // Who made the last raise?
actionCount: number; // Used to detect round completion (everyone acted)
roundStatus: 'active' | 'completed' | 'all_in';
};
Transition Logic
Transitions are triggered by PlayerAction events. The FSM validates the action against the current state before applying it.
Valid Transitions (Example: No-Limit Hold'em):
- Idle/Start: Transition to
WaitingForBlinds. - Blind Posting: Transition to
PreFlopActive. - Check: Valid only if
highestBet == 0. Moves actor to next player. - Call: Valid if
betAmount == highestBet. Moves actor to next player. - Raise: Valid if
betAmount > highestBetandbetAmount >= minRaise. Resets the "round completion" counter. Moves actor to next player. - Fold: Removes player from
activePlayers. If only 1 player remains, transition toShowdown. - All-In: Special transition. Player remains active but cannot act further. If
highestBetremains unmet, the round continues; otherwise, it closes.
2. Implementation Details: The State Machine Pattern
Do not use simple boolean flags (e.g., isRoundOver). Use a formal State Machine library or a strict class structure.
The State Machine Class (TypeScript Example)
enum BettingRoundState {
OPENING,
ACTIVE,
ALL_IN,
COMPLETED
}
class BettingRoundFSM {
private state: BettingRoundState;
private context: BettingStateContext;
constructor(context: BettingStateContext) {
this.context = context;
this.state = BettingRoundState.OPENING;
}
// The core entry point for all actions
async processAction(action: Action): Promise<void> {
const validTransitions = this.getValidTransitions();
if (!validTransitions.includes(action.type)) {
throw new InvalidActionError(`Action ${action.type} invalid in state ${this.state}`);
}
// Execute state transition logic
switch (this.state) {
case BettingRoundState.OPENING:
this.handleOpening(action);
break;
case BettingRoundState.ACTIVE:
this.handleActive(action);
break;
case BettingRoundState.ALL_IN:
this.handleAllIn(action);
break;
}
// Check for round termination
if (this.isRoundComplete()) {
this.state = BettingRoundState.COMPLETED;
await this.triggerNextStreet();
}
}
private handleActive(action: Action): void {
// Update highest bet
if (action.amount > this.context.highestBet) {
this.context.highestBet = action.amount;
this.context.lastAggressorId = action.playerId;
this.context.roundStatus = 'reset'; // Reset counter, everyone must act again
}
// Handle player elimination
if (action.type === 'FOLD') {
this.context.activePlayers.delete(action.playerId);
}
// Move turn to next active player
this.context.currentActorId = this.getNextActivePlayer();
}
private isRoundComplete(): boolean {
// Logic: Everyone active has matched the highest bet
// AND the last aggressor has acted (or no one raised)
const allMatched = this.context.activePlayers.every(p =>
p.currentBet === this.context.highestBet || p.isAllIn
);
const everyoneActed = this.context.actionCount >= this.context.activePlayers.size;
return allMatched && (this.context.lastAggressorId === null || this.context.lastAggressorId === this.context.currentActorId);
}
}
3. Handling Edge Cases & Complex Logic
Side Pots & All-Ins
The FSM must handle "All-In" as a special state modifier, not a standard action.
- State Split: When a player goes all-in, the FSM creates a Side Pot context.
- Transition: The player is removed from the "Active Betting" set but remains in the "Showdown Eligible" set.
- Logic: The main round continues with remaining players. If the all-in player was the aggressor, the round only ends when everyone else has either folded or called the full amount (if possible).
Heads-Up vs. Multi-Player
The "Round Complete" condition differs:
- Multi-Player: All active players must have acted, and no raises are pending.
- Heads-Up: The Big Blind (or aggressor) gets the last action. The state machine must track
lastAggressorIdspecifically to ensure the correct player gets the final decision.
Rebuy & Add-On
In tournaments, the FSM must handle "Rebuy" events during specific windows. This is a side-transition that doesn't affect the current betting round but updates the player's stack in the context object.
4. Data Flow & Architecture Integration
Event Sourcing
Every state transition should be an immutable event stored in an append-only log.
- Event:
BettingRoundStarted,PlayerChecked,PlayerRaised,RoundCompleted. - Replay: If the server crashes, you can reconstruct the exact state by replaying these events from the last checkpoint. This is critical for dispute resolution and "Game History" features.
Client-Server Sync
- Server: The FSM is the source of truth.
- Client: Receives
StateSnapshotafter every transition. - Protocol:
- Client sends
Raise(amount). - Server validates against FSM.
- Server updates state, emits
StateUpdatewith newhighestBet,currentActorId, andpotSize. - Client UI animates to the new state.
- Client sends
5. Scalability & Performance
- Memory: Keep the FSM state in memory (Redis or in-process memory for Node.js/Go). Do not query the database for every action.
- Concurrency: Use Optimistic Locking or Distributed Locks (Redis
SETNX) if the game server is sharded. Only one thread should process actions for a specifictable_idat a time to prevent race conditions (e.g., two players raising simultaneously). - Latency: The FSM logic is $O(1)$ or $O(N)$ where $N$ is the number of players. It must execute in < 5ms to maintain real-time feel.
6. Security Considerations
- Determinism: The FSM must be purely functional regarding logic. No random numbers or external API calls during the transition. This ensures that if two servers run the same input, they produce the same output (crucial for backup servers).
- Input Validation: The FSM acts as the primary firewall. If a client sends a "Raise" when the state is
Folded, the FSM rejects it immediately. - Audit Trail: Every state transition is logged with a timestamp and signature. This is required for regulatory compliance (e.g., UKGC, MGA).
Real-World Example: Omaha vs. Hold'em
The FSM structure is identical, but the Context differs.
- Hold'em:
maxHoleCards = 2. - Omaha:
maxHoleCards = 4. - Validation: The
validateActionfunction checkscontext.variantRulesto ensure the player isn't using 3 cards from their hand to make a hand (which is illegal in Omaha). The FSM state machine doesn't care which variant it is; it just enforces the rules defined in the injectedVariantStrategy.
FAQs
Q1: How does the FSM handle a player timing out?
The FSM includes a Timer state or a timeout check in the processAction loop. If the currentActorId does not act within $X$ seconds, a TimeoutAction event is triggered, automatically transitioning the state to Fold (or Check in some variations) and moving to the next player. This is usually handled by a background job or a Redis key expiration listener.
Q2: What happens if the server crashes mid-betting round?
Because the FSM is deterministic and all actions are logged as events (Event Sourcing), a backup server can restart, load the last snapshot of the game state, and replay the event log up to the crash point. The system regenerates the exact same state, ensuring no money or hands are lost.
Q3: Can the same FSM be used for Tournaments and Cash Games?
Yes, but the Context object differs. In Tournaments, the context includes blindLevel, ante, and playerStack (which affects rebuys). In Cash Games, the context includes tableLimits (min/max buy-in) and cashOut logic. The FSM logic (transitions) remains the same; only the rules injected into the context change.
Q4: How do you prevent race conditions when two players click "Raise" at the exact same millisecond?
The server processes requests sequentially per table_id. If using a distributed system, a distributed lock (e.g., Redis Redlock) is acquired for the table before processing any action. If a second request arrives while the lock is held, it is queued. The lock ensures the FSM state is updated atomically.
Q5: How is the "Last Aggressor" tracked for Heads-Up play?
The FSM explicitly stores lastAggressorId in the state. In a standard round, if Player A raises, lastAggressorId becomes A. The round only ends when Player B calls (or folds) AND Player A has had a chance to act again (if B raised). In Heads-Up, the logic is simplified: the round ends when the Big Blind (or current aggressor) gets the final action after all raises are matched. The FSM tracks this via the actionCount and lastAggressorId comparison.
Top comments (0)