core

Texas hold’em game logic

TODO:

In this dialog, I want to go through the process of writing a poker program myself but following the approach I learned in the cs212 class. My goal is not to copy everything from the lesson exactly, but follow the steps: understanding the problem, specifying, concept inventory, etc. that I can apply to solving any problem. To challenge myself, I will write texas hold’em poker.

Here’s the process Norvig taught:

1. Understand — Start with a vague understanding and refine it. Make an inventory of all the concepts you’ll need to deal with (data types, operations, etc.).

2. Specify — Define what needs to happen: inputs, outputs, what each function should accomplish. Think of it as a contract — not the implementation details, just the “what.”

3. Design — Figure out how to make it happen — the actual algorithms, code structure, and implementation.

Along the way, key principles: - Write tests early (even before implementation), including extreme values - Reuse existing tools when possible - Refactor for elegance (DRY, clarity, simplicity, generality) - Be aware of the four dimensions: correctness, efficiency, features, elegance — and make conscious tradeoffs

Card

namedtuple with patch

Let’s use emoji for suits and string of alphanumerics for ranks.

Rather than using a string to represent cards and using separate functions to access suits and ranks, I thought it would be better to use the . (dot) notation. One way to accomplish this is through class, but I wanted to try using namedtuple, which allows me to use attribute access and immutability. The ranks and suits from each card should not change. It is more lightweight than class.

c1
Card(suit='♠', rank='10')

I wanted the card representation to be more concise, so I used fastcore’s patch for __repr__.


Card.__repr__

def __repr__():

Return repr(self).

c2 = Card('♠', 'K')
c2
♠K

Currently, we cannot compare the ranks directly as we are using string comparisons lexicographically.

c1.rank > c2.rank, '10' > 'K'
(False, False)

It is convenient to sort hands by rank values when evaluating hands.

Using rank_values mapping, we can convert the string ranks into integers. From 2 to 10, their values are 2 to 10 respectively. Then, from ‘J’ to ‘A’, 11 to 14.

Although I had sort_cards function that sorts cards by their rank_values from the highest, it was more convenient to patch __lt__ and __eq__ and just use default sorted function. When comparing cards, we only care about their ranks, not suits.


Card.__lt__

def __lt__(
    other:Card
):

Return self<value.


Card.__eq__

def __eq__(
    other:Card
):

Return self==value.

deck = [Card(s,r) for s in suits for r in ranks]
sorted(deck,reverse=True)[:5]
[♠A, ♥A, ♦A, ♣A, ♠K]

Hand

  • evaluate_hand(cards) -> HandRank — given 7 cards, returns the best 5-card HandRank
  • compare_ranks(ranks) -> tuple — returns the winner rank

A hand has two cards. Community cards have five cards.

Poker hands: - royal flush (10) - straight flush (9, highest_value) - four of a kind (8, highest_value, kicker) - full house (7, triple, pair) - flush (6, five kickers) - straight (5, highest_value) - three of a kind (4, triple_value, two kickers) - two pair (3, high_pair, low_pair, kicker) - one pair (2, pair_value, three kickers) - high card (1, highest, second highest, third, fourth, fifth)

Sample hands

These are some example hands used for examples and tests.

cards_highcard = [Card(suits[0], ranks[0]), Card(suits[1], ranks[2]), Card(suits[2], ranks[4]),
    Card(suits[3], ranks[6]), Card(suits[0], ranks[8]), Card(suits[1], ranks[10]), Card(suits[2], ranks[12])]
cards_highcard
[♠2, ♥4, ♦6, ♣8, ♠10, ♥Q, ♦A]
cards_2kind = [Card(suits[0], ranks[0]), Card(suits[1], ranks[0]), Card(suits[0], ranks[3]), Card(suits[1], ranks[5]), 
    Card(suits[2], ranks[7]), Card(suits[3], ranks[9]), Card(suits[0], ranks[11])]
cards_2kind
[♠2, ♥2, ♠5, ♥7, ♦9, ♣J, ♠K]
cards_3kind = [Card(suits[0], ranks[0]), Card(suits[1], ranks[0]), Card(suits[2], ranks[0]), Card(suits[0], ranks[3]), 
    Card(suits[1], ranks[5]), Card(suits[2], ranks[7]), Card(suits[3], ranks[9])]
cards_3kind
[♠2, ♥2, ♦2, ♠5, ♥7, ♦9, ♣J]
cards_4kind = [Card(suits[0], ranks[0]), Card(suits[1], ranks[0]), Card(suits[2], ranks[0]), Card(suits[3], ranks[0]),
    Card(suits[0], ranks[4]), Card(suits[1], ranks[7]), Card(suits[2], ranks[10])]
cards_4kind
[♠2, ♥2, ♦2, ♣2, ♠6, ♥9, ♦Q]
cards_2pair = [Card(suits[0], ranks[0]), Card(suits[1], ranks[0]), Card(suits[2], ranks[1]), Card(suits[3], ranks[1]),
    Card(suits[0], ranks[4]), Card(suits[1], ranks[6]), Card(suits[2], ranks[8])]
