Design a Deck of Cards
“Design a deck of cards” looks like the easiest OOD prompt and it is the one candidates most
often over-build. There is no state machine and no resource pool here — this is the value
object archetype, and almost all of the answer is choosing the right three lines for Card.
The interesting decisions are the two most people skip: who owns the ranking, and whether the shuffle is actually uniform.
The cue
Section titled “The cue”When it is not this. If instances have a lifecycle or mutable state, they are entities, not
value objects — a Player with a changing chip count is not a value object even though a Card
is. And if the interesting question is allocation from a pool it is
the parking lot; if it is modes and transitions it is
the elevator.
Card is three lines, and the decorator is the design
Section titled “Card is three lines, and the decorator is the design”from dataclasses import dataclass
from enum import Enum
class Suit(Enum):
CLUBS = "clubs"
DIAMONDS = "diamonds"
HEARTS = "hearts"
SPADES = "spades"
RANKS = ["2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K", "A"]
@dataclass(frozen=True)
class Card:
"""A value object: equal by value, hashable, immutable."""
rank: str
suit: Suit
a = Card("A", Suit.SPADES)
b = Card("A", Suit.SPADES)
print([a == b, a is b, len({a, b})])
# expect [True, False, 1]frozen=True is doing three separate jobs, and naming all three is the answer to “why a
dataclass?”:
__eq__by value — two aces of spades are equal.__hash__— so a card works as a dict key or set member. A mutable dataclass gets__eq__but its__hash__is set toNone, making it unhashable.- Immutability —
card.rank = "K"raises, so a card cannot be mutated inside a hand and silently corrupt a deck it was dealt from.
Without it, you get identity semantics:
| Class | x == y for two aces of spades | len({x, y}) |
|---|---|---|
@dataclass(frozen=True) | True | 1 |
Plain class, no __eq__ | False | 2 — hashed by id |
Verified. The plain-class row is the bug: card in hand silently returns False for a card
that is present, and a set of cards happily holds duplicates. Nothing raises.
The deck, and the shuffle
Section titled “The deck, and the shuffle”import random
class Deck:
"""Owns the card order. Knows nothing about any particular game's rules."""
def __init__(self, cards=None, rng=None):
self.cards = list(cards) if cards is not None else [
Card(rank, suit) for suit in Suit for rank in RANKS
]
self.rng = rng or random.Random()
def __len__(self):
return len(self.cards)
def shuffle(self):
"""Fisher-Yates. The bound MUST include i."""
for i in range(len(self.cards) - 1, 0, -1):
j = self.rng.randint(0, i) # inclusive — an element may stay put
self.cards[i], self.cards[j] = self.cards[j], self.cards[i]
def deal(self, n):
if n > len(self.cards):
raise ValueError(f"cannot deal {n} from {len(self.cards)}")
dealt, self.cards = self.cards[:n], self.cards[n:]
return dealt
d = Deck()
print([len(d), len(set(d.cards))])
# expect [52, 52]deal raises rather than returning a short hand, and that is the opposite call from the
parking lot’s “full lot returns None”. The distinction: a full lot is an expected business
outcome, whereas dealing five cards from a three-card deck is a caller bug — nothing sensible
can be returned.
Ranking belongs to the game, not the card
Section titled “Ranking belongs to the game, not the card”Here is the decision that separates a thought-through answer from a hierarchy of card subclasses. Is an ace high or low? It depends entirely on the game:
| Ordering | [A♠, 2♥, K♣] sorts to |
|---|---|
| Ace high (poker) | ['2', 'K', 'A'] |
| Ace low (some rummy variants) | ['A', '2', 'K'] |
Verified. The same three cards order differently, so the ranking cannot live on Card — it is
not a property of the card at all. It is a property of the game:
from abc import ABC, abstractmethod
class Ranking(ABC):
"""The extension point. A new game is a new Ranking, not a new Card."""
@abstractmethod
def value(self, card) -> int: ...
def sort(self, cards):
return sorted(cards, key=self.value)
class AceHigh(Ranking):
ORDER = {r: i for i, r in enumerate(RANKS)}
def value(self, card):
return self.ORDER[card.rank]
class AceLow(Ranking):
ORDER = {r: i for i, r in enumerate(["A"] + RANKS[:-1])}
def value(self, card):
return self.ORDER[card.rank]
class Blackjack(Ranking):
"""Not even a total order — several ranks share a value."""
def value(self, card):
if card.rank in ("J", "Q", "K"):
return 10
if card.rank == "A":
return 11 # or 1; the hand decides, not the card
return int(card.rank)Blackjack is the case that kills any attempt to put ranking on the card: jack, queen and
king all have value 10, so the ranking is not even injective, let alone a fixed property. And
an ace is 11 or 1 depending on the rest of the hand — which means the value is not a function
of the card alone.
That last point is worth saying out loud: “in blackjack, a card’s value depends on the other
cards, so it cannot be a method on Card under any design.”
Dry run
Section titled “Dry run”Why frozen=True and not just @dataclass
Section titled “Why frozen=True and not just @dataclass”| Decorator | __eq__ | __hash__ | Usable in a set? | Mutable? |
|---|---|---|---|---|
@dataclass | generated | set to None | ❌ TypeError | yes |
@dataclass(frozen=True) | generated | generated | ✅ | no |
| Plain class | identity | identity | ✅ but wrong — hashes by id | yes |
The middle column is the one people trip on. A plain @dataclass gives you value equality and
then removes hashability, because a mutable object whose hash depends on its fields would
break any set containing it the moment a field changed. Python makes that choice for you, and
frozen=True is how you opt back in.
Measured: the plain class reports x == y as False and puts both aces of spades in a set as
2 entries. Neither raises. card in hand failing for a card that is present is the symptom.
The deck, dealt
Section titled “The deck, dealt”| Action | len(deck) | Notes |
|---|---|---|
Deck() | 52 | and len(set(cards)) is also 52 — no duplicates |
shuffle() | 52 | same multiset, different order — verified |
deal(5) | 47 | five cards removed from the front |
deal(48) | — | raises — a caller bug, not an outcome |
shuffle preserving the multiset is worth checking explicitly rather than eyeballing: a
Counter of the shuffled deck equals a Counter of the original. That check catches the whole
family of shuffle bugs that lose or duplicate a card, and it is one line.
The shuffle bias, by hand
Section titled “The shuffle bias, by hand”For three cards, the biased version makes only two random draws: i = 2 picks j from
{0, 1}, then i = 1 picks j from {0} — forced.
j = 0: swap slots 2 and 0 gives[4, 3, 2], then the forced swap gives[3, 4, 2]j = 1: swap slots 2 and 1 gives[2, 4, 3], then the forced swap gives[4, 2, 3]
Two paths, two outcomes, each with probability ½ — which is exactly what the 6,000-trial count
showed: {('3','4','2'): 2998, ('4','2','3'): 3002}. The identity order 2, 3, 4 is
unreachable, along with three others.
For a card game that is a live cheating vector, not an academic point: a shuffle that never produces certain orders is predictable.
Complexity
Section titled “Complexity”| Operation | Cost | Note |
|---|---|---|
| Build a deck | = | the set is closed and known |
shuffle | one pass, one swap per position | |
deal(n) | to slice, to rebuild the remainder | a deque.popleft loop is with no rebuild |
card in hand (list) | fine for a 5-card hand | |
card in deck (set) | needs __hash__ — hence frozen=True | |
Ranking.sort(hand) | Timsort on a tiny list | |
Counting a Counter(hand) | the poker-hand primitive |
Two honest notes:
- All of these are trivially fast because the deck is 52 cards. Nobody is optimising a card
game’s asymptotics, and saying so is better than pretending otherwise. What the complexity
column does tell you is that
dealrebuilding the list is avoidable with adequeor an index cursor — a real if minor design improvement. __hash__is the only complexity-relevant design choice. Without it, every membership test is a linear scan and a “have I seen this card” check on a shoe of eight decks is instead of .
The variant map
Section titled “The variant map”| Variant | The change | Notes |
|---|---|---|
| Standard 52-card deck | The base design | — |
| Jokers | Two more cards, and Ranking must handle them | rank="JOKER" rather than a subclass |
| Multiple decks (a shoe) | Deck(cards=[...] * 6) — cards are no longer unique | Counter, not set, for counting |
| Ace high / ace low | A Ranking subclass each | Same cards, different order |
| Blackjack values | A Ranking where J/Q/K all map to 10 | Not injective — proves ranking is not a card property |
| Ace as 1 or 11 | The hand computes it, not the card | Value depends on other cards |
| Poker hand evaluation | A HandEvaluator policy over Counter(ranks) and suit counts | The real work of a poker design |
Dealing to k players | Round-robin from the deck, or slice per player | Round-robin matches physical dealing |
| Reproducible games | Inject random.Random(seed) rather than using the module functions | Also what makes shuffling testable |
| Cut / riffle shuffle | A different Shuffle policy — deliberately not uniform | Simulating a physical shuffle |
Pitfalls
Section titled “Pitfalls”- A plain class without
__eq__.Card("A", SPADES) == Card("A", SPADES)isFalse, and a set holds both — verified 2 entries.card in handthen silently fails for a card that is present. @dataclasswithoutfrozen=True. You get__eq__and lose__hash__(Python sets it toNone), so the card is unhashable and{card}raisesTypeError.frozen=Truerestores it.randint(0, i - 1)in the shuffle. Produces 2 of 6 permutations for three cards instead of 6, and the identity order is unreachable. Every result is still a valid permutation, so validity tests pass — you have to count outcomes.- Ranking as a method on
Card. Ace high versus ace low orders the same three cards differently, and blackjack maps J/Q/K all to 10. The ranking is a property of the game. - Trying to make blackjack’s ace a card property. Its value is 11 or 1 depending on the rest of the hand, so it is not a function of the card at all.
- A subclass per suit or rank. 4 suits × 13 ranks is 52 subclasses to express two fields. Composition, as always.
seton a multi-deck shoe. Six aces of spades are equal, sosetcollapses them andlen(set(shoe))is 52 rather than 312. Use aCounter.dealreturning a short hand when the deck runs out. Unlike a full parking lot, this is a caller bug — raise. Returning three cards when five were asked for propagates the error somewhere harder to diagnose.- Using
random.shuffleand then being unable to test it. Inject arandom.Random(seed); module-levelrandommakes games unreproducible and shuffles unverifiable. - Rebuilding the list on every deal.
self.cards = self.cards[n:]copies. An index cursor or adequeavoids it — minor here, but it is the kind of thing worth naming. - Over-building.
CardFactory,AbstractDeckBuilder, aSuithierarchy. The correct answer is genuinely small, and reaching for patterns is the failure mode this prompt tests.
Try it yourself
Section titled “Try it yourself”Drill 1 — value semantics, and what you lose without them
Section titled “Drill 1 — value semantics, and what you lose without them”Drill 2 — the shuffle bound
Section titled “Drill 2 — the shuffle bound”Drill 3 — ranking belongs to the game
Section titled “Drill 3 — ranking belongs to the game”Interview follow-ups
Section titled “Interview follow-ups”| They ask | What they’re checking | The answer |
|---|---|---|
| “How do you represent a card?” | Whether you know the idiom | @dataclass(frozen=True) with rank and suit. That gives value equality, hashability and immutability in one decorator — and naming all three is the answer, not just “a dataclass” |
“Why frozen=True rather than plain @dataclass?” | The detail most people miss | A plain dataclass generates __eq__ and then sets __hash__ to None, so the card is unhashable. frozen restores the hash because the fields can no longer change under a set |
| “What breaks with a plain class?” | Concrete consequence | Identity semantics: two aces of spades compare unequal and a set holds both — verified 2 entries. card in hand silently returns False for a card that is present |
| “Write the shuffle.” | The randint bound | Fisher-Yates with randint(0, i) inclusive. Excluding i gives 2 of 6 permutations for three cards and never produces the identity — while still always returning a valid permutation |
| “How would you test the shuffle?” | Whether you know validity is not enough | Not by asserting it is a permutation — the biased version passes that. Count outcomes over many trials, and check the multiset is preserved with a Counter |
| “Is the ace high or low?” | Whether you see it is not a card property | It depends on the game, so it belongs to a Ranking policy. The same three cards sort to ['2','K','A'] or ['A','2','K'] |
| “Model blackjack values.” | The killer argument | J, Q and K all map to 10, so the ranking is not injective — and an ace is 11 or 1 depending on the rest of the hand, so the value is not a function of the card at all. That settles where ranking lives |
| “Now it is a six-deck shoe.” | Where value semantics bite | Cards stop being unique: six aces of spades are equal, so set(shoe) collapses to 52 rather than 312. Switch to a Counter wherever multiplicity matters |
| “Deal five from a three-card deck.” | Error modelling | Raise. Unlike a full parking lot — an expected outcome — this is a caller bug, and returning three cards propagates the error somewhere harder to find |
| “Make the game reproducible.” | Testability | Inject random.Random(seed) rather than calling module-level random. That is also what makes the shuffle verifiable at all |
| “Evaluate a poker hand.” | Where the real work goes | A HandEvaluator policy over Counter(ranks) plus suit counts — pairs and trips fall out of the counter, straights out of consecutive rank values, flushes out of a single suit count |
| “Would you subclass per suit?” | The over-build trap | No — 4 suits × 13 ranks is 52 subclasses to express two fields. Suit is an Enum, rank is a value |
Self-check
Section titled “Self-check”-
Why `@dataclass(frozen=True)` rather than a plain `@dataclass` for Card?
Python removes hashability from mutable dataclasses on purpose: an object whose hash depends on its fields would break any set containing it the moment a field changed. So a plain @dataclass gives you value equality and takes away set/dict membership, and `{card}` raises TypeError. frozen=True is how you opt back in.
pch.quizShowAnswer
B — A plain dataclass generates __eq__ but sets __hash__ to None, making the card unhashable — frozen restores the hash because the fields can no longer change — Python removes hashability from mutable dataclasses on purpose: an object whose hash depends on its fields would break any set containing it the moment a field changed. So a plain @dataclass gives you value equality and takes away set/dict membership, and `{card}` raises TypeError. frozen=True is how you opt back in.
-
You use a plain class with no `__eq__`. What happens to `Card('A','spades') == Card('A','spades')` and to a set of both?
Verified. And nothing raises, which is what makes it dangerous: `card in hand` returns False for a card that is present, and a 'set' of cards happily contains duplicates. The failure surfaces as a wrong game outcome far from the class definition.
pch.quizShowAnswer
B — False, and 2 entries — identity semantics, hashed by id — Verified. And nothing raises, which is what makes it dangerous: `card in hand` returns False for a card that is present, and a 'set' of cards happily contains duplicates. The failure surfaces as a wrong game outcome far from the class definition.
-
Fisher-Yates with `randint(0, i - 1)` instead of `randint(0, i)`. How bad is it for three cards?
Measured over 6,000 trials: 2 distinct permutations at ~3000 each, against 6 at ~1000 each for the correct version. Excluding i forces every card to move, so the reachable set is permutations with no fixed point — strictly smaller. Because each result is still a valid permutation, a test asserting sorted(result) == sorted(deck) passes.
pch.quizShowAnswer
B — It produces only 2 of the 6 permutations and never the identity order — while still always returning a valid permutation — Measured over 6,000 trials: 2 distinct permutations at ~3000 each, against 6 at ~1000 each for the correct version. Excluding i forces every card to move, so the reachable set is permutations with no fixed point — strictly smaller. Because each result is still a valid permutation, a test asserting sorted(result) == sorted(deck) passes.
-
How should you test a shuffle?
Validity is exactly the property the biased shuffle satisfies, which is why it is the wrong test. The two checks that catch real bugs are distributional (count permutations, or at least positions) and conservational (a Counter of the result equals a Counter of the deck, so no card was lost or duplicated).
pch.quizShowAnswer
B — Count outcomes over many trials for uniformity, and check the multiset is preserved with a Counter — validity alone passes even for the biased version — Validity is exactly the property the biased shuffle satisfies, which is why it is the wrong test. The two checks that catch real bugs are distributional (count permutations, or at least positions) and conservational (a Counter of the result equals a Counter of the deck, so no card was lost or duplicated).
-
Where does card ranking belong?
Verified: [A, 2, K] sorts to ['2','K','A'] ace-high and ['A','2','K'] ace-low. Since the same card has different values in different games, the value is not a property of the card. A module constant is only marginally better than a method — it still assumes one global ordering.
pch.quizShowAnswer
B — A Ranking policy owned by the game — ace high and ace low order the same three cards differently — Verified: [A, 2, K] sorts to ['2','K','A'] ace-high and ['A','2','K'] ace-low. Since the same card has different values in different games, the value is not a property of the card. A module constant is only marginally better than a method — it still assumes one global ordering.
-
What does blackjack prove about ranking?
This is the argument that closes the question. A method on Card could in principle return different numbers per game if you passed the game in — but it could never return a value that depends on the *other cards in the hand*, which an ace's 11-or-1 does. So the hand computes it, and Card stays a pure value object.
pch.quizShowAnswer
B — That the ranking is not even injective — J, Q and K all map to 10 — and an ace's value depends on the rest of the hand, so it is not a function of the card at all — This is the argument that closes the question. A method on Card could in principle return different numbers per game if you passed the game in — but it could never return a value that depends on the *other cards in the hand*, which an ace's 11-or-1 does. So the hand computes it, and Card stays a pure value object.
-
Six-deck shoe. What goes wrong with `set(cards)`?
This is the one place value semantics work against you — and it follows directly from the design being correct. Equal cards *should* be equal; a set of them *should* deduplicate. So reach for a Counter when you mean 'how many of each' and a set only when you mean 'which distinct cards exist'. Noticing it unprompted is a good signal.
pch.quizShowAnswer
B — Six aces of spades are EQUAL, so the set collapses them: len(set(shoe)) is 52, not 312. Use a Counter where multiplicity matters — This is the one place value semantics work against you — and it follows directly from the design being correct. Equal cards *should* be equal; a set of them *should* deduplicate. So reach for a Counter when you mean 'how many of each' and a set only when you mean 'which distinct cards exist'. Noticing it unprompted is a good signal.
-
`deal(5)` on a three-card deck. Raise or return three cards?
The distinction is whether the situation is a legitimate business outcome the caller must routinely handle, or a violation of the caller's own contract. A full car park is the former; asking for more cards than exist is the latter — there is no sensible value to return, and a short hand will be treated as a real hand.
pch.quizShowAnswer
B — Raise — unlike a full lot, this is a caller bug, and returning a short hand propagates the error somewhere harder to diagnose — The distinction is whether the situation is a legitimate business outcome the caller must routinely handle, or a violation of the caller's own contract. A full car park is the former; asking for more cards than exist is the latter — there is no sensible value to return, and a short hand will be treated as a real hand.
-
Why inject `random.Random(seed)` rather than calling module-level `random.shuffle`?
Dependency injection here buys testability specifically. Without a controllable source of randomness you cannot write the count-the-permutations test that catches the biased bound, and you cannot reproduce a reported game. It is a small design choice with a direct effect on whether the most important bug on this page is catchable.
pch.quizShowAnswer
B — Reproducibility: a seeded generator makes games replayable and the shuffle itself testable — the distributional checks above are impossible otherwise — Dependency injection here buys testability specifically. Without a controllable source of randomness you cannot write the count-the-permutations test that catches the biased bound, and you cannot reproduce a reported game. It is a small design choice with a direct effect on whether the most important bug on this page is catchable.
Recall card
Section titled “Recall card”- A card is a value object:
@dataclass(frozen=True)with rank and suit. That is genuinely most of the answer — over-building is the failure mode this prompt tests. frozen=Truedoes three jobs:__eq__by value,__hash__, and immutability. A plain@dataclassgives__eq__and sets__hash__toNone, so the card becomes unhashable.- Without
__eq__you get identity semantics — two aces of spades compare unequal and a set holds 2 entries.card in handsilently fails. - Fisher-Yates needs
randint(0, i), inclusive. Excludingigives 2 of 6 permutations for three cards and never the identity. - Test a shuffle by counting outcomes, not by asserting validity — the biased version always
returns a valid permutation. Also check a
Counteris preserved. - Ranking belongs to the game, not the card. Ace-high and ace-low sort
[A, 2, K]differently. - Blackjack settles it: J/Q/K all map to 10 (not injective), and an ace is 11 or 1 depending on the rest of the hand — so the value is not a function of the card.
- A shoe breaks
set— six equal aces of spades collapse to one, giving 52 instead of 312. Use aCounter. dealpast the end raises (caller bug), where a full parking lot returnsNone(expected outcome).- Inject
random.Random(seed)for reproducible games and a testable shuffle. - No subclass per suit or rank — that is 52 classes for two fields.
pch.coffeeTagline
pch.coffeeCtapch.feedbackHeading
pch.feedbackSubheading