Knowledge Guide
HomeHands-On BuildsLLD Katas

Design a Deck of Cards & Blackjack

Build a Deck of Cards & Blackjack

Every candidate can draw the class diagram for a deck of cards in ninety seconds — Card, Suit, Rank, Deck, done. The interview is not testing that diagram. It is testing two things almost everyone gets wrong the moment they write code instead of boxes: whether the shuffle you just typed is actually random (most “obvious” shuffles are provably biased, and you can measure the bias), and whether your hand-scoring logic treats an Ace as the two things it simultaneously is — 1 and 11 — without hard-coding either one and silently busting a hand that is still alive. You already have the class breakdown and the corrected scoring code from the Design Blackjack and a Deck of Cards walkthrough; this lab makes you build it from an empty file in Java and Go, derive Fisher-Yates instead of quoting it, and then break both the naive shuffle and the naive Ace logic on purpose and measure exactly how they fail.

1. The trap

You are asked to shuffle a deck. You would never write “sort by nothing” — you know a shuffle needs randomness — so you write the thing that looks obviously right:

// Abridged trap sketch -- illustrative only, not the full reference
// (the full compiled version, with the bias measured, is in Movement 5)
void naiveShuffle(List<Card> cards, Random rng) {
    for (int i = 0; i < cards.size(); i++) {
        int j = rng.nextInt(cards.size());   // pick ANY index, every time
        Collections.swap(cards, i, j);
    }
}

This compiles. It runs. Deal a hand from the “shuffled” deck and it looks shuffled — the cards are visibly out of order, nobody watching a demo would ever suspect anything. That is exactly why this bug survives code review, ships, and sits in production for years: it never crashes and it never looks wrong. It is wrong. Strip the deck down to three cards — [0, 1, 2], six possible orderings — run this loop a few hundred thousand times, and count which ordering you got each time. Every ordering should appear about 1-in-6 of the time under a fair shuffle. Instead you will measure three orderings landing at 4/27 ≈ 14.8% and three landing at 5/27 ≈ 18.5% — a real, repeatable, measurable skew, not noise. Movement 5 shows you the actual run. The second trap sits right next to the first: score a hand by summing card values with an Ace fixed at 11. A + 6 reads as 17 — correct so far. Hit, draw a 5, and the naive scorer adds 11 again: 11 + 6 + 5 = 22, BUST. The hand was never actually bust — an Ace can drop to 1, making it 1 + 6 + 5 = 12, very much alive. The naive version does not crash either. It just quietly kills hands, and pays out or collects the wrong way on every single one of them.

2. Scope it like a senior

Before writing a line, pin the contract down:

3. Reason to the design

Shuffle — attempt 0, the trap. “For every position, swap it with a random position” (Movement 1). It looks like Fisher-Yates because it has the same shape — a loop, a random index, a swap — but there is one silent difference: the random index is drawn from [0, n), the FULL range, on every single iteration, including the last one. Here is why that guarantees bias, not just risks it. Each of the n loop iterations independently draws one of n equally likely values, so the whole shuffle is really just picking one of nn equally likely “random sequences” and running them through a fixed, deterministic swap procedure. For the OUTPUT to be uniform over the n! permutations, those nn sequences would need to divide EXACTLY evenly across the n! possible outputs. Check the arithmetic for a 3-card deck: 33 = 27 sequences, 3! = 6 permutations, and 27 / 6 = 4.5 — not an integer. By the pigeonhole principle alone, before running a single trial, some permutations MUST come out more often than others; there is no possible assignment of 27 equally-likely sequences to 6 buckets that comes out even. (Enumerating all 27 by hand confirms exactly which: 3 permutations get 4 sequences each = 4/27 ≈ 14.81%, the other 3 get 5 each = 5/27 ≈ 18.52% — Movement 5 measures precisely this split.)

Shuffle — attempt 1, shrink the range. The fix is one changed bound: instead of drawing from [0, n) every time, draw position i’s partner from [0, i] — a range that shrinks by one each step, going from the end of the list down to index 1 (Fisher–Yates / Durstenfeld). Now the counting argument flips in your favor: the number of possible choice-sequences is n × (n-1) × (n-2) × ... × 1 = n! — exactly the number of permutations, no more, no less. That is not a coincidence; each distinct sequence of choices produces a distinct final arrangement (it is literally the standard way to construct a uniformly random permutation by successive random selection without replacement), so the mapping from choice-sequences to permutations is a bijection. A bijection from n! equally-likely inputs onto n! outputs is, by definition, uniform. No simulation is needed to believe this — it falls out of the counting alone — but Movement 5 runs it anyway, because “prove your shuffle is unbiased” is a real interview follow-up and you should be able to point at a number, not just an argument.

Hand valuation — attempt 0. Fix every Ace at 11: wrong the moment a hand needs an Ace to be worth 1 to survive (any hand with an Ace that would otherwise bust). Fix every Ace at 1: wrong the moment a two-card A+K should be a 21 (blackjack) but scores as 12. Neither fixed choice is correct; the value is genuinely conditional on the rest of the hand.

