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
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
lastAggressorIndexAND 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:
- Sort all-in players by their bet amount (ascending)
- Create pots from bottom up — each pot contains chips above the next lower all-in level
- 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: booleanflag 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?