Build a Poker Game
Every lesson in the Paradigms section hired a new cook. The OOP page built the Card and Deck. The functional page hired the pure scoring card. The mixed page put the head chef, PokerGame, at the front of the line and made her call the recipe cards instead of cooking everything herself. This page opens the restaurant. Two customers sit down — Aarit and Aditya — and you run them through a full hand of Texas Hold'em from the first deal to the pot going to the winner.
John von Neumann had the same itch 98 years ago. In 1928 he published a paper called "Zur Theorie der Gesellschaftsspiele" — "On the Theory of Parlor Games" — and the game he kept coming back to was poker. Chess was already solved in principle: every piece is on the board, both players see everything. Poker hides cards. You have to reason about what the other person might have, and bluff, and fold when the math turns against you. Von Neumann proved in that paper that every two-player zero-sum game has an optimal strategy, which later became the backbone of game theory, economics, and the nuclear standoff of the Cold War. He started with poker because poker is the cleanest toy version of "deciding under uncertainty." You are about to build the same toy.

Open your learning-python folder and make a new project inside it with its own workbench. Every real Python project gets its own venv so the dependencies of one project cannot leak into another.
cd ~/learning-python
mkdir poker
cd poker
python3 -m venv .venv
source .venv/bin/activate
mkdir -p src/poker
touch src/poker/__init__.py
touch pyproject.tomlcd $HOME\learning-python
mkdir poker
cd poker
py -m venv .venv
.venv\Scripts\Activate.ps1
mkdir src\poker
ni src\poker\__init__.py
ni pyproject.tomlThe folder shape is a real packaged Python project, the same shape the next lesson turns into an installable library. All of your game code lives under src/poker/. The pyproject.toml at the top is the file every modern Python packaging tool reads. Fill it in with the minimum it needs to know your project's name and version:
[project]
name = "aarit-poker"
version = "0.1.0"
requires-python = ">=3.11"
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
where = ["src"]The first file in src/poker/ is cards.py. It carries the dataclass Card and the stateful Deck straight out of the mixed-programming lesson. Nothing in the shape of those classes changes for the poker project. The only addition is a numeric rank value — Texas Hold'em needs to compare cards, and comparing the string "A" against "K" alphabetically puts the Ace below the King, which is wrong. Every Card gets a value property that maps the rank to a number from 2 to 14.
import random
from dataclasses import dataclass
RANKS = ["2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K", "A"]
SUITS = ["s", "h", "d", "c"]
RANK_VALUE = {r: i for i, r in enumerate(RANKS, start=2)}
@dataclass(frozen=True)
class Card:
rank: str
suit: str
def __repr__(self) -> str:
return f"{self.rank}{self.suit}"
@property
def value(self) -> int:
return RANK_VALUE[self.rank]
class Deck:
def __init__(self) -> None:
self.cards: list[Card] = [Card(r, s) for s in SUITS for r in RANKS]
def __len__(self) -> int:
return len(self.cards)
def shuffle(self) -> None:
random.shuffle(self.cards)
def deal(self, n: int) -> list[Card]:
dealt = self.cards[:n]
self.cards = self.cards[n:]
return dealtThe RANKS list reorders from "2" to "A" so the dictionary comprehension RANK_VALUE assigns 2 to "2", 3 to "3", and 14 to "A". The Ace on top matches how Texas Hold'em ranks a high card. Everything else — the frozen=True Card, the __repr__ printing A♠, the Deck's shuffle and deal — is the exact code from the mixed-programming lesson.
The second file is score.py, and this is where the real poker scoring lives. The scorer from the functional lesson only handled pairs, three of a kind, four of a kind, and full house. Texas Hold'em has ten hand ranks. From lowest to highest: high card, one pair, two pair, three of a kind, straight, flush, full house, four of a kind, straight flush, royal flush. The function score_hand(cards) takes 5 cards and returns a tuple where the first element is the hand rank (1 through 10) and the rest of the tuple breaks ties between hands of the same rank. Tuples sort naturally in Python — (5, 14) > (5, 13) is True — so comparing two hands is a single > operator. Write this in src/poker/score.py:
from collections import Counter
from .cards import Card, RANK_VALUE
HIGH_CARD = 1
ONE_PAIR = 2
TWO_PAIR = 3
THREE_OF_A_KIND = 4
STRAIGHT = 5
FLUSH = 6
FULL_HOUSE = 7
FOUR_OF_A_KIND = 8
STRAIGHT_FLUSH = 9
ROYAL_FLUSH = 10
def is_flush(cards: list[Card]) -> bool:
return len({c.suit for c in cards}) == 1
def straight_high(cards: list[Card]) -> int:
values = sorted({c.value for c in cards})
if len(values) != 5:
return 0
if values == [2, 3, 4, 5, 14]:
return 5
if values[-1] - values[0] == 4:
return values[-1]
return 0
def score_hand(cards: list[Card]) -> tuple[int, ...]:
counts = Counter(c.rank for c in cards)
groups = sorted(counts.values(), reverse=True)
ranked = sorted(
counts.items(),
key=lambda rc: (-rc[1], -RANK_VALUE[rc[0]]),
)
kickers = tuple(RANK_VALUE[r] for r, _ in ranked)
flush = is_flush(cards)
straight = straight_high(cards)
if flush and straight == 14:
return (ROYAL_FLUSH,)
if flush and straight:
return (STRAIGHT_FLUSH, straight)
if groups == [4, 1]:
return (FOUR_OF_A_KIND,) + kickers
if groups == [3, 2]:
return (FULL_HOUSE,) + kickers
if flush:
return (FLUSH,) + tuple(sorted((c.value for c in cards), reverse=True))
if straight:
return (STRAIGHT, straight)
if groups == [3, 1, 1]:
return (THREE_OF_A_KIND,) + kickers
if groups == [2, 2, 1]:
return (TWO_PAIR,) + kickers
if groups == [2, 1, 1, 1]:
return (ONE_PAIR,) + kickers
return (HIGH_CARD,) + tuple(sorted((c.value for c in cards), reverse=True))The straight check has one trick worth reading twice. In poker a "wheel" is the straight A-2-3-4-5, where the Ace counts as 1 and the straight tops out at 5 instead of 14. The line if values == [2, 3, 4, 5, 14]: return 5 catches exactly that case. Every other straight is 5 unique values in a row, where the highest minus the lowest equals 4. The function returns the high card of the straight, or 0 if the 5 cards are not a straight. Zero is falsy, which is why the if straight check above works.
Real Texas Hold'em deals 2 hole cards to each player and 5 community cards to the middle of the table. A player's best hand is the best 5-card hand they can make out of their 7 cards (2 hole + 5 community). The function best_of_seven(cards) picks the best 5-card subset. There are only 21 ways to choose 5 cards out of 7, so brute force is fine. Python's itertools.combinations is the canonical tool for this. Add to score.py:
from itertools import combinations
def best_of_seven(cards: list[Card]) -> tuple[int, ...]:
return max(score_hand(list(five)) for five in combinations(cards, 5))The third file is player.py — a small dataclass for the state each seat owns: a name, a chip stack, a list of hole cards, and whether they have folded this hand.
from dataclasses import dataclass, field
from .cards import Card
@dataclass
class Player:
name: str
chips: int = 1000
hole: list[Card] = field(default_factory=list)
folded: bool = False
def reset_for_hand(self) -> None:
self.hole = []
self.folded = Falsechips starts at 1000. hole is the 2 cards dealt face-down to the player. folded flips to True when the player quits the hand. reset_for_hand wipes the per-hand state clean at the start of every new deal. The field(default_factory=list) pattern is the fix for the mutable default argument bug — if you wrote hole: list[Card] = [] every Player would share the same list, which is the python-gotchas lesson's favorite trap.

