Skip to main content
  1. Portfolio/

montecarlo-poker

Will Paik
Author
Will Paik
I optimize large-scale GPU clusters for AI/ML workloads. Outside of work, I build a mini-supercomputer from consumer hardware and document every step of it here.

Overview
#

A text-based Texas Hold’em poker game built in Python using only the standard library. The AI opponent uses Monte Carlo simulation to estimate win probability from incomplete information and decides whether to raise, call, fold, or bluff.

GitHub: github.com/willgpaik/montecarlo-poker


Goals
#

The initial goal was to implement a working poker game using OOP without external packages, relying only on random, math, copy, and collections from the standard library. The rules were implemented from scratch based on Texas Hold’em specs, which meant writing the full hand evaluator, betting logic, and blind structure by hand.


How the AI Works
#

Each AI turn triggers a Monte Carlo simulation:

  1. Deep copy the remaining deck and shuffle it
  2. Randomly complete the community cards up to 5
  3. Randomly assign hole cards to each simulated opponent
  4. Evaluate all hands and check if the AI wins
  5. Repeat 1000 times and compute win rate = wins / 1000

Each AI is assigned a random personality at game start that determines its decision thresholds. The personality is not revealed to the player.

Personality Raise threshold Fold threshold Bluff chance
aggressive 0.50 0.25 15%
passive 0.75 0.45 5%
bluffer 0.60 0.30 20%

Thresholds also have ±0.4 random noise applied per decision to prevent predictable behavior. A separate 30% random action layer runs on top regardless of win rate.

Win rate above raise threshold → raise. Win rate above fold threshold → call. Otherwise → fold. Win rate above 0.9 triggers all-in at 30% chance.


Development Notes
#

The initial version had the structure and classes in place but several broken pieces.

Hand evaluator bugs
#

The royalflush() function compared the full card tuple instead of the value field (card == 1 instead of card[1] == 1), so royal flush never triggered. The straightflush() function checked the correct suit in the outer condition but then filtered for 'heart' cards in all four suit branches, a copy-paste error that broke straight flush detection for club, diamond, and spade entirely.

Straight detection
#

cards.sort() on (suit, value) tuples sorts alphabetically by suit first, so straight() received values like [3, 7, 2, 5, 4, 8, 6] instead of a numerically sorted list. Straights were rarely detected. The fix was sorted(set(card[1] for card in cards)) to get deduplicated, numerically sorted values.

Monte Carlo was deterministic
#

The simulate loop used copy.deepcopy(deck) but never shuffled the copy. Every simulation drew the same cards in the same order, so the win rate was always 0 or 1. Adding remainingDeck.shuffle() inside the loop fixed this.

Action Prompt
#

Flop, turn, and river each start a new callAll() call with betHigh=0. Since roundBet is also initialized to 0, every player immediately satisfied roundBet[idx] == betHigh and was skipped. The game auto-played through all rounds without asking the human for input. The fix was a hasActed[] boolean array that tracks whether each player has acted in the current round, separate from whether their bet amount matches the current high.

Handling re-raise
#

The original callCnt counter incremented per call but never reset when a raise occurred. If player A called and player B raised, player A was not prompted again. The rewrite uses hasActed[] and resets it for all other players when a raise occurs.

Money deducted twice
#

callHuman() and callAI() deducted from player.money directly. callAll() then tracked the same amounts in roundBet and added them to the pot. The fix restructured all helper functions to return amounts only, with a single deduction point in callAll(). This touched callHuman, callAI, raiseHuman, and raiseAI.

First player wins on a tie
#

The function used player.score, which is set by getScore(). That method was never called during gameplay, so every player’s score stayed at 0. The fix was to compare the playerScore tuples (already computed by think()) directly, using the high card and low card fields for tiebreaking.

Hand evaluator refactor
#

The original evaluator had 10 separate functions, one per hand rank, totaling around 250 lines. straightflush() alone was 90 lines of the same logic copy-pasted for each of the four suits. The rewrite uses a single evaluate_hand() function with collections.Counter for value and suit frequency, and a nested find_straight() helper shared by both straight and straight flush detection. The result is around 50 lines and removes all the repetition.


Known Limitations
#

  • Side pot not implemented. When a player goes all-in for less than the current bet, the correct behavior is to split the pot. This version gives the full pot to the winner regardless of all-in amounts.
  • No opponent modeling. The AI has a fixed personality per game but does not adapt to observed player behavior across hands.
  • Text interface only. A browser version using the same Monte Carlo logic running in JavaScript is a future direction.

Stack
#

Python 3.10+ · Standard library only (random, math, copy, collections)