cards_2pair
[♠2, ♥2, ♦3, ♣3, ♠6, ♥8, ♦10]
cards_fullhouse = [Card(suits[0], ranks[0]), Card(suits[1], ranks[0]), Card(suits[0], ranks[1]), Card(suits[2], ranks[1]), 
    Card(suits[3], ranks[1]), Card(suits[1], ranks[6]), Card(suits[2], ranks[8])]
cards_fullhouse
[♠2, ♥2, ♠3, ♦3, ♣3, ♥8, ♦10]
cards_straight = [Card(suits[0], ranks[1]), Card(suits[1], ranks[2]), Card(suits[2], ranks[3]),
    Card(suits[3], ranks[4]), Card(suits[0], ranks[5]), Card(suits[1], ranks[8]), Card(suits[2], ranks[10])]
cards_straight
[♠3, ♥4, ♦5, ♣6, ♠7, ♥10, ♦Q]
cards_flush = [Card(suits[0], ranks[0]), Card(suits[0], ranks[2]), Card(suits[0], ranks[4]),
    Card(suits[0], ranks[6]), Card(suits[0], ranks[8]), Card(suits[1], ranks[3]), Card(suits[2], ranks[7])]
cards_flush
[♠2, ♠4, ♠6, ♠8, ♠10, ♥5, ♦9]
cards_straightflush = [Card(suits[0], ranks[1]), Card(suits[0], ranks[2]), Card(suits[0], ranks[3]),
    Card(suits[0], ranks[4]), Card(suits[0], ranks[5]), Card(suits[1], ranks[8]), Card(suits[2], ranks[10])]
cards_straightflush
[♠3, ♠4, ♠5, ♠6, ♠7, ♥10, ♦Q]
cards_royalflush = [Card(suits[0], ranks[12]), Card(suits[0], ranks[11]), Card(suits[0], ranks[10]), 
    Card(suits[0], ranks[9]), Card(suits[0], ranks[8]), Card(suits[1], ranks[2]), Card(suits[2], ranks[4])]
cards_royalflush
[♠A, ♠K, ♠Q, ♠J, ♠10, ♥4, ♦6]

Ranks and counts

By getting the counts from the cards, we can figure out four of a kind, full house, three of a kind, two pair, and one pair. One thing we have to be careful of is because we have seven cards to evaluate, full house might have two three of kinds, rather than a three of kind and a pair.

Here is get_ranks_counts, which takes a list of cards and returns a tuple of ranks and counts. The output is sorted by the rank counts and rank_values for tie breakers. Rank counts are useful for finding what kind of poker ranking hand it is, and ranks are used for the main hand components and kickers.


L.__class_getitem__

def __class_getitem__(
    cls:L, *item
):

Call self as a function.


get_ranks_counts

def get_ranks_counts(
    cards:list
)->(tuple[int, ...], tuple[int, ...]):

Return ((ranks…), (counts…)) sorted by count then by rank value from cards

get_ranks_counts(cards_fullhouse), cards_fullhouse
([(3, 2, 10, 8), (3, 2, 1, 1)], [♠2, ♥2, ♠3, ♦3, ♣3, ♥8, ♦10])
def test_get_ranks_counts():
    test_eq(get_ranks_counts(cards_2kind), ((2, 13, 11, 9, 7, 5), (2, 1, 1, 1, 1, 1)))
    test_eq(get_ranks_counts(cards_3kind), ((2, 11, 9, 7, 5), (3, 1, 1, 1, 1)))
    test_eq(get_ranks_counts(cards_4kind), ((2, 12, 9, 6), (4, 1, 1, 1)))
    test_eq(get_ranks_counts(cards_2pair), ((3, 2, 10, 8, 6), (2, 2, 1, 1, 1)))
    test_eq(get_ranks_counts(cards_fullhouse), ((3, 2, 10, 8), (3, 2, 1, 1)))
    test_eq(get_ranks_counts(cards_highcard), ((14, 12, 10, 8, 6, 4, 2), (1, 1, 1, 1, 1, 1, 1)))
    test_eq(get_ranks_counts(cards_straight), ((12, 10, 7, 6, 5, 4, 3), (1, 1, 1, 1, 1, 1, 1)))

    # Edge case: two three-of-a-kinds (e.g. 222 333 K) — full house should pick higher triple
    cards_2trips = [Card(suits[0], ranks[0]), Card(suits[1], ranks[0]), Card(suits[2], ranks[0]),
                    Card(suits[0], ranks[1]), Card(suits[1], ranks[1]), Card(suits[2], ranks[1]),
                    Card(suits[3], ranks[11])]
    test_eq(get_ranks_counts(cards_2trips), ((3, 2, 13), (3, 3, 1)))

test_get_ranks_counts()

flush

As long as the suit count is 5 or more, it is flush because we have seven cards total.


is_flush

def is_flush(
    cards:list
)->list:

If cards have flush, return the rank_values from the cards in sorted order. If not, return []

