Coding by Hand
Rust home

Build a Poker Game in Rust

A card night at the kitchen table runs on four little jobs done in order. Somebody fans the deck to prove it is whole. Somebody cuts and shuffles. Somebody deals around the table one card at a time until everyone has a hand. Then everyone flips their cards face up and a single voice calls out who won. Build those four jobs in Rust — deck, shuffle, deal, judge — and you have a poker program. Skip a job or mix two together and the table catches you, because cards from the wrong place start showing up in the wrong hands.

The four jobs of a card-night game, in order.
The four jobs of a card-night game, in order.

The four-job split is older than the game. When Edmond Hoyle wrote the first English rulebook for card games in 1742, he opened with the same sequence — examine the deck, shuffle, deal, score — because parlor cheats of the day made money by collapsing two of the jobs into one. A dealer who shuffled while dealing could slip a known card to a friend. A scorer who counted while collecting could miscall a hand. Hoyle's fix was to keep each job in its own pair of hands, in a fixed order, with witnesses. A program does the same thing with separate functions, each one taking only what it needs and handing back a clear answer the next function can use.

Start with the deck — the smallest types the rest of the program rests on.

#[derive(Copy, Clone, PartialEq, Eq)]
enum Suit { Clubs, Diamonds, Hearts, Spades }

#[derive(Copy, Clone, PartialEq, Eq)]
struct Card { rank: u8, suit: Suit }

#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
enum HandClass {
    HighCard, Pair, TwoPair, ThreeOfAKind, Straight,
    Flush, FullHouse, FourOfAKind, StraightFlush,
}

fn card_str(c: Card) -> String {
    let r = match c.rank {
        14 => "A".into(), 13 => "K".into(),
        12 => "Q".into(), 11 => "J".into(),
        n => n.to_string(),
    };
    let s = ['C', 'D', 'H', 'S'][c.suit as usize];
    format!("{r}{s}")
}

A Card is a rank from 2 to 14 paired with one of four suits, where 11, 12, 13, and 14 mean Jack, Queen, King, and Ace. Storing rank as a number instead of a String like "Jack" pays off in two places. The straight check becomes one subtraction. The pair check becomes one count. A String rank would force the program to translate "Jack" back to 11 every time it wanted to compare two cards, and the translation table itself becomes a thing that can have bugs.

Suit is an enum because there are exactly four suits and the compiler should refuse to compile a match arm that forgets one. HandClass is an enum for the same reason at a bigger scale — there are nine ways a five-card hand can be classified, from high card up to straight flush, and giving each one a name lets the rest of the code talk about hands in plain English. The PartialOrd, Ord on HandClass is the trick that makes finding the winner one line at the end. Rust orders enum variants by their declaration order, so writing the list from worst to best means a straight flush is automatically greater than a four of a kind which is automatically greater than a full house, all the way down. The max_by_key call later does the rest for free.

Now build the deck itself. Fifty-two cards, no duplicates, in a known order.

fn fresh_deck() -> Vec<Card> {
    let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
    let mut deck = Vec::with_capacity(52);
    for suit in suits {
        for rank in 2..=14u8 {
            deck.push(Card { rank, suit });
        }
    }
    deck
}

fn shuffle(deck: &mut [Card]) {
    // Hardcoded Fisher-Yates picks. A real dealer would shuffle here;
    // the picks are fixed so the snapshot stays byte-identical.
    let picks: [usize; 51] = [
        29, 45, 3, 4, 41, 34, 11, 18, 1, 4, 33, 7, 25, 16, 35, 34, 13, 9, 27,
        14, 25, 8, 3, 7, 9, 19, 23, 10, 1, 19, 19, 3, 13, 3, 3, 6, 9, 3, 11,
        8, 1, 9, 9, 2, 1, 0, 3, 4, 1, 1, 1,
    ];
    for (i, &j) in picks.iter().enumerate() {
        deck.swap(i, i + j);
    }
}

fresh_deck walks suits on the outside and ranks on the inside, which puts all thirteen clubs first, then all thirteen diamonds, and so on. The order does not matter for play — the shuffle will scramble it — but a known starting order matters a lot for debugging. If a card ever shows up twice in a hand, the bug is somewhere between the deck and the deal, and a known starting order makes the bug findable in one read.