Hand valuation — attempt 1, track both, resolve last. Score every Ace as 11 up front (simplest to add), then, ONLY if the running total would bust AND an Ace is still being counted as 11, subtract 10 — reinterpreting that one Ace as 1 instead. Repeat while both conditions hold (relevant with two-plus Aces: A+A is 12, not 22, and never 2). This is provably correct because it is exhaustive in the cheap direction that matters: the ONLY thing that can make a hand illegally over 21 is an Ace being counted high when it does not need to be, so checking “can I fix this by demoting exactly one soft Ace” after every card, in order, converges to the true best total <= 21 without ever having to branch over 2(number of aces) combinations explicitly.

Dealer rule — make it data, not a branch. “Hit on 16, stand on 17” is not quite the rule — the real rule has one more clause exactly at the boundary: what does the dealer do on a SOFT 17? Two casinos can legally answer differently (H17 hits it, S17 stands on any 17). Bake either answer into an if-statement and you have hard-coded a business rule that a config value should own. The fix is one enum parameter threaded into the dealer’s single decision method — below 17 always hit, above 17 always stand, and only exactly-17 consults the rule and whether the hand is currently soft.

4. Build it — milestones

Build the deck and the scoring engine milestone by milestone; attempt each yourself before the reference implementation — it is positioned as the reveal on purpose.

Reference implementation — Java (package cards, six files, one package)

Shuffles holds both algorithms side by side on purpose — the trap and the fix, same file, so the one-line diff (rng.nextInt(n) vs rng.nextInt(i + 1)) is impossible to miss. Deck only ever calls the correct one.

// Suit.java
package cards;

public enum Suit {
    CLUBS("C"), DIAMONDS("D"), HEARTS("H"), SPADES("S");

    private final String symbol;
    Suit(String symbol) { this.symbol = symbol; }
    public String symbol() { return symbol; }
}
// Rank.java
package cards;

public enum Rank {
    TWO("2", 2), THREE("3", 3), FOUR("4", 4), FIVE("5", 5), SIX("6", 6),
    SEVEN("7", 7), EIGHT("8", 8), NINE("9", 9), TEN("10", 10),
    JACK("J", 10), QUEEN("Q", 10), KING("K", 10),
    ACE("A", 11); // soft (high) value -- Hand downgrades to 1 as needed, never here

    private final String symbol;
    private final int blackjackValue;

    Rank(String symbol, int blackjackValue) {
        this.symbol = symbol;
        this.blackjackValue = blackjackValue;
    }
    public String symbol() { return symbol; }
    public int blackjackValue() { return blackjackValue; }
    public boolean isAce() { return this == ACE; }
}
// Card.java
package cards;

public final class Card {
    private final Suit suit;
    private final Rank rank;

    public Card(Suit suit, Rank rank) {
        this.suit = suit;
        this.rank = rank;
    }
    public Suit suit() { return suit; }
    public Rank rank() { return rank; }

    @Override public String toString() { return rank.symbol() + suit.symbol(); }
}
// Shuffles.java
package cards;

import java.util.Collections;
import java.util.List;
import java.util.Random;

public final class Shuffles {
    private Shuffles() {}

    /** THE TRAP: looks like Fisher-Yates, is not. Swaps position i with a
     *  uniformly random index drawn from the WHOLE list every time, instead
     *  of a shrinking range. Compiles, "shuffles", is measurably biased --
     *  see BreakItShuffleDemo. */
    public static <T> void naiveShuffle(List<T> items, Random rng) {
        int n = items.size();
        for (int i = 0; i < n; i++) {
            int j = rng.nextInt(n);   // BUG: full range [0,n) every step
            Collections.swap(items, i, j);
        }
    }

    /** Fisher-Yates / Durstenfeld: the range shrinks by one each step, so
     *  the n choices made are (n)(n-1)...(1) = n! total possible sequences,
     *  a BIJECTION onto the n! permutations. Provably uniform -- see
     *  Movement 3 for the counting argument. */
    public static <T> void fisherYates(List<T> items, Random rng) {
        for (int i = items.size() - 1; i > 0; i--) {
            int j = rng.nextInt(i + 1);   // range shrinks -- the fix
            Collections.swap(items, i, j);
        }
    }
}
// Deck.java
package cards;

import java.util.ArrayList;
import java.util.List;
import java.util.Random;

public final class Deck {
    private final List<Card> cards = new ArrayList<Card>();

    public Deck() {
        for (Suit s : Suit.values())
            for (Rank r : Rank.values())
                cards.add(new Card(s, r));
    }

    /** Fisher-Yates in place -- see Shuffles.fisherYates. */
    public void shuffle(Random rng) { Shuffles.fisherYates(cards, rng); }

    public Card deal() {
        if (cards.isEmpty()) throw new IllegalStateException("deck is empty");
        return cards.remove(cards.size() - 1);
    }
    public int remaining() { return cards.size(); }
    public List<Card> cards() { return cards; }
}
// Hand.java
package cards;

import java.util.ArrayList;
import java.util.List;

public final class Hand {
    private final List<Card> cards = new ArrayList<Card>();

    public void add(Card c) { cards.add(c); }
    public List<Card> cards() { return cards; }

    /** total[0] = best total, downgrading Aces from 11 to 1 one at a time
     *  only as long as the hand is still bust; total[1] = how many Aces are
     *  STILL counted as 11 after that (0 means the hand is "hard"). */
    private int[] totalAndSoftAces() {
        int total = 0, aces = 0;
        for (Card c : cards) {
            total += c.rank().blackjackValue();   // Ace enters as 11
            if (c.rank().isAce()) aces++;
        }
        while (total > 21 && aces > 0) {
            total -= 10;   // one Ace reinterpreted as 1 instead of 11
            aces--;
        }
        return new int[] { total, aces };
    }