is_flush(cards_flush)
[♠10, ♠8, ♠6, ♠4, ♠2]
def test_is_flush():
    # Flush: 5 spades, two side cards
    test_eq([str(c) for c in is_flush(cards_flush)], ['♠10', '♠8', '♠6', '♠4', '♠2'])

    # Straight flush
    test_eq([str(c) for c in is_flush(cards_straightflush)], ['♠7', '♠6', '♠5', '♠4', '♠3'])

    # Royal flush
    test_eq([str(c) for c in is_flush(cards_royalflush)], ['♠A', '♠K', '♠Q', '♠J', '♠10'])

    # No flush: suits too spread out
    test_eq(is_flush(cards_fullhouse), [])
    test_eq(is_flush(cards_highcard), [])
    test_eq(is_flush(cards_4kind), [])
    
    # Edge: all 7 cards same suit — returns all 7
    all_spades = [Card('♠', r) for r in 'A,K,Q,J,10,9,8'.split(',')]
    test_eq([str(c) for c in is_flush(all_spades)], ['♠A', '♠K', '♠Q', '♠J', '♠10', '♠9', '♠8'])
    five_hearts = [Card('♥', r) for r in '2,3,4,5,6'.split(',')] + [Card('♠', 'A'), Card('♦', 'K')]
    test_eq([str(c) for c in is_flush(five_hearts)], ['♥6', '♥5', '♥4', '♥3', '♥2'])

test_is_flush()

straight

Finding straight is trickier because Ace can serve as 1 or 14. Also, there are seven cards, but only five are used.


is_straight

def is_straight(
    cards:list
)->int:

Return the highest value of the straight if cards contain 5 consecutive ranks, else 0

def test_is_straight():
    # Regular straight: 3-4-5-6-7
    test_eq(is_straight(cards_straight), 7)

    # Ace-high straight (10-J-Q-K-A)
    ace_high = [Card('♠','10'), Card('♥','J'), Card('♦','Q'), Card('♣','K'), Card('♠','A'), Card('♥','3'), Card('♦','6')]
    test_eq(is_straight(ace_high), 14)

    # Wheel: A-2-3-4-5 (ace low)
    wheel = [Card('♠','A'), Card('♥','2'), Card('♦','3'), Card('♣','4'), Card('♠','5'), Card('♥','9'), Card('♦','J')]
    test_eq(is_straight(wheel), 5)

    # No straight — gapped
    test_eq(is_straight(cards_2pair), 0)

    # No straight — bunch of duplicates that run together in rank
    dupes = [Card('♠','5'), Card('♥','5'), Card('♦','6'), Card('♣','6'), Card('♠','7'), Card('♥','7'), Card('♦','8')]
    test_eq(is_straight(dupes), 0)

test_is_straight()

evaluate_hand

Now that we can detect flush and straight, we can update evaluate_hand.


evaluate_hand

def evaluate_hand(
    cards:list
)->tuple:

Return hand rank tuple for the best 5-card hand from cards. Hands ranked from 1 (high card) to 10 (royal flush). Each rank tuple starts with the hand category, followed by tiebreaker values (kickers or relevant ranks).

def test_evaluate_hand():
    # Royal flush: A♠ K♠ Q♠ J♠ 10♠ → (10,)
    test_eq(evaluate_hand(cards_royalflush), (10,))

    # Straight flush: 3♠ 4♠ 5♠ 6♠ 7♠ → (9, 7)
    test_eq(evaluate_hand(cards_straightflush), (9, 7))

    # Four of a kind: 2222 + Q 9 6 → (8, 2, 12)
    test_eq(evaluate_hand(cards_4kind), (8, 2, 12))

    # Full house: 333 22 + 10 8 → (7, 3, 2)
    test_eq(evaluate_hand(cards_fullhouse), (7, 3, 2))

    # Flush: 5 spades → (6, 10, 8, 6, 4, 2)
    test_eq(evaluate_hand(cards_flush), (6, 10, 8, 6, 4, 2))

    # Straight: 3-4-5-6-7 → (5, 7)
    test_eq(evaluate_hand(cards_straight), (5, 7))

    # Three of a kind: 222 + J 9 7 5 → (4, 2, 11, 9)
    test_eq(evaluate_hand(cards_3kind), (4, 2, 11, 9))
    
    # Two pair: 33 22 + 10 8 6 → (3, 3, 2, 10)
    test_eq(evaluate_hand(cards_2pair), (3, 3, 2, 10))

    # One pair: 22 + K J 9 7 5 → (2, 2, 13, 11, 9)
    test_eq(evaluate_hand(cards_2kind), (2, 2, 13, 11, 9))

    # High card: A Q 10 8 6 4 2 → (1, 14, 12, 10, 8, 6, 4, 2)
    test_eq(evaluate_hand(cards_highcard), (1, 14, 12, 10, 8, 6, 4, 2))

    # ace-high straight without flush should NOT be royal flush
    cards_ace_straight_noflush = [Card('♠','10'), Card('♥','J'), Card('♦','Q'), Card('♣','K'), Card('♠','A'), Card('♥','3'), Card('♦','6')]
    test_eq(evaluate_hand(cards_ace_straight_noflush), (5, 14))

    # straight + flush from different cards should NOT be straight flush
    cards_straight_and_flush = [Card('♠','2'), Card('♠','4'), Card('♠','6'), Card('♠','8'), Card('♠','10'), Card('♥','5'), Card('♥','7')]
    test_eq(evaluate_hand(cards_straight_and_flush), (6, 10, 8, 6, 4, 2))

    # ace-high straight + flush, but flush cards not a straight → flush, not royal flush
    cards_ace_straight_nonstraight_flush = [Card('♠','A'), Card('♠','K'), Card('♠','Q'), Card('♠','J'), Card('♠','5'), Card('♥','10'), Card('♥','3')]
    test_eq(evaluate_hand(cards_ace_straight_nonstraight_flush), (6, 14, 13, 12, 11, 5))

    # straight flush hidden when 6+ cards share a suit and top 5 don't form a straight
    cards_sf_hidden = [Card('♠','2'), Card('♠','3'), Card('♠','4'), Card('♠','5'), Card('♠','6'), Card('♠','8'), Card('♠','K')]
    test_eq(evaluate_hand(cards_sf_hidden), (9, 6))