The shuffle is a hardcoded Fisher-Yates. The algorithm itself is the same one Ronald Fisher and Frank Yates printed in 1938 for randomizing biology experiments and Richard Durstenfeld rewrote for computers in 1964. The idea is small. Walk the deck from the front. At each position, swap the current card with one of the cards from that position forward. If the picks are random, every order of the deck is equally likely. The picks are hardcoded here on purpose — a lesson binary that calls rand::thread_rng() would print a different shuffle every time it ran, and the snapshot test that proves the page is real would fail on every machine. A hardcoded pick array is what a "rigged deck" looks like in code, and it is exactly the right shape for a teaching demo.

One swap of the Fisher-Yates shuffle in action.
One swap of the Fisher-Yates shuffle in action.

With a shuffled deck in hand, the deal is the next job. One card to each player, going around the table, five times.

fn deal(deck: &[Card], players: usize, cards_each: usize) -> Vec<Vec<Card>> {
    let mut hands = vec![Vec::with_capacity(cards_each); players];
    for round in 0..cards_each {
        for player in 0..players {
            hands[player].push(deck[round * players + player]);
        }
    }
    for hand in hands.iter_mut() {
        hand.sort_by_key(|c| c.rank);
    }
    hands
}

The outer loop is the rounds — first card to everyone, then second card to everyone, then third. Dealing this way instead of giving Player 1 their whole hand first and then Player 2 their whole hand is how the kitchen-table game runs, and there is a reason the game runs that way. If the deck is even a little bit ordered after a weak shuffle, dealing one whole hand at a time bunches the order into one seat. Dealing round-robin spreads the leftover order across all seats, which makes the hands fairer. The code mirrors the table.

After every player has five cards, each hand gets sorted by rank. The sort is for the human reading the output. A hand that prints as 2D 3S 8H 9H AD is easier to read than 9H 2D AD 8H 3S, and a sorted hand also makes the straight check in the next step a one-liner.

The hardest job is the judge — taking five cards and naming what the player has.

fn classify(hand: &[Card]) -> HandClass {
    let mut counts = [0u8; 15];
    for c in hand { counts[c.rank as usize] += 1; }
    let (mut pairs, mut threes, mut fours) = (0, 0, 0);
    for n in counts.iter() {
        match n { 2 => pairs += 1, 3 => threes += 1, 4 => fours += 1, _ => {} }
    }
    let flush = hand.iter().all(|c| c.suit == hand[0].suit);
    let mut ranks: Vec<u8> = hand.iter().map(|c| c.rank).collect();
    ranks.sort();
    let straight = ranks.windows(2).all(|w| w[1] == w[0] + 1);
    match (straight, flush, fours, threes, pairs) {
        (true, true, _, _, _) => HandClass::StraightFlush,
        (_, _, 1, _, _)       => HandClass::FourOfAKind,
        (_, _, _, 1, 1)       => HandClass::FullHouse,
        (_, true, _, _, _)    => HandClass::Flush,
        (true, _, _, _, _)    => HandClass::Straight,
        (_, _, _, 1, _)       => HandClass::ThreeOfAKind,
        (_, _, _, _, 2)       => HandClass::TwoPair,
        (_, _, _, _, 1)       => HandClass::Pair,
        _                     => HandClass::HighCard,
    }
}

fn class_name(c: HandClass) -> &'static str {
    match c {
        HandClass::HighCard => "high card",
        HandClass::Pair => "pair",
        HandClass::TwoPair => "two pair",
        HandClass::ThreeOfAKind => "three of a kind",
        HandClass::Straight => "straight",
        HandClass::Flush => "flush",
        HandClass::FullHouse => "full house",
        HandClass::FourOfAKind => "four of a kind",
        HandClass::StraightFlush => "straight flush",
    }
}

The whole job runs on a counting trick. The counts array has one slot for every possible rank from 0 to 14, and walking the hand once fills in how many of each rank the player holds. After the walk, every fact about pairs, triples, and quads is already in the array. A pair is a slot with a 2. A three of a kind is a slot with a 3. Two pair is two slots with a 2. A full house is one slot with a 3 and one slot with a 2. Counting once and then asking questions of the counts is faster and clearer than scanning the hand fresh for every question.