    /** Best total <= 21 across every 1-or-11 reading of every Ace held. */
    public int value() { return totalAndSoftAces()[0]; }

    /** True iff at least one Ace is still being scored as 11 -- a "soft" hand
     *  (e.g. A+6 = soft 17). False = "hard" (no Ace, or all Aces forced to 1). */
    public boolean isSoft() { return totalAndSoftAces()[1] > 0; }

    public boolean isBust() { return value() > 21; }
    public boolean isBlackjack() { return cards.size() == 2 && value() == 21; }
}
// DealerRule.java
package cards;

/** H17: dealer hits a soft 17. S17: dealer stands on ANY 17 (hard or soft). */
public enum DealerRule { HIT_SOFT_17, STAND_SOFT_17 }
// Dealer.java
package cards;

public final class Dealer {
    private final DealerRule rule;
    public Dealer(DealerRule rule) { this.rule = rule; }

    /** The one line that encodes the whole H17/S17 rule difference. */
    public boolean shouldHit(Hand hand) {
        int v = hand.value();
        if (v < 17) return true;
        if (v > 17) return false;
        // v == 17: hard 17 always stands; soft 17 depends on the rule.
        return rule == DealerRule.HIT_SOFT_17 && hand.isSoft();
    }
}

GameDemo.java (default package) walks M1–M4 end to end:

import cards.Card;
import cards.Dealer;
import cards.DealerRule;
import cards.Deck;
import cards.Hand;
import cards.Rank;
import cards.Suit;

import java.util.Random;

/** Walks M1-M4: build the deck, shuffle it, deal a round, score both hands,
 *  then isolate the one dealer hand shape (soft 17) where H17 and S17
 *  actually disagree. */
public final class GameDemo {
    public static void main(String[] args) {
        Random rng = new Random(42); // fixed seed -- reproducible for the page

        Deck deck = new Deck();
        System.out.println("fresh deck size = " + deck.remaining());
        deck.shuffle(rng);
        System.out.println("after Fisher-Yates shuffle, still " + deck.remaining() + " cards");

        Hand player = new Hand();
        Hand dealerHand = new Hand();
        player.add(deck.deal());
        dealerHand.add(deck.deal());
        player.add(deck.deal());
        dealerHand.add(deck.deal());

        System.out.println("player dealt: " + player.cards() + " = " + player.value()
                + (player.isSoft() ? " (soft)" : " (hard)"));
        System.out.println("dealer dealt: " + dealerHand.cards() + " = " + dealerHand.value()
                + (dealerHand.isSoft() ? " (soft)" : " (hard)") + ", upcard shows " + dealerHand.cards().get(0));
        System.out.println("cards remaining after dealing 4 = " + deck.remaining());

        Dealer h17 = new Dealer(DealerRule.HIT_SOFT_17);
        Dealer s17 = new Dealer(DealerRule.STAND_SOFT_17);
        System.out.println("H17 dealer says hit on THIS dealt hand? " + h17.shouldHit(dealerHand));
        System.out.println("S17 dealer says hit on THIS dealt hand? " + s17.shouldHit(dealerHand));

        // The dealt hand above may not land on 17 at all -- isolate the ONE
        // hand shape where H17 and S17 actually diverge: soft 17 (A+6).
        Hand soft17 = new Hand();
        soft17.add(new Card(Suit.SPADES, Rank.ACE));
        soft17.add(new Card(Suit.HEARTS, Rank.SIX));
        System.out.println();
        System.out.println("isolated case -- dealer holds " + soft17.cards() + " = " + soft17.value()
                + (soft17.isSoft() ? " (soft)" : " (hard)"));
        System.out.println("H17 says hit? " + h17.shouldHit(soft17) + "   (soft 17 -- H17 MUST hit)");
        System.out.println("S17 says hit? " + s17.shouldHit(soft17) + "  (any 17 stands under S17)");

        Hand hard17 = new Hand();
        hard17.add(new Card(Suit.CLUBS, Rank.TEN));
        hard17.add(new Card(Suit.DIAMONDS, Rank.SEVEN));
        System.out.println("for contrast -- dealer holds " + hard17.cards() + " = " + hard17.value()
                + (hard17.isSoft() ? " (soft)" : " (hard)"));
        System.out.println("H17 says hit? " + h17.shouldHit(hard17) + "   S17 says hit? " + s17.shouldHit(hard17)
                + "  (hard 17 -- both rules stand, no disagreement)");
    }
}

Actual captured output from java -cp out GameDemo (seeded, reproducible):

fresh deck size = 52
after Fisher-Yates shuffle, still 52 cards
player dealt: [2H, JS] = 12 (hard)
dealer dealt: [8S, 7H] = 15 (hard), upcard shows 8S
cards remaining after dealing 4 = 48
H17 dealer says hit on THIS dealt hand? true
S17 dealer says hit on THIS dealt hand? true

isolated case -- dealer holds [AS, 6H] = 17 (soft)
H17 says hit? true   (soft 17 -- H17 MUST hit)
S17 says hit? false  (any 17 stands under S17)
for contrast -- dealer holds [10C, 7D] = 17 (hard)
H17 says hit? false   S17 says hit? false  (hard 17 -- both rules stand, no disagreement)

