Veit F. a07117efaf fix: implement correct Texas Hold'em betting rules and game flow
Rewrite core game logic to fix 10 critical bugs violating poker rules:

- Add betMatched flag to separate chip tracking from bet-matching state
- Implement last-aggressor tracking for proper betting round completion
- Rewrite all action functions with validation enforcement
- Add side pot support for multi-level all-in scenarios
- Replace nested setTimeout AI turns with async promise chain
- Add aiActing guard to prevent race conditions during AI play
- Fix currentTurn advancement to always land on active players
2026-05-17 18:46:08 +02:00

7.1 KiB

Context

The current PokeR app implements Texas Hold'em poker but contains 10 critical bugs in its game flow logic. The core issues stem from three architectural problems: (1) ambiguous currentBet semantics that conflate "player contribution" with "bet matched," (2) lack of last-aggressor tracking for betting round completion, and (3) no side pot support for all-in scenarios. The hand evaluator and card systems are solid; the game orchestration layer needs complete reconstruction.

Goals / Non-Goals

Goals:

  • Implement correct Texas Hold'em betting mechanics per official rules
  • Fix all identified bugs without introducing new ones
  • Support side pots for multi-player all-in scenarios
  • Prevent race conditions between human input and AI turns
  • Maintain existing Svelte 5 runes-based architecture and component structure

Non-Goals:

  • Adding new game variants (Omaha, Stud, etc.)
  • Implementing GTO or strategic AI improvements
  • Multiplayer networking
  • Educational features (pot odds, hand ranges)
  • UI/visual redesign

Decisions

Decision 1: Separate betMatched flag from currentBet

Current: PlayerSeat.currentBet serves dual purpose — tracks actual chips contributed AND whether player has matched the current bet level. This causes applyCheck to corrupt state by setting currentBet = state.currentBet.

Decision: Add explicit betMatched: boolean field to PlayerSeat. currentBet becomes purely "chips contributed this round." Check simply sets betMatched = true without modifying chip counts.

PlayerSeat {
  // ... existing fields
  currentBet: number    // actual chips contributed this round
  betMatched: boolean   // has player matched or checked this round?
}

Rationale: Eliminates the fundamental semantic confusion that causes cascading bugs. Call amount becomes state.currentBet - player.currentBet with no corruption from check actions.

Decision 2: Last-aggressor betting round completion

Current: completeBettingRound checks if all active players have currentBet >= currentMaxBet. This fails when all-in players are included and doesn't handle the "return to last aggressor" rule.

Decision: Track lastAggressorIndex in GameState. A betting round ends when:

  • Action returns to lastAggressorIndex AND that player has matched, OR
  • All active (non-all-in) players have betMatched = true, OR
  • Only one active player remains
// Round completion logic
function isRoundComplete(state): boolean {
  const activePlayers = state.players.filter(p => p.status === 'active');
  
  // Single active player — round over
  if (activePlayers.length <= 1) return true;
  
  // No raises occurred — everyone checked/called
  if (state.lastAggressorIndex === -1) {
    return activePlayers.every(p => p.betMatched);
  }
  
  // Raises occurred — must return to last aggressor who matches
  const lastAgg = state.players[state.lastAggressorIndex];
  return lastAgg.status === 'active' && 
         state.currentTurn === state.lastAggressorIndex && 
         lastAgg.betMatched;
}

Rationale: Texas Hold'em rules require action to return to the last aggressor. This is the standard implementation pattern used by poker software.

Decision 3: Side pot algorithm

Current: No side pot support. All chips go into a single pot, causing incorrect distributions when players go all-in at different levels.

Decision: Implement standard side pot calculation:

  1. Sort all-in players by their bet amount (ascending)
  2. Create pots from bottom up — each pot contains chips above the next lower all-in level
  3. Track which players are eligible for each pot (active or all-in at that level or above)
SidePot {
  amount: number
  eligiblePlayerIds: string[]
}

// Algorithm sketch:
function calculatePots(players, currentPot): { mainPot: number, sidePots: SidePot[] }
  // Sort players by bet amount
  // Create nested pots from smallest all-in to largest
  // Main pot = chips matched by all remaining active/all-in players

Rationale: Standard poker software approach. Keeps implementation clean and matches real-world rules exactly.

Decision 4: Unified game state machine in +page.svelte

Current: processGame() has complex branching logic with async AI turns, state discarding, and multiple exit paths. Race conditions occur when human actions interrupt AI chains.

Decision: Replace with a clear state machine pattern:

  • aiActing: boolean flag disables controls during AI turns
  • Single processTurn() function handles all post-action processing
  • State transitions are always applied atomically (gameState = newState)
  • AI turns use sequential promise chains, not nested timeouts
function processTurn() {
  // 1. Check for early win (single active player)
  // 2. Check if betting round is complete
  // 3. If complete: advance to next stage or showdown
  // 4. If not complete: check if next player is AI, schedule turn
}

// In handleAction:
if (aiActing) return;  // Guard against race conditions
gameState = applyAction(gameState, action);
processTurn();

Rationale: Eliminates race conditions and state discarding bugs. Single source of truth for game flow logic.

Decision 5: Validation enforcement

Current: validateAction exists but is never called by applyRaise, applyFold, etc. The AI and human actions bypass validation entirely.

Decision: All action functions call validateAction internally and return unchanged state on invalid actions. The UI layer also validates before rendering buttons.

function applyRaise(state, playerId, amount) {
  const validation = validateAction(playerId, 'raise', amount, state);
  if (!validation.valid) return state;
  // ... proceed with raise logic
}

Rationale: Defense in depth — both the action layer and UI layer enforce rules. Prevents illegal states from being created.

Risks / Trade-offs

[Side pot complexity] → Side pots add significant complexity to showdown logic. Mitigation: Thoroughly test with edge cases (2 all-ins, 3 all-ins, all-in on flop/turn/river).

[State expansion] → Adding betMatched, lastAggressorIndex, and sidePots to GameState increases mutation surface. Mitigation: Use immutable state updates consistently, reset all fields in startNewHand.

[AI timing changes] → Switching from nested timeouts to promise chains changes AI behavior timing. Mitigation: Keep 400ms delay per AI action for consistent feel.

[Performance] → Side pot calculation at showdown could be expensive with many players. Mitigation: 6-max table limits complexity; algorithm is O(n log n) with n ≤ 6.

[currentTurn deadlock] → completeBettingRound originally set currentTurn = dealerPosition + 1, which could land on a folded/all-in player, causing an infinite loop in the game state machine. Fixed by using findFirstActivePlayer() to always point to an active player.

Open Questions

  • Should we implement burn cards (standard in real poker, currently not implemented)?
  • How should all-in raises that don't constitute a full raise be handled (half-pot rule)?
  • Should the BB option (BB can check pre-flop if no raise) be explicitly documented in the UI?