How to Build a Poker Bot in Python
From a 20-line rule bot to one that computes real equity with Monte Carlo, standard library only. Working code for each step, and where to make it play ranked matches.
A poker bot is a function. The platform calls it every time it's your turn, hands it the game state, and expects an action back. Everything else, matchmaking, dealing, pots, showdowns, is someone else's problem.
This post builds that function four times, each version stronger than the last. All of it is plain Python, standard library only. By the end you have a bot that computes real equity and makes defensible decisions, and a place to find out what it's actually worth.
The interface is small. You subclass Bot, implement decide(state), and return an Action. The SDK handles the WebSocket, the handshake, and the timeouts.
from chipzen import Bot, GameState, Action
Level 0: the legal bot (20 lines)
The first job is never crashing and never timing out. A bot that checks when it can and calls when it must is terrible at poker and perfectly legal.
class MyBot(Bot):
def decide(self, state: GameState) -> Action:
if "check" in state.valid_actions:
return Action.check()
if "call" in state.valid_actions:
return Action.call()
return Action.fold()
This bot loses slowly. Every real bot you write should keep this shape as its fallback: whatever your strategy code does, wrap it so an exception degrades to check-or-fold instead of a crash. The server gives you a 5 second budget per decision and folds for you if you blow it, so a slow bot loses the same way a crashing one does.
Level 1: fold the bottom, raise the top (preflop ranges)
Most of the gap between "random" and "respectable" is preflop hand selection. There are 169 distinct starting hands. You don't need perfect ranges, you need to stop playing the bottom half. Convert your two cards to a canonical code (AA, AKs, AKo) and open only a tight set.
RANK_ORDER = "23456789TJQKA"
def hand_code(cards):
"""Two SDK Cards -> 'AA' / 'AKs' (suited) / 'AKo' (offsuit)."""
(r1, s1), (r2, s2) = ((c.rank, c.suit) for c in cards)
if RANK_ORDER.index(r1) < RANK_ORDER.index(r2):
r1, s1, r2, s2 = r2, s2, r1, s1
if r1 == r2:
return r1 + r2
return r1 + r2 + ("s" if s1 == s2 else "o")
# A tight opening range. Loosen it once you have data.
OPEN_RANGE = {
"AA", "KK", "QQ", "JJ", "TT", "99", "88", "77", "66",
"AKs", "AQs", "AJs", "ATs", "A9s", "KQs", "KJs", "QJs", "JTs", "T9s",
"AKo", "AQo", "AJo", "KQo",
}
class RangeBot(Bot):
def decide(self, state: GameState) -> Action:
if state.phase == "preflop":
code = hand_code(state.hole_cards)
if code in OPEN_RANGE and "raise" in state.valid_actions:
return Action.raise_to(state.min_raise)
if state.to_call == 0 and "check" in state.valid_actions:
return Action.check()
return Action.fold() if "fold" in state.valid_actions else Action.check()
# postflop: handled in the next two levels
return Action.check() if "check" in state.valid_actions else Action.fold()
That is the single highest-value change you will make. The full 169-hand ranking by equity is its own post; for now this range is enough to stop donating chips before the flop.
Level 2: pot odds (never pay the wrong price)
Facing a bet, the only question is whether the price is right. If the pot is 100 and the call is 25, you are risking 25 to win 125, so you need to be good more than 25 / (100 + 25) = 20% of the time. That threshold is your required equity, and comparing it to your actual win probability is the most important calculation in poker.
def required_equity(pot, to_call):
"""The win rate a call needs to break even."""
if to_call == 0:
return 0.0
return to_call / (pot + to_call)
One function, and it already tells you the price of every decision. The other half of the comparison, your actual win probability, is Level 3.
Level 3: Monte Carlo equity (know your win probability)
You estimate win probability by simulation: deal random opponent cards and random board completions a few thousand times, and count how often you win. For that you need to rank 7-card hands. Writing a fast enough evaluator in pure Python is a solved problem and a good afternoon.
import random
from itertools import combinations
from collections import Counter
_VAL = {r: i for i, r in enumerate(RANK_ORDER, start=2)} # '2'->2 ... 'A'->14
def _eval5(cards):
"""Five (rank_value, suit) cards -> a comparable category tuple."""
vals = sorted((v for v, _ in cards), reverse=True)
suits = [s for _, s in cards]
counts = Counter(vals)
distinct = sorted(set(vals), reverse=True)
straight = None
if len(distinct) == 5:
if distinct[0] - distinct[4] == 4:
straight = distinct[0]
elif distinct == [14, 5, 4, 3, 2]: # the wheel, A-2-3-4-5
straight = 5
flush = len(set(suits)) == 1
by_count = sorted(counts.items(), key=lambda kv: (kv[1], kv[0]), reverse=True)
shape = [c for _, c in by_count]
ranked = [v for v, _ in by_count]
if straight and flush: return (8, straight)
if shape[0] == 4: return (7, *ranked)
if shape[:2] == [3, 2]: return (6, ranked[0], ranked[1])
if flush: return (5, *vals)
if straight: return (4, straight)
if shape[0] == 3: return (3, *ranked)
if shape[:2] == [2, 2]: return (2, ranked[0], ranked[1], ranked[2])
if shape[0] == 2: return (1, *ranked)
return (0, *vals)
def _best7(cards):
return max(_eval5(list(c)) for c in combinations(cards, 5))
def equity(hole, board, trials=2000):
"""Win probability (ties count as half) for SDK Cards, by simulation."""
h = [(_VAL[c.rank], c.suit) for c in hole]
b = [(_VAL[c.rank], c.suit) for c in board]
known = set(h) | set(b)
deck = [(v, s) for v in range(2, 15) for s in "hdcs" if (v, s) not in known]
need = 5 - len(b)
wins = ties = 0
for _ in range(trials):
draw = random.sample(deck, 2 + need)
opp, full = draw[:2], b + draw[2:]
me, them = _best7(h + full), _best7(opp + full)
if me > them: wins += 1
elif me == them: ties += 1
return (wins + ties / 2) / trials
At 2,000 trials the estimate has roughly a 1% standard error, which is plenty to act on and finishes well inside the decision budget. Raise the trial count if you want tighter numbers on the river, where there is less left to simulate.
Now wire the three levels together. The decision rule is one sentence: raise when your equity is well above the price, call when it clears it, fold when it does not.
class EquityBot(Bot):
def decide(self, state: GameState) -> Action:
va = state.valid_actions
if state.phase == "preflop":
code = hand_code(state.hole_cards)
if code in OPEN_RANGE and "raise" in va:
return Action.raise_to(state.min_raise)
if state.to_call == 0 and "check" in va:
return Action.check()
return Action.fold() if "fold" in va else Action.check()
eq = equity(state.hole_cards, state.board)
price = required_equity(state.pot, state.to_call)
if state.to_call == 0:
if eq > 0.62 and "raise" in va:
size = min(state.max_raise, max(state.min_raise, state.pot // 2))
return Action.raise_to(size)
return Action.check()
if eq > price + 0.10 and "raise" in va:
size = min(state.max_raise, max(state.min_raise, state.pot // 2))
return Action.raise_to(size)
if eq >= price and "call" in va:
return Action.call()
return Action.fold() if "fold" in va else Action.check()
What's still missing
Position, bet sizing, opponent modeling, and balance, in roughly that order of value. Each one is its own post. But here is the honest secret: the bot you just built beats every bot that crashes, times out, or calls everything, and on any real ladder that is a lot of bots.
Find out what it's worth
Self-play tells you the code runs. It cannot tell you if the bot is any good, because you wrote both players and they share your blind spots. For that it needs opponents you did not write. That gap is the reason Chipzen exists: upload the bot, play it yourself or enter it into matches against other people's bots, and you get ranked results overnight. It's free and in beta.