Compiled clean with javac -Xlint:all (zero warnings) and executed before writing this page. Note the isolated cases at the bottom: the SAME two dealer objects, h17 and s17, disagree on the soft-17 hand and agree on the hard-17 hand — direct proof the rule only bites exactly where it should.

Reference implementation — Go (same design, package main, one module)

Go has no enum keyword, so Suit/Rank/DealerRule are typed int constants with a switch for display — functionally identical to the Java enums. The two shuffle algorithms are Go generics (go 1.21+) so the exact same functions run on []Card in the real deck AND on the plain []int used by the bias measurement in Movement 5 — one implementation, not two.

// go.mod
module deckcards

go 1.21
// card.go
package main

import "fmt"

type Suit int

const (
	Clubs Suit = iota
	Diamonds
	Hearts
	Spades
)

func (s Suit) symbol() string {
	switch s {
	case Clubs:
		return "C"
	case Diamonds:
		return "D"
	case Hearts:
		return "H"
	default:
		return "S"
	}
}

type Rank int

const (
	Two Rank = iota
	Three
	Four
	Five
	Six
	Seven
	Eight
	Nine
	Ten
	Jack
	Queen
	King
	Ace
)

var allRanks = []Rank{Two, Three, Four, Five, Six, Seven, Eight, Nine, Ten, Jack, Queen, King, Ace}

func (r Rank) symbol() string {
	switch r {
	case Ten:
		return "10"
	case Jack:
		return "J"
	case Queen:
		return "Q"
	case King:
		return "K"
	case Ace:
		return "A"
	default:
		return fmt.Sprintf("%d", int(r)+2) // Two..Nine -> "2".."9"
	}
}

// blackjackValue returns the SOFT (high) value -- Ace enters as 11. Hand
// downgrades a soft ace to 1 as needed; that decision never happens here.
func (r Rank) blackjackValue() int {
	switch {
	case r == Ace:
		return 11
	case r >= Ten: // Ten, Jack, Queen, King all score 10
		return 10
	default:
		return int(r) + 2 // Two..Nine -> 2..9
	}
}

func (r Rank) isAce() bool { return r == Ace }

type Card struct {
	Suit Suit
	Rank Rank
}

func (c Card) String() string { return c.Rank.symbol() + c.Suit.symbol() }
// shuffles.go
package main

import "math/rand"

// naiveShuffle is THE TRAP: looks like Fisher-Yates, is not. It swaps
// position i with a uniformly random index drawn from the WHOLE slice every
// step, instead of a shrinking range. Compiles, "shuffles", is measurably
// biased -- see runShuffleBiasDemo.
func naiveShuffle[T any](items []T, rng *rand.Rand) {
	n := len(items)
	for i := 0; i < n; i++ {
		j := rng.Intn(n) // BUG: full range [0,n) every step
		items[i], items[j] = items[j], items[i]
	}
}

// fisherYates is Fisher-Yates / Durstenfeld: the range shrinks by one each
// step, so the choices made are n*(n-1)*...*1 = n! total possible
// sequences -- a bijection onto the n! permutations. Provably uniform, see
// Movement 3 for the counting argument.
func fisherYates[T any](items []T, rng *rand.Rand) {
	for i := len(items) - 1; i > 0; i-- {
		j := rng.Intn(i + 1) // range shrinks -- the fix
		items[i], items[j] = items[j], items[i]
	}
}
// deck.go
package main

import (
	"errors"
	"math/rand"
)

type Deck struct {
	cards []Card
}

func NewDeck() *Deck {
	d := &Deck{cards: make([]Card, 0, 52)}
	suits := []Suit{Clubs, Diamonds, Hearts, Spades}
	for _, s := range suits {
		for _, r := range allRanks {
			d.cards = append(d.cards, Card{Suit: s, Rank: r})
		}
	}
	return d
}

// Shuffle in place -- Fisher-Yates. See shuffles.go.
func (d *Deck) Shuffle(rng *rand.Rand) { fisherYates(d.cards, rng) }

func (d *Deck) Deal() (Card, error) {
	n := len(d.cards)
	if n == 0 {
		return Card{}, errors.New("deck is empty")
	}
	c := d.cards[n-1]
	d.cards = d.cards[:n-1]
	return c, nil
}

func (d *Deck) Remaining() int { return len(d.cards) }
func (d *Deck) Cards() []Card  { return d.cards }
// hand.go
package main

type Hand struct {
	cards []Card
}

func (h *Hand) Add(c Card)    { h.cards = append(h.cards, c) }
func (h *Hand) Cards() []Card { return h.cards }

// totalAndSoftAces returns (best total, aces still counted as 11). Every Ace
// enters as 11; while the running total busts and an ace is still soft, one
// ace is reinterpreted as 1 instead of 11 (subtract 10) until it doesn't, or
// none are left.
func (h *Hand) totalAndSoftAces() (int, int) {
	total, aces := 0, 0
	for _, c := range h.cards {
		total += c.Rank.blackjackValue() // Ace enters as 11
		if c.Rank.isAce() {
			aces++
		}
	}
	for total > 21 && aces > 0 {
		total -= 10
		aces--
	}
	return total, aces
}

// Value is the best total <= 21 across every 1-or-11 reading of every Ace held.
func (h *Hand) Value() int {
	v, _ := h.totalAndSoftAces()
	return v
}