test_evaluate_hand()

compare_hands

compare_hands(hands: List[List[Card]]) -> tuple — Return the winning hand rank among hands


compare_hands

def compare_hands(
    hands:list
)->tuple:

Return the winning hand rank among hands.

evaluate_hand(cards_flush)
(6, 10, 8, 6, 4, 2)
def test_compare_hands():
    # Clear winner: flush beats one pair
    test_eq(compare_hands([cards_2kind, cards_flush]), (6, 10, 8, 6, 4, 2))

    # Tie: two identical hands → both should win
    test_eq(compare_hands([cards_flush, cards_flush]), (6, 10, 8, 6, 4, 2))

    # Three hands, two tie for best
    test_eq(compare_hands([cards_2kind, cards_flush, cards_royalflush]), (10,))
    
    # Reverse order shouldn't matter
    test_eq(compare_hands([cards_flush, cards_2kind]), (6, 10, 8, 6, 4, 2))

test_compare_hands()

hand_name


hand_name

def hand_name(
    rank:tuple
)->str:

Readable name for an evaluate_hand tuple, e.g. (7,3,2) -> ‘full house, threes over twos’.

def test_hand_name():
    # Royal flush — only category with no rank mentioned
    test_eq(hand_name((10,)), 'royal flush')

    # Straight flush / straight — "X-high"
    test_eq(hand_name((9, 7)), 'straight flush, seven-high')
    test_eq(hand_name((5, 7)), 'straight, seven-high')
    test_eq(hand_name((5, 14)), 'straight, ace-high')     # ace-high straight
    test_eq(hand_name((5, 5)), 'straight, five-high')     # wheel (A-2-3-4-5)

    # Four of a kind / three of a kind / one pair — "Xs"
    test_eq(hand_name((8, 2, 12)), 'four of a kind, twos')
    test_eq(hand_name((8, 14, 2)), 'four of a kind, aces')
    test_eq(hand_name((4, 2, 11, 9)), 'three of a kind, twos')
    test_eq(hand_name((2, 2, 13, 11, 9)), 'one pair, twos')
    test_eq(hand_name((2, 14, 13, 11, 9)), 'one pair, aces')

    # Full house — "Xs over Ys"
    test_eq(hand_name((7, 3, 2)), 'full house, threes over twos')
    test_eq(hand_name((7, 14, 13)), 'full house, aces over kings')

    # Two pair — "Xs and Ys"
    test_eq(hand_name((3, 3, 2, 10)), 'two pair, threes and twos')
    test_eq(hand_name((3, 14, 13, 12)), 'two pair, aces and kings')

    # Flush / high card — "X-high"
    test_eq(hand_name((6, 10, 8, 6, 4, 2)), 'flush, ten-high')
    test_eq(hand_name((6, 14, 13, 12, 11, 5)), 'flush, ace-high')
    test_eq(hand_name((1, 14, 12, 10, 8, 6, 4, 2)), 'high card, ace-high')
    test_eq(hand_name((1, 2, 4, 6, 8, 10, 12)), 'high card, two-high')

test_hand_name()

Deck

In my original plan, I thought of writing a CardCollection class as an abstract class and create Hand, Deck, and CommunityCards by inheriting from it as they all consist of cards. However, they don’t really have methods that share. To keep things simple, I decided to start implementing Deck as a list of Cards and see how it feels.

A deck consists of 52 cards. From that, we need to shuffle and withdraw cards so the dealer can deal cards.


mk_deck

def mk_deck(
    suits:list='♠♥♦♣', ranks:list=['2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A']
)->L:

Create a deck with given suits and ranks


shuffle_deck

def shuffle_deck(
    deck:list
)->list:

Shuffle deck in place and return it

random.seed(42)

deck = mk_deck()
shuffle_deck(deck)[:5]
[♠J, ♥Q, ♥A, ♠5, ♥10]
def test_deck():
    # Standard deck: 52 cards, 4 suits × 13 ranks
    d = mk_deck()
    test_eq(len(d), 52)

    # No duplicates
    test_eq(len(set(d)), 52)

    # Custom: single suit, 5 ranks → 5 cards
    d5 = mk_deck(suits[0], ranks[:5])
    test_eq(len(d5), 5)
    test_eq(d5, [Card('♠','2'), Card('♠','3'), Card('♠','4'), Card('♠','5'), Card('♠','6')])

    # Two suits, three ranks → 6 cards
    d6 = mk_deck(suits[:2], ranks[:3])
    test_eq(len(d6), 6)
    
    # First card is first suit + first rank
    test_eq(d6[0], Card('♠','2'))
    