The flush check asks whether every card shares the suit of the first card. The straight check sorts the ranks and asks whether each rank is exactly one more than the rank before it. Both checks live on the original hand, not the counts, because both questions are about positions and gaps rather than how many of each rank there are.

Then the classifier asks the questions in order, strongest first. Straight flush beats four of a kind beats full house beats flush, and so on down to high card. Once a question says yes, the function returns and the rest of the questions do not run. The order is the rule book — if a hand is both a straight and a flush, the right name is straight flush, and asking that question first is how the code makes sure.

Drive every piece by dealing one full table and naming what every player has.

fn show_deck() {
    let deck = fresh_deck();
    println!("--- fresh deck (top 13) ---");
    for c in deck.iter().take(13) { print!("{} ", card_str(*c)); }
    println!("\ndeck size: {}\n", deck.len());
}

fn show_deal() {
    let mut deck = fresh_deck();
    shuffle(&mut deck);
    println!("--- shuffled deck (top 8) ---");
    for c in deck.iter().take(8) { print!("{} ", card_str(*c)); }
    println!("\n");
}

fn show_hands() {
    let mut deck = fresh_deck();
    shuffle(&mut deck);
    let hands = deal(&deck, 4, 5);
    println!("--- table: 4 players, 5 cards each ---");
    for (i, hand) in hands.iter().enumerate() {
        let cards: Vec<String> = hand.iter().map(|c| card_str(*c)).collect();
        println!("Player {}: {}  ({})", i + 1, cards.join(" "), class_name(classify(hand)));
    }
    let winner = hands.iter().enumerate()
        .max_by_key(|(_, h)| classify(h))
        .map(|(i, _)| i + 1).unwrap_or(0);
    println!("\nbest hand: Player {winner}");
}

The driver does three small jobs. show_deck prints the first thirteen cards of the fresh deck — all the clubs in rank order — so the reader can see the starting state before the shuffle touches anything. show_deal prints the first eight cards after the shuffle so the reader can see the order has actually changed. show_hands does the real work — shuffle a fresh deck, deal to four players, classify each hand, and call the winner with one max_by_key over the HandClass ordering.

The nine poker hand classes, weakest at the bottom.
The nine poker hand classes, weakest at the bottom.

Run it and watch the table flip face-up.

--- fresh deck (top 13) ---
2C 3C 4C 5C 6C 7C 8C 9C 10C JC QC KC AC 
deck size: 52

--- shuffled deck (top 8) ---
5H 9S 7C 9C 8S 2S 6D AD 

--- table: 4 players, 5 cards each ---
Player 1: 5H 8S 10C JC KH  (high card)
Player 2: 2S 2D 2C 2H 9S  (four of a kind)
Player 3: 6D 6S 6C 7C QS  (three of a kind)
Player 4: 3D 7D 9C 9H AD  (pair)

best hand: Player 2

Read the output top to bottom. The fresh deck shows 2 through Ace of clubs in order, then prints the count to prove all fifty-two cards survived the build. The shuffled deck shows the same fifty-two cards in a scrambled order — 5H first, then 9S, then 7C. The table prints each player's sorted hand with the classification in parentheses. Player 1 has nothing better than a King. Player 2 has all four 2's, which beats every other hand at the table. Player 3 has three 6's. Player 4 has a pair of 9's. The last line names Player 2 the winner because the HandClass::FourOfAKind variant is declared higher in the enum than ThreeOfAKind, Pair, or HighCard.

One question worth asking — what happens if two players tie on classification, like both have a pair? Right now the code calls whoever comes first in the player list because max_by_key keeps the first maximum it sees. A real poker scorer would break the tie with the rank of the pair, then the highest leftover card, then the second-highest leftover card, all the way down. The fix is to return a tuple from the classifier — (HandClass, sorted ranks) — and let Rust's tuple ordering compare classifications first and ranks second. The trick is the same one the Ord derive on HandClass already uses, just nested one level deeper.

A four-seat table mid-deal with the program's output mirrored beside it.
A four-seat table mid-deal with the program's output mirrored beside it.

The thing this program cannot do on its own is play more than one round. There is no betting, no folding, no community cards, no draw of new cards to replace discards. The deck is shuffled once and dealt once and the game is over. The next bottleneck is the round structure — letting the table keep its state between hands so a tournament can run match after match without losing track of who is still in.