The fourth file wires everything together. game.py holds the PokerGame class that owns the deck, the players, the community cards, the pot, and the action history. Four betting rounds happen per hand: pre-flop (after hole cards), flop (first 3 community cards), turn (4th community card), river (5th community card). Every round prints the pot, the current player's hole cards, the community cards visible so far, and the history of every check, bet, and fold in the hand.
from dataclasses import dataclass, field
from .cards import Card, Deck
from .player import Player
from .score import best_of_seven
@dataclass
class PokerGame:
players: list[Player]
deck: Deck = field(default_factory=Deck)
community: list[Card] = field(default_factory=list)
pot: int = 0
history: list[str] = field(default_factory=list)
def deal_hole(self) -> None:
for player in self.players:
player.reset_for_hand()
self.deck = Deck()
self.deck.shuffle()
for player in self.players:
player.hole = self.deck.deal(2)
def deal_community(self, n: int) -> None:
self.deck.deal(1)
self.community.extend(self.deck.deal(n))
def betting_round(self, round_name: str) -> None:
for player in self.players:
if player.folded:
continue
self.print_state(round_name, player)
action = input(f"{player.name}, [c]heck, [b]et 50, [f]old: ").strip().lower()
if action == "f":
player.folded = True
self.history.append(f"{player.name} folded on {round_name}")
elif action == "b":
player.chips -= 50
self.pot += 50
self.history.append(f"{player.name} bet 50 on {round_name}")
else:
self.history.append(f"{player.name} checked on {round_name}")
def print_state(self, round_name: str, player: Player) -> None:
print(f"--- {round_name} | pot: {self.pot} ---")
print(f"community: {self.community}")
print(f"{player.name}'s hole cards: {player.hole} (chips: {player.chips})")
print(f"history: {self.history}")
def showdown(self) -> Player:
live = [p for p in self.players if not p.folded]
if len(live) == 1:
winner = live[0]
else:
winner = max(live, key=lambda p: best_of_seven(p.hole + self.community))
winner.chips += self.pot
self.history.append(f"{winner.name} won {self.pot}")
print(f"winner: {winner.name} takes {self.pot}")
self.pot = 0
self.community = []
return winner
def play(self) -> Player:
self.deal_hole()
self.betting_round("pre-flop")
self.deal_community(3)
self.betting_round("flop")
self.deal_community(1)
self.betting_round("turn")
self.deal_community(1)
self.betting_round("river")
return self.showdown()Two small things to notice. deal_community burns one card before dealing — that is the real casino rule, one card face down into the muck before every community reveal, to prevent card counting from a marked top. showdown handles the early-fold case: if one player folded, the other wins without comparing hands. Otherwise the pure best_of_seven function decides. The PokerGame class never opens up a hand to read its ranks. It hands 7 cards to the scoring module and compares the tuple it got back.
The last file is main.py at the project root, outside src/poker/, that runs one full hand:
from poker.game import PokerGame
from poker.player import Player
def main() -> None:
game = PokerGame(players=[Player(name="Aarit"), Player(name="Aditya")])
winner = game.play()
print(f"final chips: Aarit={game.players[0].chips}, Aditya={game.players[1].chips}")
print(f"full history: {winner.name} won the hand")
if __name__ == "__main__":
main()Install the package in editable mode so Python can find poker.game and run the game. Editable mode means Python reads straight from src/poker/ every time you run, without rebuilding after each edit. You use it on every project you develop locally.
pip install -e .
python main.pypip install -e .
python main.pyA full hand plays out in the terminal. The prompts will ask each player what to do on each of the 4 streets. Here is what a run looks like with both players checking the whole way down to a showdown:
--- pre-flop | pot: 0 ---
community: []
Aarit's hole cards: [Ks, 7h] (chips: 1000)
history: []
Aarit, [c]heck, [b]et 50, [f]old: c
--- pre-flop | pot: 0 ---
community: []
Aditya's hole cards: [Ad, Ac] (chips: 1000)
history: ['Aarit checked on pre-flop']
Aditya, [c]heck, [b]et 50, [f]old: b
--- flop | pot: 50 ---
community: [Kd, 7s, 2c]
Aarit's hole cards: [Ks, 7h] (chips: 1000)
history: ['Aarit checked on pre-flop', 'Aditya bet 50 on pre-flop']
Aarit, [c]heck, [b]et 50, [f]old: b
--- flop | pot: 100 ---
community: [Kd, 7s, 2c]
Aditya's hole cards: [Ad, Ac] (chips: 950)
history: ['Aarit checked on pre-flop', 'Aditya bet 50 on pre-flop', 'Aarit bet 50 on flop']
Aditya, [c]heck, [b]et 50, [f]old: c
--- turn | pot: 100 ---
community: [Kd, 7s, 2c, 9d]
Aarit's hole cards: [Ks, 7h] (chips: 950)
history: [...]
Aarit, [c]heck, [b]et 50, [f]old: c
--- turn | pot: 100 ---
community: [Kd, 7s, 2c, 9d]
Aditya's hole cards: [Ad, Ac] (chips: 950)
history: [...]
Aditya, [c]heck, [b]et 50, [f]old: c
--- river | pot: 100 ---
community: [Kd, 7s, 2c, 9d, Jh]
Aarit's hole cards: [Ks, 7h] (chips: 950)
history: [...]
Aarit, [c]heck, [b]et 50, [f]old: c
--- river | pot: 100 ---
community: [Kd, 7s, 2c, 9d, Jh]
Aditya's hole cards: [Ad, Ac] (chips: 950)
history: [...]
Aditya, [c]heck, [b]et 50, [f]old: c
winner: Aarit takes 100
final chips: Aarit=1050, Aditya=950
full history: Aarit won the handA question worth answering from the output: both players checked the river, so why did Aarit win with K-7 against Aditya's pocket aces?
Aarit's 7 cards are [K♠, 7♥, K♦, 7♠, 2♣, 9♦, J♥]. The best 5-card subset inside that is [K♠, K♦, 7♥, 7♠, J♥] — two pair, kings over sevens. Aditya's 7 cards are [A♦, A♣, K♦, 7♠, 2♣, 9♦, J♥]. The best 5 there is [A♦, A♣, K♦, J♥, 9♦] — one pair of aces. best_of_seven returned (TWO_PAIR, 13, 7, 11) for Aarit and (ONE_PAIR, 14, 13, 11, 9) for Aditya. Tuples compare element by element from the left — 3 is greater than 2 on the first slot, so two pair beats one pair even when the pair of aces outranks the pair of kings. Poker's whole point is that hand rank beats card rank, and a tuple comparison says that in one line.
You can ship this. Your friend can clone the folder, run pip install -e ., and play against themselves in a terminal. Your code is 5 files, 200-ish lines, and every piece has one job: Card and Deck hold values and shuffle, Player tracks a seat, score functions decide strength, PokerGame owns the flow. You built a real thing. You do not know if it works the way you think it does until somebody breaks it — a 2-3-4-5-6 straight on the turn, a player folding with the winning hand, a tie that the scoring tuple has to handle. The next lesson writes tests that try to break every rule of the scorer before a real hand ever hits it.