test_deck()

withdraw_card

def withdraw_card(
    deck:list, n:int=1
)->list:

Withdraw n cards from deck and return them. Modifies deck in place.

def test_withdraw_card():
    # One card: returns it, deck shrinks by one, card was last in deck
    deck = mk_deck()
    last_card = deck[-1]
    got = withdraw_card(deck)
    test_eq(got, [last_card])
    test_eq(len(deck), 51)
    
    # n cards: returned in reverse deck order (pop from end)
    deck = mk_deck()
    expected = list(deck[-3:])[::-1]
    got = withdraw_card(deck, 3)
    test_eq(got, expected)
    test_eq(len(deck), 49)

    # Withdrawn cards are exactly the ones removed — nothing lost or duplicated
    deck = mk_deck()
    full = set(deck)
    got = withdraw_card(deck, 5)
    test_eq(len(set(got) & set(deck)), 0)
    test_eq(set(got) | set(deck), full)

    # n=0: no cards, deck unchanged
    deck = mk_deck()
    test_eq(withdraw_card(deck, 0), [])
    test_eq(len(deck), 52)

    # Withdraw the whole deck: works, deck now empty
    deck = mk_deck()
    test_eq(len(withdraw_card(deck, 52)), 52)
    test_eq(len(deck), 0)

    # Too many: raises, and the deck is untouched
    deck = mk_deck()
    test_fail(lambda: withdraw_card(deck, 53), contains='Not enough cards')
    test_eq(len(deck), 52)

    # Depleted deck: raises even for n=1
    deck = mk_deck()
    withdraw_card(deck, 52)
    test_fail(lambda: withdraw_card(deck), contains='Not enough cards')

test_withdraw_card()

Monte Carlo

equity


equity

def equity(
    hand:list, community:list=(), n_opponents:int=1, n_sims:int=10000, seed:int=None
)->dict:

Monte Carlo estimate of win/tie/lose fractions for hand given community vs n_opponents. Pass seed for reproducible classroom demos.

The equity function provides a great way to simulate what would happen if the game goes on. It counts wins, ties, and loses from 10_000 simulations by default. It’s fun to play with. If I start with two aces, what is a chance of winning against one opponent?

equity([Card('♠','A'), Card('♥','A')])
{'win': 0.8571, 'tie': 0.0053, 'lose': 0.13759999999999994}

It is 85%, but think about what would happen with 2 opponents?

equity([Card('♠','A'), Card('♥','A')], n_opponents=2)
{'win': 0.7305, 'tie': 0.0042, 'lose': 0.2653}

It dropped to 73%. Why would that be?

What would happen if we have three kings in the community card? How would that change the probability of winning if it changes at all?

equity([Card('♠','A'), Card('♥','A')], [Card('♠','K'), Card('♥','K'), Card('♦','K')])
{'win': 0.9522, 'tie': 0.0053, 'lose': 0.04249999999999998}

Player

Playerclass:

  • balance: int
  • bet: int
  • playing: bool
  • hand: ListCard methods:
  • take_action(round)
  • call(round)
  • check(round)
  • fold(round)
  • raise(round, amount).

Action

def Action(
    *args, **kwds
):

Create a collection of name/value pairs.

Example enumeration:

class Color(Enum): … RED = 1 … BLUE = 2 … GREEN = 3

Access them by:

  • attribute access:

    Color.RED <Color.RED: 1>

  • value lookup:

    Color(1) <Color.RED: 1>

  • name lookup:

    Color[‘RED’] <Color.RED: 1>

Enumerations can be iterated over, and know how many members they have:

len(Color) 3

list(Color) [<Color.RED: 1>, <Color.BLUE: 2>, <Color.GREEN: 3>]

Methods can be added to enumerations, and members can have their own attributes – see the documentation for details.

Strategy

Player has a strategy attribute, which chooses actions.


human

def human():

Call self as a function.


always_raise

def always_raise(
    n
):

Call self as a function.


always_fold

def always_fold():

Call self as a function.


always_check

def always_check():

Call self as a function.


always_call

def always_call():

Call self as a function.

player class


Player

def Player(
    username:str='new_user', balance:int=0, bet:int=0, playing:bool=True, hand:list=<factory>,
    strategy:Callable=always_call
)->None:
@patch
def __repr__(self: Player):
    return (f"Player(username={self.username!r}, balance={self.balance}, bet={self.bet}, "
            f"playing={self.playing}, hand={self.hand}, strategy={self.strategy.__name__})")

ActionHandler

def ActionHandler(
    player, stage_bet
):

Initialize self. See help(type(self)) for accurate signature.

stage_bet = 2
player1 = Player('galopy', balance=20, playing=True, strategy=always_raise(1))
need = player1.perform_action(stage_bet)
need, player1
galopy intends to raise, stage_bet: 2, raise_amount: 1
(3,
 Player(username='galopy', balance=17, bet=3, playing=True, hand=[], strategy=always_raise(1)))
