Design an object-oriented poker game
Hands compared deterministically, betting rounds and a table of players.
- Value objects and immutability — Objects defined by their value, never changed, safely shared.
- Strategy — Swapping an algorithm at runtime by passing an object.
- State machines and the State pattern — Behaviour that depends on a mode, without a wall of conditionals.
- Identity, equality and hashing — When two objects are the same object, and when they are equal.
01The design, in the six-step framework
1. Requirements and use cases
A reported Google onsite prompt asked for an object-oriented poker game with clear class responsibilities, deterministic hand comparison, and an extensible, testable design. An older report asks for a deck class, a shuffle and poker-hand scoring. The hard core is therefore the comparison, and the rest is a state machine around it.
- A table seats 2 to 9 players with chip stacks, for Texas Hold'em.
- Each hand, the dealer deals two private cards and, over the streets, five community cards.
- In each betting round a player folds, checks or calls, or raises.
- When one player remains or the river betting ends, the hand finishes.
- At showdown the system finds the best five-card hand from each player's seven cards, compares them, and splits the pot on an exact tie.
Out of scope: side pots for all-in players, blinds rotation and tournaments, all named so the interviewer can pick one. Constraints: comparison is deterministic and total, so two evaluations of the same cards always agree; variants (Omaha, seven-card stud) reuse the evaluator; randomness is injected so a test can fix the deck.
2. Core entities and relationships
Card,Rank,Suit: as in Blackjack; here aces rank high, and also low in one straight.Category: an ordered enum from high card to straight flush.HandValue: an immutable, comparable pair of a category and tiebreak ranks.Evaluator: turns five cards into aHandValue, and picks the best five of seven.Seat: a player's stack, hole cards, street bet and status.Street: preflop, flop, turn, river, showdown.HandRound: one deal: the street, the pot, whose turn it is, and the betting rules.HoleRule: which card combinations are allowed, the strategy that distinguishes variants.
The round composes its seats for the life of one hand. The evaluator is stateless, so it is merely used; that keeps it free of table details and trivially testable, which is the point of the separation.
3. Interfaces and key methods
import itertools, random
from collections import Counter, deque
from dataclasses import dataclass, field
from enum import Enum, IntEnum
from typing import Iterable, Protocol, Tuple
class Suit(Enum):
CLUBS = "c"; DIAMONDS = "d"; HEARTS = "h"; SPADES = "s"
@dataclass(frozen=True)
class Card:
rank: int # 2..14, ace is 14
suit: Suit
class Category(IntEnum):
HIGH_CARD = 0; PAIR = 1; TWO_PAIR = 2; TRIPS = 3; STRAIGHT = 4
FLUSH = 5; FULL_HOUSE = 6; QUADS = 7; STRAIGHT_FLUSH = 8
@dataclass(frozen=True, order=True)
class HandValue:
category: Category
tiebreak: Tuple[int, ...]
class HoleRule(Protocol):
def combos(self, hole: list, board: list) -> Iterable:
"""Every legal five-card combination for this variant."""
class Street(Enum):
PREFLOP = 0; FLOP = 1; TURN = 2; RIVER = 3; SHOWDOWN = 4
class Action(Enum):
FOLD = "fold"; CALL = "call"; RAISE = "raise"
Invariants: a HandValue depends only on ranks and suits, never on card order; the dataclass ordering compares category first and then the tiebreak, so the comparison is total; the pot equals the sum of all chips moved in; a betting round ends only when every live seat has acted and matched the current bet.
4. Design patterns and the choices behind them
A hand's strength as a comparable value
The force: "who won" has a dozen special cases if written as nested ifs. Reduce it to one idea: every hand maps to a tuple, and tuples compare left to right. A full house of kings over twos becomes (FULL_HOUSE, (13, 2)); two pair becomes (TWO_PAIR, (high pair, low pair, kicker)). Ordering by count then by rank builds the tiebreak in a single line. All the rules of poker's ordering reduce to that encoding, and max then finds the winner.
That is a value object doing exactly what value objects are for. It also settles an identity and equality question: two hands with different suits can be equal in value and split a pot, while the cards themselves remain distinct. So equality of strength belongs on HandValue, not on a Hand of cards.
Variants as a strategy
Hold'em lets any five of seven cards play; Omaha requires exactly two hole cards and three from the board. That is the only difference for evaluation, so HoleRule.combos is a strategy yielding allowed combinations, and best takes the maximum. Enumerating is simple: 21 combinations for Hold'em, and a lookup table is an optimisation worth mentioning rather than building.
The hand as a state machine
The force: betting has strict order, and a raise reopens action. Street moves in one direction only, which is the state idea, and the to_act queue encodes who still must respond. A raise refills the queue with every other live seat, and that one line captures the reopening rule without a has_acted flag per seat. The alternative, looping around the table testing conditions, is where the bugs in this problem usually live.
5. Core flow and code
HandRoundreceives a shuffled deck, deals two cards per seat and fillsto_act.- Each
actvalidates that it is that seat's turn, moves chips, and on a raise refills the queue. - When the queue empties,
advanceresets street bets and deals the next community cards. - If only one seat is left unfolded, it wins the pot with no showdown.
- Otherwise
showdownevaluates each live seat and divides the pot among those with the maximumHandValue.
class Evaluator:
@staticmethod
def _straight_high(ranks: list) -> int:
distinct = sorted(set(ranks), reverse=True)
if len(distinct) == 5:
if distinct[0] - distinct[4] == 4:
return distinct[0]
if distinct == [14, 5, 4, 3, 2]: # the wheel: the ace plays low
return 5
return 0
@classmethod
def rank_five(cls, cards: tuple) -> HandValue:
ranks = [c.rank for c in cards]
counts = Counter(ranks)
ordered = sorted(counts, key=lambda r: (counts[r], r), reverse=True)
shape = sorted(counts.values(), reverse=True)
flush = len({c.suit for c in cards}) == 1
high = cls._straight_high(ranks)
if flush and high:
return HandValue(Category.STRAIGHT_FLUSH, (high,))
if shape == [4, 1]:
return HandValue(Category.QUADS, tuple(ordered))
if shape == [3, 2]:
return HandValue(Category.FULL_HOUSE, tuple(ordered))
if flush:
return HandValue(Category.FLUSH, tuple(ordered))
if high:
return HandValue(Category.STRAIGHT, (high,))
if shape == [3, 1, 1]:
return HandValue(Category.TRIPS, tuple(ordered))
if shape == [2, 2, 1]:
return HandValue(Category.TWO_PAIR, tuple(ordered))
if shape == [2, 1, 1, 1]:
return HandValue(Category.PAIR, tuple(ordered))
return HandValue(Category.HIGH_CARD, tuple(ordered))
@classmethod
def best(cls, hole: list, board: list, rule: HoleRule) -> HandValue:
return max(cls.rank_five(combo) for combo in rule.combos(hole, board))
class HoldemRule:
def combos(self, hole: list, board: list) -> Iterable:
return itertools.combinations(hole + board, 5)
class OmahaRule:
def combos(self, hole: list, board: list) -> Iterable:
for h in itertools.combinations(hole, 2):
for b in itertools.combinations(board, 3):
yield h + b
@dataclass
class Seat:
name: str
stack: int
hole: list = field(default_factory=list)
street_bet: int = 0
folded: bool = False
class HandRound:
def __init__(self, seats: list, deck: list, rule: HoleRule) -> None:
self.seats, self.deck, self.rule = seats, deck, rule
self.board: list = []
self.street = Street.PREFLOP
self.pot = 0
self.current_bet = 0
for s in seats:
s.hole = [self.deck.pop(), self.deck.pop()]
self.to_act = deque(seats)
def live(self) -> list:
return [s for s in self.seats if not s.folded]
def act(self, seat: Seat, action: Action, amount: int = 0) -> None:
if not self.to_act or self.to_act[0] is not seat:
raise ValueError("not your turn")
self.to_act.popleft()
if action is Action.FOLD:
seat.folded = True
else:
target = self.current_bet + (amount if action is Action.RAISE else 0)
pay = min(target - seat.street_bet, seat.stack)
seat.stack -= pay
seat.street_bet += pay
self.pot += pay
if action is Action.RAISE:
self.current_bet = target
i = self.seats.index(seat) # everyone else must respond, in seat order
order = self.seats[i + 1:] + self.seats[:i]
self.to_act = deque(s for s in order if not s.folded)
if not self.to_act or len(self.live()) == 1:
self.advance()
def advance(self) -> None:
if len(self.live()) == 1:
self.street = Street.SHOWDOWN
return
self.street = Street(self.street.value + 1)
for s in self.seats:
s.street_bet = 0
self.current_bet = 0
if self.street is not Street.SHOWDOWN:
count = 3 if self.street is Street.FLOP else 1
self.board += [self.deck.pop() for _ in range(count)]
self.to_act = deque(self.live())
def showdown(self) -> dict:
live = self.live()
if len(live) == 1:
return {live[0].name: self.pot}
values = {s.name: Evaluator.best(s.hole, self.board, self.rule) for s in live}
top = max(values.values())
winners = sorted(n for n, v in values.items() if v == top)
share, odd = divmod(self.pot, len(winners))
return {n: share + (1 if i < odd else 0) for i, n in enumerate(winners)}
if __name__ == "__main__":
def hand(text: str) -> tuple:
ranks = {"T": 10, "J": 11, "Q": 12, "K": 13, "A": 14}
return tuple(Card(ranks.get(t[0]) or int(t[0]), Suit(t[1])) for t in text.split())
rank = Evaluator.rank_five
assert rank(hand("Ah 2h 3h 4h 5h")).category is Category.STRAIGHT_FLUSH
assert rank(hand("Kh Kd Kc 2s 2d")) > rank(hand("Qh Qd Qc As Ad"))
assert rank(hand("Ah Ad 9c 8s 2d")) > rank(hand("Ks Kd Qc Js Td"))
assert rank(hand("2h 2d 3c 3s 9d")) == rank(hand("2c 2s 3h 3d 9s"))
6. Extensions, concurrency and what interviewers probe
- "Make the comparison deterministic." It is:
HandValuedepends only on the multiset of cards, and the last assert shows two suits tying exactly. Odd chips go to winners in sorted name order, which is a stated rule, not an accident of dictionary order. - "Side pots." When a seat is all-in for less, split the pot into layers by contribution, and evaluate each layer among the seats eligible for it. It belongs in a
Potclass that replaces the single integer. - "Speed up evaluation." Enumerating 21 combinations is fine for a table; a simulator would precompute a lookup keyed by rank primes. Say it, then keep the simple form.
- "Add Omaha." Pass
OmahaRuleand deal four hole cards; the evaluator is untouched. - Concurrency: one table is a single stream of actions, so each table runs on one event loop; many tables are independent, and the stateless evaluator is safe to share.
- Common mistake: forgetting the wheel (A-2-3-4-5, whose high card is the five), comparing kickers in the wrong order, and failing to reopen action after a raise, so a player who already acted never gets to respond.