// IsSoft is true iff at least one Ace is still being scored as 11.
func (h *Hand) IsSoft() bool {
	_, aces := h.totalAndSoftAces()
	return aces > 0
}

func (h *Hand) IsBust() bool      { return h.Value() > 21 }
func (h *Hand) IsBlackjack() bool { return len(h.cards) == 2 && h.Value() == 21 }
// dealer.go
package main

// DealerRule: H17 = dealer hits a soft 17. S17 = dealer stands on ANY 17
// (hard or soft).
type DealerRule int

const (
	HitSoft17 DealerRule = iota
	StandSoft17
)

type Dealer struct {
	Rule DealerRule
}

// ShouldHit is the one function that encodes the whole H17/S17 rule difference.
func (d Dealer) ShouldHit(h *Hand) bool {
	v := h.Value()
	if v < 17 {
		return true
	}
	if v > 17 {
		return false
	}
	// v == 17: hard 17 always stands; soft 17 depends on the rule.
	return d.Rule == HitSoft17 && h.IsSoft()
}

demo_game.go mirrors the Java GameDemo exactly (one of the three demo functions main.go calls — Go permits exactly one main() per binary, so all three movements’ demos run from one entry point, clearly delimited by section headers):

// demo_game.go
package main

import (
	"fmt"
	"math/rand"
)

// runGameDemo walks M1-M4: build the deck, shuffle it, deal a round, score
// both hands, then isolate the one dealer hand shape (soft 17) where H17
// and S17 actually disagree.
func runGameDemo() {
	rng := rand.New(rand.NewSource(42)) // fixed seed -- reproducible for the page

	deck := NewDeck()
	fmt.Println("fresh deck size =", deck.Remaining())
	deck.Shuffle(rng)
	fmt.Println("after Fisher-Yates shuffle, still", deck.Remaining(), "cards")

	player := &Hand{}
	dealerHand := &Hand{}
	c, _ := deck.Deal()
	player.Add(c)
	c, _ = deck.Deal()
	dealerHand.Add(c)
	c, _ = deck.Deal()
	player.Add(c)
	c, _ = deck.Deal()
	dealerHand.Add(c)

	fmt.Printf("player dealt: %v = %d %s\n", player.Cards(), player.Value(), softness(player))
	fmt.Printf("dealer dealt: %v = %d %s, upcard shows %v\n", dealerHand.Cards(), dealerHand.Value(),
		softness(dealerHand), dealerHand.Cards()[0])
	fmt.Println("cards remaining after dealing 4 =", deck.Remaining())

	h17 := Dealer{Rule: HitSoft17}
	s17 := Dealer{Rule: StandSoft17}
	fmt.Println("H17 dealer says hit on THIS dealt hand?", h17.ShouldHit(dealerHand))
	fmt.Println("S17 dealer says hit on THIS dealt hand?", s17.ShouldHit(dealerHand))

	soft17 := &Hand{}
	soft17.Add(Card{Suit: Spades, Rank: Ace})
	soft17.Add(Card{Suit: Hearts, Rank: Six})
	fmt.Println()
	fmt.Printf("isolated case -- dealer holds %v = %d %s\n", soft17.Cards(), soft17.Value(), softness(soft17))
	fmt.Println("H17 says hit?", h17.ShouldHit(soft17), "  (soft 17 -- H17 MUST hit)")
	fmt.Println("S17 says hit?", s17.ShouldHit(soft17), " (any 17 stands under S17)")

	hard17 := &Hand{}
	hard17.Add(Card{Suit: Clubs, Rank: Ten})
	hard17.Add(Card{Suit: Diamonds, Rank: Seven})
	fmt.Printf("for contrast -- dealer holds %v = %d %s\n", hard17.Cards(), hard17.Value(), softness(hard17))
	fmt.Println("H17 says hit?", h17.ShouldHit(hard17), "  S17 says hit?", s17.ShouldHit(hard17),
		" (hard 17 -- both rules stand, no disagreement)")
}

func softness(h *Hand) string {
	if h.IsSoft() {
		return "(soft)"
	}
	return "(hard)"
}
// main.go
package main

import "fmt"

func main() {
	fmt.Println("===== GAME DEMO (M1-M4) =====")
	runGameDemo()
	fmt.Println()
	fmt.Println("===== BREAK IT: SHUFFLE BIAS =====")
	runShuffleBiasDemo()
	fmt.Println()
	fmt.Println("===== BREAK IT: ACE BUG =====")
	runAceBugDemo()
}

Actual captured output from go run . (identical shape to the Java run, modulo Go’s slice-printing format):

===== GAME DEMO (M1-M4) =====
fresh deck size = 52
after Fisher-Yates shuffle, still 52 cards
player dealt: [9H 7D] = 16 (hard)
dealer dealt: [9D 5H] = 14 (hard), upcard shows 9D
cards remaining after dealing 4 = 48
H17 dealer says hit on THIS dealt hand? true
S17 dealer says hit on THIS dealt hand? true

isolated case -- dealer holds [AS 6H] = 17 (soft)
H17 says hit? true   (soft 17 -- H17 MUST hit)
S17 says hit? false  (any 17 stands under S17)
for contrast -- dealer holds [10C 7D] = 17 (hard)
H17 says hit? false   S17 says hit? false  (hard 17 -- both rules stand, no disagreement)