def test_player():
    # call: normal case
    p = Player('t', balance=100, bet=10)
    test_eq(p.call(20), 20)
    test_eq(p.balance, 80)
    test_eq(p.bet, 30)

    # call: need = 0 (already matched)
    p = Player('t', balance=100, bet=10)
    test_eq(p.call(0), 0)
    test_eq(p.balance, 100)
    test_eq(p.bet, 10)

    # check: no state change
    p = Player('t', balance=100, bet=10)
    test_eq(p.check(), 0)
    test_eq(p.balance, 100)
    test_eq(p.bet, 10)

    # fold: playing → False, no money changes
    p = Player('t', balance=100, bet=10)
    test_eq(p.fold(), 0)
    test_eq(p.playing, False)
    test_eq(p.balance, 100)

    # raise_: normal case
    p = Player('t', balance=100, bet=10)
    test_eq(p.raise_(50), 50)
    test_eq(p.balance, 50)
    test_eq(p.bet, 60)

    # call + raise_ share _place_bet: bets accumulate
    p = Player('t', balance=100, bet=10)
    p.call(5); p.raise_(5)
    test_eq(p.balance, 90)
    test_eq(p.bet, 20)
    
    # reset_bet: only bet zeroed, balance untouched
    p = Player('t', balance=100, bet=10)
    p.reset_bet()
    test_eq(p.bet, 0)
    test_eq(p.balance, 100)

test_player()
def test_action_handler():
    # call: pays the difference between stage_bet and current bet
    p = Player('t', balance=20, bet=0, strategy=always_call)
    test_eq(p.perform_action(2), 2)
    test_eq(p.balance, 18)
    test_eq(p.bet, 2)
    test_eq(p.playing, True)

    # call: partial — player already bet 1, only needs 1 more
    p = Player('t', balance=20, bet=1, strategy=always_call)
    test_eq(p.perform_action(2), 1)
    test_eq(p.balance, 19)
    test_eq(p.bet, 2)

    # check: no state change, returns 0
    p = Player('t', balance=20, bet=0, strategy=always_check)
    test_eq(p.perform_action(2), 0)
    test_eq(p.balance, 20)
    test_eq(p.bet, 0)
    test_eq(p.playing, True)

    # fold: playing → False, no money moves
    p = Player('t', balance=20, bet=0, strategy=always_fold)
    test_eq(p.perform_action(2), 0)
    test_eq(p.playing, False)
    test_eq(p.balance, 20)
    test_eq(p.bet, 0)

    # raise: pays new_stage_bet - current bet
    p = Player('t', balance=20, bet=0, strategy=always_raise(5))
    test_eq(p.perform_action(2), 7)  # 2 + 5 = 7
    test_eq(p.balance, 13)
    test_eq(p.bet, 7)

    # AI folds when it can't afford the call
    p = Player('t', balance=1, bet=0, strategy=always_call)
    test_eq(p.perform_action(2), 0)
    test_eq(p.playing, False)
    test_eq(p.balance, 1)

    # AI folds when it can't afford the raise
    p = Player('t', balance=5, bet=0, strategy=always_raise(10))
    test_eq(p.perform_action(2), 0)
    test_eq(p.playing, False)
    test_eq(p.balance, 5)

test_action_handler()
t intends to call, stage_bet: 2, raise_amount: 0
t intends to call, stage_bet: 2, raise_amount: 0
t intends to check, stage_bet: 2, raise_amount: 0
t intends to fold, stage_bet: 2, raise_amount: 0
t intends to raise, stage_bet: 2, raise_amount: 5
t intends to call, stage_bet: 2, raise_amount: 0
t intends to raise, stage_bet: 2, raise_amount: 10

Round

Round Management

Attributes: - stage (pre-flop, flop, turn, river) - players - deck - community_cards

Methods:

  • betting_round(round) -> None: Manage one round of player actions (pre-flop, flop, turn, or river). Ends when all active players have called or checked.
  • distribute(pot, winners) -> None: Distribute the pot to the winner(s).
  • start_round(round) -> None: Orchestrate the whole hand: deal hole cards → blinds → betting round → flop → betting round → turn → betting round → river → betting round → showdown → distribute.

Stage

def Stage(
    *args, **kwds
):

Create a collection of name/value pairs.

Example enumeration:

class Color(Enum): … RED = 1 … BLUE = 2 … GREEN = 3

Access them by:

  • attribute access:

    Color.RED <Color.RED: 1>

  • value lookup:

    Color(1) <Color.RED: 1>

  • name lookup:

    Color[‘RED’] <Color.RED: 1>

Enumerations can be iterated over, and know how many members they have:

len(Color) 3

list(Color) [<Color.RED: 1>, <Color.BLUE: 2>, <Color.GREEN: 3>]

Methods can be added to enumerations, and members can have their own attributes – see the documentation for details.

Let’s go through playing a round of poker game!


Round

def Round(
    players:NoneType=None, deck:NoneType=None, community:NoneType=None, stage:Stage=<Stage.PREFLOP: 1>,
    stage_bet:int=0, pot:int=0
):

Initialize self. See help(type(self)) for accurate signature.

