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:
- One deck or a multi-deck shoe? We build one 52-card
Deckas the core (M1–M4) — it is the smallest thing that exercises shuffle correctness and dealing — then note the shoe (N decks flattened together) as a direct extension: same shuffle, same deal, just a bigger starting list. - Does the shuffle need to be cryptographically secure? Not for this lab —
java.util.Random/ Go’smath/randare fine for correctness and for reproducing a bug with a fixed seed. Flag it explicitly: a real-money casino platform must use a CSPRNG (SecureRandom/crypto/rand) and often a licensed, auditable RNG — uniformity of the algorithm and unpredictability of the seed are two separate requirements, and this lab is entirely about the first one. - How much of Blackjack’s rule surface is in scope? Core: deal, hit, stand, dealer plays a fixed rule. Doubling, splitting, insurance, surrender are real rules with their own edge cases (Movement 6 sketches split/double as extensions) — do not volunteer all of them unless asked; name them as follow-ups.
- Is the dealer rule fixed or configurable? This is the one every interviewer eventually asks: does the dealer hit or stand on a soft 17 (an Ace-high 17, e.g.
A+6)? Real casinos differ (H17 vs S17) and it measurably changes the house edge, so the rule must be a parameter, not a hard-coded branch buried in the dealer’s logic. - Concurrency: one physical shoe, one dealer. Like the vending machine and the parking lot, this models one physical resource — there is exactly one deck being dealt from at a time at one table. The interesting concurrency question here is not “many threads deal from one Deck” (that is not how a real table works) but “many tables, does each need its own Deck and its own RNG stream” — covered in Movement 7’s scale question.
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.
- Core contract:
Card(Suit, Rank);Deckwithshuffle(rng)anddeal() → Card(throws/errors when empty);Handwithadd(card),value(),isSoft(),isBust(),isBlackjack();Dealer(rule).shouldHit(hand) → boolean. - M1 — model + build the 52.
Suit×Rank(13 × 4), aDeckthat builds all 52 exactly once,deal()off the top. No randomness yet — get the inventory right first. - M2 — shuffle correctly. Implement Fisher-Yates from Movement 3’s derivation, not from memory of the name. Test: shuffle must be a permutation (same 52 cards, no duplicates, no drops) — that is necessary but nowhere near sufficient, which is exactly Movement 5’s point.
- M3 — soft-ace hand valuation.
value()andisSoft()per Movement 3’s “track both, resolve last” rule. Test:A+6= 17 soft;A+A= 12;A+K= 21 andisBlackjack();A+6+5= 12, not bust. - M4 — configurable dealer rule + one full round.
DealerRule.HIT_SOFT_17/STAND_SOFT_17threaded throughshouldHit(). Test: the SAMEA+6dealer hand must get a different answer from an H17 dealer than an S17 dealer — if it does not, the rule is not actually wired in.
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 approach | Uniformity | Cost | Use 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 place | The 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 chance | O(n log n) time, plus the RNG calls | You 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 5 | O(n) time — same asymptotic cost as the correct version | Never. 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.
- “Prove your shuffle is unbiased.” Two independent arguments, not one: (1) counting — Fisher-Yates makes
n, n-1, ..., 1independent choices,n!total sequences, exactly the number of permutations, and the map from sequence to output is a bijection, so every permutation has probability exactly1/n!; that is a proof, not an estimate. (2) measurement — Movement 5’s 600,000-trial run lands every permutation within a few hundredths of a percent of1/6, while the naive version measurably splits into a 14.8%/18.5% pattern that matches the pigeonhole prediction exactly. Lead with the counting argument; offer the measured numbers as corroboration, not as the whole answer — a simulation can never prove uniformity, only fail to disprove it within its trial count. - “Soft vs. hard ace — walk me through it.” Soft = at least one Ace in the hand is currently being counted as 11 without busting (
A+6= soft 17). Hard = no Ace, or every Ace has already been forced down to 1 because counting it as 11 would bust (A+6+5resolves to hard 12, not soft anything — the Ace had to drop). The tell that distinguishes them in code: track total assuming every Ace is 11, then downgrade one Ace at a time, each time you are over 21 AND at least one Ace is still “live” at 11. Whatever total you land on, and whether any Ace is still live, is the whole answer — nothing else about the hand matters for scoring. - “H17 vs S17 — does it actually matter, and by how much?” Yes, and it is not close to symmetric: forcing the dealer to hit a soft 17 (H17) only ever gives the dealer a chance to improve, never a chance to lock in 17 defensively, so it systematically favors the house by roughly 0.2 percentage points of edge on an otherwise-identical 6-deck game (Movement 6, sourced to standard casino-rules tables). If you are asked to derive the exact number from scratch: do not — say plainly that it requires simulating full basic strategy against both rule variants, cite the standard figure, and move on; treating a real gambling-math result as something you reinvent live is the wrong instinct in an interview.
- “Why not just call
Collections.shuffle/rand.Shuffleand skip all this?” In production, you should — both already implement Fisher-Yates correctly and are better-tested than anything written live. The point of building it yourself here is the same reason you implement a hash map once even though every language ships one: so that when someone hands you the “obvious” naive version in a review, you have the counting argument in your pocket instead of a vague feeling that something is off. - “What breaks at 100× the scale — say, an online casino running thousands of concurrent tables?” The single-shoe, single-dealer model (one
Deck, one RNG stream, dealt from sequentially) still holds PER TABLE — that part does not change, because each table genuinely is one physical (or virtual) shoe. What changes is everything around it: each table needs its OWNDeckand its own independent RNG stream — sharing one global RNG across tables is both a performance bottleneck (lock contention on every deal) and, more seriously, a correlation risk (two tables sharing a PRNG stream at known offsets is a real historical exploit class in online gambling). At regulatory scale you also need the seed (or the CSPRNG’s audit trail) logged per shoe so a disputed hand can be replayed and independently verified — “trust the shuffle” stops being enough once money and regulators are both watching; “prove and log the shuffle” is the bar.
8. You can now defend
- You can derive Fisher-Yates from the naive “swap with a random index” trap by counting — not recite the algorithm’s name — and you can prove uniformity with a pigeonhole argument BEFORE running a single trial, then confirm it with a real measured distribution over 600,000 shuffles in both Java and Go.
- You have broken the naive shuffle (a measured 14.8%/18.5% permutation skew where uniform is 16.7%) and the naive Ace scorer (a hand that busts at 22 when it is legitimately a live 12) on purpose, and you know exactly which one-line change fixes each.
- You can model a hand’s value as “every Ace is 11 until that busts, then downgrade one at a time” and explain precisely why that converges to the correct answer without enumerating every 1-or-11 combination explicitly.
- You can place a casino rule (H17 vs S17) behind a configuration parameter instead of a hard-coded branch, cite the real house-edge impact of getting it wrong, and extend the same Card/Deck/Shuffle model to a different card game by isolating the domain-specific scoring behind one small interface.
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.
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.
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.
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.
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.