(Java’s seeded Random(42) and Go’s seeded rand.NewSource(42) use different PRNG algorithms, so the two languages legitimately deal different opening hands from the same seed — that is expected, not a bug; the isolated soft-17/hard-17 cases below them are constructed directly and match exactly.) Passes gofmt -l (clean), go vet ./... (clean), go build ./... (clean) — verified before publishing.

5. Break it — the tests that fail

Break-it #1 — the shuffle bias, measured. Shrink the deck to 3 items so the 6 possible permutations are easy to enumerate, run the naive shuffle from Movement 1 hundreds of thousands of times, and tally which ordering came out. A fair shuffle puts every ordering at trials÷6. Full file, copy-paste compilable:

import cards.Shuffles;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Random;

/** BREAK IT: shuffle 3 distinct items (6 possible permutations) hundreds of
 *  thousands of times with the naive swap-with-full-range shuffle, then with
 *  Fisher-Yates, and tally which permutation actually came out. Uniform
 *  would be trials/6 for every row -- watch the naive version miss that by
 *  a wide, repeatable margin while Fisher-Yates lands close to it every run. */
public final class BreakItShuffleDemo {

    private static String key(List<Integer> items) {
        StringBuilder sb = new StringBuilder();
        for (int x : items) sb.append(x);
        return sb.toString();
    }

    private static Map<String, Integer> distribution(boolean naive, int trials, long seed) {
        Random rng = new Random(seed);
        Map<String, Integer> counts = new LinkedHashMap<String, Integer>();
        for (String perm : new String[] { "012", "021", "102", "120", "201", "210" })
            counts.put(perm, 0);
        for (int t = 0; t < trials; t++) {
            List<Integer> items = new ArrayList<Integer>(Arrays.asList(0, 1, 2));
            if (naive) Shuffles.naiveShuffle(items, rng);
            else Shuffles.fisherYates(items, rng);
            String k = key(items);
            counts.put(k, counts.get(k) + 1);
        }
        return counts;
    }

    public static void main(String[] args) {
        int trials = 600000;
        long seed = 7L;
        int expected = trials / 6;

        System.out.println("== naive shuffle: swap(i, random(0,n)) for i=0..n-1, n=3 ==");
        System.out.println(trials + " trials, 6 permutations, uniform expectation = " + expected + " each");
        Map<String, Integer> naive = distribution(true, trials, seed);
        for (Map.Entry<String, Integer> e : naive.entrySet()) {
            double pct = 100.0 * e.getValue() / trials;
            System.out.printf("  perm %s : %7d  (%.3f%%)%n", e.getKey(), e.getValue(), pct);
        }

        System.out.println();
        System.out.println("== Fisher-Yates: same 3 items, same trial count ==");
        Map<String, Integer> fy = distribution(false, trials, seed);
        for (Map.Entry<String, Integer> e : fy.entrySet()) {
            double pct = 100.0 * e.getValue() / trials;
            System.out.printf("  perm %s : %7d  (%.3f%%)%n", e.getKey(), e.getValue(), pct);
        }
    }
}

Run it — actual output, captured verbatim from java -cp out BreakItShuffleDemo:

== naive shuffle: swap(i, random(0,n)) for i=0..n-1, n=3 ==
600000 trials, 6 permutations, uniform expectation = 100000 each
  perm 012 :   88643  (14.774%)
  perm 021 :  111118  (18.520%)
  perm 102 :  111232  (18.539%)
  perm 120 :  110791  (18.465%)
  perm 201 :   89087  (14.848%)
  perm 210 :   89129  (14.855%)

== Fisher-Yates: same 3 items, same trial count ==
  perm 012 :  100152  (16.692%)
  perm 021 :   99887  (16.648%)
  perm 102 :  100530  (16.755%)
  perm 120 :  100049  (16.675%)
  perm 201 :   99645  (16.608%)
  perm 210 :   99737  (16.623%)

That is not noise — it is Movement 3’s pigeonhole argument showing up as measured numbers. Three permutations cluster around 14.8% (the theoretical 4/27 = 14.815%) and three cluster around 18.5% (the theoretical 5/27 = 18.519%) — a repeatable ~25% relative skew between the “cold” and “hot” permutations, present on every run, in both languages, because it is a property of the algorithm, not of any particular random seed. Fisher-Yates lands every permutation within a few hundredths of a percent of the uniform target 1/6 = 16.667%. In a real 52-card deck this exact skew means specific orderings are measurably more likely than others — in a game with real money on it, a documented, exploitable bias.

The Go version reproduces the identical shape of the skew, same trial count, same seed value 7 (the exact percentages differ slightly from Java’s because the two languages’ PRNGs are different algorithms — the skew PATTERN, not the specific decimal, is the property under test):

// demo_shuffle_bias.go
package main

import (
	"fmt"
	"math/rand"
)

// runShuffleBiasDemo: BREAK IT. Shuffle 3 distinct items (6 possible
// permutations) hundreds of thousands of times with the naive
// swap-with-full-range shuffle, then with Fisher-Yates, and tally which
// permutation actually came out. Uniform would be trials/6 for every row.
func runShuffleBiasDemo() {
	trials := 600000
	seed := int64(7)
	expected := trials / 6

	fmt.Println("== naive shuffle: swap(i, random(0,n)) for i=0..n-1, n=3 ==")
	fmt.Printf("%d trials, 6 permutations, uniform expectation = %d each\n", trials, expected)
	naive := shuffleDistribution(true, trials, seed)
	printDistribution(naive, trials)

	fmt.Println()
	fmt.Println("== Fisher-Yates: same 3 items, same trial count ==")
	fy := shuffleDistribution(false, trials, seed)
	printDistribution(fy, trials)
}