def test_distribute():
    # Single winner takes the whole pot
    r = Round(); r.pot = 10
    p = Player(balance=5)
    r._distribute([p])
    test_eq(p.balance, 15)

    # Even split: two winners
    r = Round(); r.pot = 10
    p1, p2 = Player(balance=5), Player(balance=5)
    r._distribute([p1, p2])
    test_eq(p1.balance, 10)
    test_eq(p2.balance, 10)

    # Odd chips: remainder dropped (documents current behavior)
    r = Round(); r.pot = 5
    p1, p2 = Player(balance=0), Player(balance=0)
    r._distribute([p1, p2])
    test_eq(p1.balance, 2)
    test_eq(p2.balance, 2)

test_distribute()
Distributing $ 10 to [Player(username='new_user', balance=5, bet=0, playing=True, hand=[], strategy=always_call)]
Distributing $ 10 to [Player(username='new_user', balance=5, bet=0, playing=True, hand=[], strategy=always_call), Player(username='new_user', balance=5, bet=0, playing=True, hand=[], strategy=always_call)]
Distributing $ 5 to [Player(username='new_user', balance=0, bet=0, playing=True, hand=[], strategy=always_call), Player(username='new_user', balance=0, bet=0, playing=True, hand=[], strategy=always_call)]
def test_prep_round():
    # Fresh round: each player gets 2 cards, deck loses 2 per player
    players = [Player(balance=20) for _ in range(3)]
    r = Round(players=players)
    r._prep_round()
    test_eq([len(p.hand) for p in players], [2, 2, 2])
    test_eq(len(r.deck), 52 - 6)
    test_eq(r.stage, Stage.PREFLOP)
    test_eq(r.community, [])
    test_eq(r.pot, 0)

    # Called again: state resets, fresh hands dealt
    hands_before = [p.hand for p in players]
    r._prep_round()
    test_eq([len(p.hand) for p in players], [2, 2, 2])
    test_eq(len(r.deck), 52 - 6)
    test_ne([p.hand for p in players], hands_before)

test_prep_round()
def test_reset_betting_round():
    # Dirty state gets fully reset
    players = [Player(balance=20, bet=3) for _ in range(2)]
    r = Round(players=players)
    r._idx, r.stage_bet, r._raiser = 1, 10, 1
    r._reset_betting_round()
    test_eq([p.bet for p in players], [0, 0])
    test_eq(r.stage_bet, 0)
    test_eq(r._idx, 0)
    test_eq(r._raiser, -1)

    # Balances untouched — only bets reset
    test_eq([p.balance for p in players], [20, 20])

test_reset_betting_round()
def test_deal_community():
    # PREFLOP
    r = Round()
    r._deal_community()
    test_eq(len(r.community), 0)
    test_eq(len(r.deck), 52)

    # FLOP: 3 cards dealt, appended to community
    r.stage = Stage.FLOP
    r._deal_community()
    test_eq(len(r.community), 3)
    test_eq(len(r.deck), 52 - 3)

    # TURN: 1 more card appended
    r.stage = Stage.TURN
    r._deal_community()
    test_eq(len(r.community), 4)
    test_eq(len(r.deck), 52 - 4)

    # RIVER: same logic as TURN — 1 card
    r.stage = Stage.RIVER
    r._deal_community()
    test_eq(len(r.community), 5)
    test_eq(len(r.deck), 52 - 5)

test_deal_community()

--- PREFLOP --- Community: []

--- FLOP --- Community: [♣7, ♠J, ♦10]

--- TURN --- Community: [♣7, ♠J, ♦10, ♠4]

--- RIVER --- Community: [♣7, ♠J, ♦10, ♠4, ♥Q]
def test_showdown():
    # Clear winner: player1 flush; player2 high card; player3 folded with
    # a winning hand, excluded from evaluation
    community = [Card('♠','6'), Card('♠','8'), Card('♠','10'), Card('♥','5'), Card('♦','9')]
    p1 = Player(username='a', hand=[Card('♠','2'), Card('♠','4')])
    p2 = Player(username='b', hand=[Card('♥','3'), Card('♦','7')])
    p3 = Player(username='c', hand=[Card('♠','A'), Card('♠','K')], playing=False)
    r = Round(players=[p1,p2,p3], community=community, pot=10)
    r._showdown()
    test_eq(p1.balance, 10)
    test_eq(p2.balance, 0)
    test_eq(p3.balance, 0)  # folded: excluded even though hand would win

    # Tie: community royal flush — both players split the pot
    community = [Card('♠','10'), Card('♠','J'), Card('♠','Q'), Card('♠','K'), Card('♠','A')]
    p1 = Player(username='a', hand=[Card('♥','2'), Card('♦','3')])
    p2 = Player(username='b', hand=[Card('♣','4'), Card('♠','5')])
    r = Round(players=[p1,p2], community=community, pot=10)
    r._showdown()
    test_eq(p1.balance, 5)
    test_eq(p2.balance, 5)

test_showdown()
Winners: ['a'] with (6, 10, 8, 6, 4, 2)
Distributing $ 10 to [Player(username='a', balance=0, bet=0, playing=True, hand=[♠2, ♠4], strategy=always_call)]
Winners: ['a', 'b'] with (10,)
Distributing $ 10 to [Player(username='a', balance=0, bet=0, playing=True, hand=[♥2, ♦3], strategy=always_call), Player(username='b', balance=0, bet=0, playing=True, hand=[♣4, ♠5], strategy=always_call)]

Round.start_round

def start_round():

Deal, start PREFLOP, and auto-advance to first human player.

def test_start_round():
    # A raises 1 each betting round, B calls, C always folds
    a = Player('a', balance=20, strategy=always_raise(1))
    b = Player('b', balance=20, strategy=always_call)
    c = Player('c', balance=20, strategy=always_fold)
    r = Round(players=[a, b, c])
    list(r.start_round())  # consume the generator; all AI, so no yields

    # 4 betting rounds × (A:1 + B:1) = 8 chips, all paid out
    test_eq(sum(p.balance for p in r.players), 60)  # no chips created or lost
    test_eq(a.balance + b.balance, 40)              # 8 chips moved A+B → winner
    test_eq(c.balance, 20)                          # folded, never paid

test_start_round()

--- PREFLOP --- Community: []
a intends to raise, stage_bet: 0, raise_amount: 1
b intends to call, stage_bet: 1, raise_amount: 0
c intends to fold, stage_bet: 1, raise_amount: 0

--- FLOP --- Community: [♥3, ♥K, ♣9]
a intends to raise, stage_bet: 0, raise_amount: 1
b intends to call, stage_bet: 1, raise_amount: 0

--- TURN --- Community: [♥3, ♥K, ♣9, ♥2]
a intends to raise, stage_bet: 0, raise_amount: 1
b intends to call, stage_bet: 1, raise_amount: 0

--- RIVER --- Community: [♥3, ♥K, ♣9, ♥2, ♠6]
a intends to raise, stage_bet: 0, raise_amount: 1
b intends to call, stage_bet: 1, raise_amount: 0
Winners: ['a'] with (6, 13, 10, 9, 3, 2)
Distributing $ 8 to [Player(username='a', balance=16, bet=1, playing=True, hand=[♥10, ♥9], strategy=always_raise(1))]

act

def act(
    game, player, round
):

Call self as a function.

player1 = Player('galopy', balance=20, playing=True, strategy=always_fold)
player2 = Player('jalopy', balance=20, playing=False)
player3 = Player('zalopy', balance=20, playing=True, strategy=always_call)
players = [player1, player2, player3]
round1 = Round(players = players)
game = round1.start_round()
player = next(game, None)

round1.state()

--- PREFLOP --- Community: []
galopy intends to fold, stage_bet: 0, raise_amount: 0
jalopy intends to call, stage_bet: 0, raise_amount: 0
zalopy intends to call, stage_bet: 0, raise_amount: 0

--- FLOP --- Community: [♠9, ♣A, ♥8]
jalopy intends to call, stage_bet: 0, raise_amount: 0
zalopy intends to call, stage_bet: 0, raise_amount: 0

--- TURN --- Community: [♠9, ♣A, ♥8, ♠8]
jalopy intends to call, stage_bet: 0, raise_amount: 0
zalopy intends to call, stage_bet: 0, raise_amount: 0

--- RIVER --- Community: [♠9, ♣A, ♥8, ♠8, ♦K]
jalopy intends to call, stage_bet: 0, raise_amount: 0
zalopy intends to call, stage_bet: 0, raise_amount: 0
Winners: ['zalopy'] with (4, 8, 14, 13)
Distributing $ 0 to [Player(username='zalopy', balance=20, bet=0, playing=True, hand=[♣J, ♣8], strategy=always_call)]
'Game is over'
player = act(game, player, round1)
player
Game is over

Game

We can create a Game class that simplifies playing the game.


Game

def Game(
    players
):

Initialize self. See help(type(self)) for accurate signature.

game = Game([Player('you', balance=20, strategy=always_call),
             Player('bot', balance=20, strategy=always_raise(1))])
game.start()

--- PREFLOP --- Community: []
you intends to call, stage_bet: 0, raise_amount: 0
bot intends to raise, stage_bet: 0, raise_amount: 1
you intends to call, stage_bet: 1, raise_amount: 0

--- FLOP --- Community: [♦6, ♦J, ♦9]
you intends to call, stage_bet: 0, raise_amount: 0
bot intends to raise, stage_bet: 0, raise_amount: 1
you intends to call, stage_bet: 1, raise_amount: 0

--- TURN --- Community: [♦6, ♦J, ♦9, ♥J]
you intends to call, stage_bet: 0, raise_amount: 0
bot intends to raise, stage_bet: 0, raise_amount: 1
you intends to call, stage_bet: 1, raise_amount: 0

--- RIVER --- Community: [♦6, ♦J, ♦9, ♥J, ♦3]
you intends to call, stage_bet: 0, raise_amount: 0
bot intends to raise, stage_bet: 0, raise_amount: 1
you intends to call, stage_bet: 1, raise_amount: 0
Winners: ['bot'] with (6, 13, 11, 9, 6, 3)
Distributing $ 8 to [Player(username='bot', balance=16, bet=1, playing=True, hand=[♦K, ♥Q], strategy=always_raise(1))]
Game is over
game.act()
game.players
[Player(username='you', balance=16, bet=1, playing=True, hand=[♥8, ♣5], strategy=always_call),
 Player(username='bot', balance=24, bet=1, playing=True, hand=[♦K, ♥Q], strategy=always_raise(1))]