func permKey(items []int) string {
	s := ""
	for _, x := range items {
		s += fmt.Sprintf("%d", x)
	}
	return s
}

func shuffleDistribution(naive bool, trials int, seed int64) map[string]int {
	rng := rand.New(rand.NewSource(seed))
	counts := map[string]int{"012": 0, "021": 0, "102": 0, "120": 0, "201": 0, "210": 0}
	for t := 0; t < trials; t++ {
		items := []int{0, 1, 2}
		if naive {
			naiveShuffle(items, rng)
		} else {
			fisherYates(items, rng)
		}
		k := permKey(items)
		counts[k]++
	}
	return counts
}

func printDistribution(counts map[string]int, trials int) {
	order := []string{"012", "021", "102", "120", "201", "210"}
	for _, k := range order {
		v := counts[k]
		pct := 100.0 * float64(v) / float64(trials)
		fmt.Printf("  perm %s : %7d  (%.3f%%)\n", k, v, pct)
	}
}
== naive shuffle: swap(i, random(0,n)) for i=0..n-1, n=3 ==
600000 trials, 6 permutations, uniform expectation = 100000 each
  perm 012 :   88908  (14.818%)
  perm 021 :  111519  (18.587%)
  perm 102 :  110820  (18.470%)
  perm 120 :  110781  (18.463%)
  perm 201 :   88856  (14.809%)
  perm 210 :   89116  (14.853%)

== Fisher-Yates: same 3 items, same trial count ==
  perm 012 :   99460  (16.577%)
  perm 021 :  100340  (16.723%)
  perm 102 :   99902  (16.650%)
  perm 120 :  100358  (16.726%)
  perm 201 :   99760  (16.627%)
  perm 210 :  100180  (16.697%)

Break-it #2 — the Ace bug, a hand that busts and should not. naiveAceValue sums every card at its face blackjack value and never reconsiders an Ace once it is on the table — the natural first draft. Full file, copy-paste compilable:

import cards.Card;
import cards.Hand;
import cards.Rank;
import cards.Suit;

/** BREAK IT #2: a hand-valuation bug that never crashes -- it just quietly
 *  busts a hand that is legitimately still alive. naiveValue() scores every
 *  Ace as a fixed 11, the way a first draft almost always does, and never
 *  reconsiders that choice once the hand grows. */
public final class BreakItAceDemo {

    static int naiveValue(Hand h) {
        int total = 0;
        for (Card c : h.cards()) total += c.rank().blackjackValue(); // Ace always 11, no downgrade
        return total;
    }

    public static void main(String[] args) {
        Hand h = new Hand();
        h.add(new Card(Suit.SPADES, Rank.ACE));
        h.add(new Card(Suit.HEARTS, Rank.SIX));
        System.out.println("A+6 dealt:");
        System.out.println("  naive value = " + naiveValue(h));
        System.out.println("  correct value = " + h.value() + (h.isSoft() ? " (soft)" : " (hard)"));

        h.add(new Card(Suit.CLUBS, Rank.FIVE)); // hit
        System.out.println();
        System.out.println("hit, drew 5 -> A+6+5:");
        int naive = naiveValue(h);
        System.out.println("  naive value = " + naive + "  => naive says BUST: " + (naive > 21));
        System.out.println("  correct value = " + h.value() + "  => correct says BUST: " + h.isBust()
                + ", soft: " + h.isSoft());
    }
}

Run it — actual output, captured verbatim from java -cp out BreakItAceDemo:

A+6 dealt:
  naive value = 17
  correct value = 17 (soft)

hit, drew 5 -> A+6+5:
  naive value = 22  => naive says BUST: true
  correct value = 12  => correct says BUST: false, soft: false

Both scorers agree on the two-card hand (17, soft) — the bug is invisible until the hand grows. The instant a third card would push the always-11 total over 21, the naive scorer declares a bust on a hand that the rules say is worth exactly 12 and very much alive. This is the “busts a soft hand that should not bust” failure named in the milestone spec, reproduced deterministically, not a hypothetical. The Go version (naiveAceValue in demo_ace_bug.go) reproduces the identical numbers:

// demo_ace_bug.go
package main

import "fmt"

// naiveAceValue is BREAK IT #2: a hand-valuation bug that never crashes --
// it just quietly busts a hand that is legitimately still alive. It scores
// every Ace as a fixed 11 and never reconsiders that choice as the hand grows.
func naiveAceValue(h *Hand) int {
	total := 0
	for _, c := range h.Cards() {
		total += c.Rank.blackjackValue() // Ace always 11, no downgrade
	}
	return total
}

func runAceBugDemo() {
	h := &Hand{}
	h.Add(Card{Suit: Spades, Rank: Ace})
	h.Add(Card{Suit: Hearts, Rank: Six})
	fmt.Println("A+6 dealt:")
	fmt.Println("  naive value =", naiveAceValue(h))
	fmt.Println("  correct value =", h.Value(), softness(h))

	h.Add(Card{Suit: Clubs, Rank: Five}) // hit
	fmt.Println()
	fmt.Println("hit, drew 5 -> A+6+5:")
	naive := naiveAceValue(h)
	fmt.Println("  naive value =", naive, " => naive says BUST:", naive > 21)
	fmt.Println("  correct value =", h.Value(), " => correct says BUST:", h.IsBust(), ", soft:", h.IsSoft())
}
A+6 dealt:
  naive value = 17
  correct value = 17 (soft)

hit, drew 5 -> A+6+5:
  naive value = 22  => naive says BUST: true
  correct value = 12  => correct says BUST: false , soft: false

6. Optimise — with trade-offs

Shuffle approachUniformityCostUse when
Fisher-Yates / Durstenfeld (this lab)Provably uniform — a bijection from n! equally-likely choice-sequences onto n! permutations (Movement 3)O(n) time, O(1) extra space, in placeThe default, always — there is no reason to reach for anything more expensive for a plain shuffle
Sort-by-random-key (assign each card a random key, sort by key)Uniform ONLY if keys are drawn from a high-resolution, collision-free space (e.g. a 64-bit float/long) — collisions silently break uniformity by tying two cards’ relative order to sort stability, not chanceO(n log n) time, plus the RNG callsYou need a shuffle expressed as a one-line LINQ/stream-style sortBy(random) for readability and can guarantee a wide enough key space — otherwise it is strictly worse than Fisher-Yates with a subtler failure mode
Naive full-range swap (Movement 1’s trap)Provably NOT uniform for any n > 1 where n! does not divide nn evenly (true for almost every n) — measured in Movement 5O(n) time — same asymptotic cost as the correct versionNever. It costs the same as Fisher-Yates and is wrong — there is no scenario where this is the right trade, only the one where nobody checked

The real judgment call is not Fisher-Yates vs its alternatives — nothing seriously competes with it on cost — it is trusting a home-rolled shuffle at all once real money is involved. Collections.shuffle (Java) and rand.Shuffle (Go) already implement Fisher-Yates correctly; ship those in production and keep your own implementation for exactly one purpose: proving in an interview, or in a design review, that you understand WHY the library call is correct and can name the failure mode of the version that looks the same but is not.

H17 vs S17 — the dealer rule is a real, priced trade-off, not flavor text. Movement 4/5 showed the SAME dealer hand (A+6, soft 17) get opposite answers depending on the rule. That single-hand disagreement compounds: forcing the dealer to hit soft 17 gives the dealer strictly one more chance to improve a middling hand into a strong one on exactly the hands where the player would have been happy to see the dealer stand pat. Standard casino-rules references (Wizard of Odds’ basic-strategy engine; consistent with Thorp’s original analysis) tabulate H17 as roughly a 0.2 percentage-point increase in house edge over S17 on an otherwise identical 6-deck game — small per hand, meaningful at casino volume, and exactly the kind of number a staff engineer should be able to cite when asked “does this rule actually matter?” without re-deriving a full basic-strategy simulation on a whiteboard.

Extensibility — reusing this for a different card game. Suit/Rank/Card/Deck/Shuffles carry zero Blackjack-specific logic — they are exactly as useful for Poker, War, or Solitaire. What is Blackjack-specific is entirely inside Hand (the soft-ace valuation) and Dealer (the H17/S17 rule). The extension point is the same shape as the parking lot’s FareStrategy and the vending machine’s PaymentMethod: pull hand-scoring behind a small interface (int score(List<Card>)) and a new game becomes a new scorer, never a change to Deck or Shuffles. Splitting a hand (a real Blackjack rule this lab scopes out per Movement 2) fits the same shape: it turns one Hand into two independent Hand objects, each re-using the same value()/isSoft() logic unchanged — the model does not need to be redesigned to add it, only driven from an extra event in the round loop.

7. Defend under drilling

These are the follow-ups that come up almost every time, with the answer a staff engineer gives — short, concrete, no hedging.

8. You can now defend


Re-authored/Deepened for this guide. Reference code compiled and executed (javac -Xlint:all + java; gofmt, go vet, go build, go run) before publishing; the shuffle-bias distribution and the Ace-bug bust are both measured/reproduced deterministically in both languages, not asserted. Sourced from the classic Blackjack/Deck-of-Cards object-design exercise (Grokking the Object Oriented Design Interview; Cracking the Coding Interview, Gayle Laakmann McDowell) and standard casino Blackjack rules and house-edge tables (Bicycle; Wizard of Odds; Edward Thorp, Beat the Dealer). See also: the Design Blackjack and a Deck of Cards walkthrough.

🤖 Don't fully get this? Learn it with Claude

Stuck on Design a Deck of Cards & Blackjack? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.

🎨 Explain it visually

Build the mental picture, not memorization.

I just read a lesson on **Design a Deck of Cards & Blackjack** (Hands-On Builds) and want to truly understand it. Explain Design a Deck of Cards & Blackjack from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🤔 Walk me through it (interactive)

Socratic — adapts to where you're stuck.

Teach me **Design a Deck of Cards & Blackjack** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🧪 Quiz me & fix my gaps

Active recall exposes what you missed.

Quiz me on **Design a Deck of Cards & Blackjack** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🧠 Make it stick

Intuition + hook + flashcards for long-term memory.

Help me remember **Design a Deck of Cards & Blackjack** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.

📝 My notes