Skip to content

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.

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”
card.py
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?”:

  1. __eq__ by value — two aces of spades are equal.
  2. __hash__ — so a card works as a dict key or set member. A mutable dataclass gets __eq__ but its __hash__ is set to None, making it unhashable.
  3. Immutabilitycard.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:

Classx == y for two aces of spadeslen({x, y})
@dataclass(frozen=True)True1
Plain class, no __eq__False2 — 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.

deck.py
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.

sketch Every possible 3-card shuffle, enumerated p5.js
Not a sample -- the complete path tree for both versions. The correct loop has 3x2 = 6 equally likely paths and reaches all 6 permutations exactly once each. Excluding i leaves only 2x1 = 2 paths, so at most 2 permutations can ever come out, and both happen to be derangements. The bug cannot be caught by asserting the result is a permutation, because it always is. Only counting outcomes exposes it.

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:

ranking.py
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.”

sketch One card, three values: why ranking cannot live on Card p5.js
The same thirteen ranks under all three Rankings from the page. AceHigh and AceLow are both bijections -- they only disagree about where the ace goes. Blackjack is the one that settles the design argument: jack, queen and king all collapse onto 10, so the map is not injective, and the ace is 11 or 1 depending on the rest of the hand. A value that is not a function of the card alone cannot be a method on Card.
Decorator__eq____hash__Usable in a set?Mutable?
@dataclassgeneratedset to NoneTypeErroryes
@dataclass(frozen=True)generatedgeneratedno
Plain classidentityidentity✅ but wrong — hashes by idyes

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.

Actionlen(deck)Notes
Deck()52and len(set(cards)) is also 52 — no duplicates
shuffle()52same multiset, different order — verified
deal(5)47five 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.

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.

OperationCostNote
Build a deckO(52)O(52) = O(1)O(1)the set is closed and known
shuffleO(n)O(n)one pass, one swap per position
deal(n)O(n)O(n) to slice, O(len)O(len) to rebuild the remaindera deque.popleft loop is O(n)O(n) with no rebuild
card in hand (list)O(h)O(h)fine for a 5-card hand
card in deck (set)O(1)O(1)needs __hash__ — hence frozen=True
Ranking.sort(hand)O(hlogh)O(h \log h)Timsort on a tiny list
Counting a Counter(hand)O(h)O(h)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 deal rebuilding the list is avoidable with a deque or 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 O(416)O(416) instead of O(1)O(1).
VariantThe changeNotes
Standard 52-card deckThe base design
JokersTwo more cards, and Ranking must handle themrank="JOKER" rather than a subclass
Multiple decks (a shoe)Deck(cards=[...] * 6) — cards are no longer uniqueCounter, not set, for counting
Ace high / ace lowA Ranking subclass eachSame cards, different order
Blackjack valuesA Ranking where J/Q/K all map to 10Not injective — proves ranking is not a card property
Ace as 1 or 11The hand computes it, not the cardValue depends on other cards
Poker hand evaluationA HandEvaluator policy over Counter(ranks) and suit countsThe real work of a poker design
Dealing to k playersRound-robin from the deck, or slice per playerRound-robin matches physical dealing
Reproducible gamesInject random.Random(seed) rather than using the module functionsAlso what makes shuffling testable
Cut / riffle shuffleA different Shuffle policy — deliberately not uniformSimulating a physical shuffle
  • A plain class without __eq__. Card("A", SPADES) == Card("A", SPADES) is False, and a set holds both — verified 2 entries. card in hand then silently fails for a card that is present.
  • @dataclass without frozen=True. You get __eq__ and lose __hash__ (Python sets it to None), so the card is unhashable and {card} raises TypeError. frozen=True restores 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.
  • set on a multi-deck shoe. Six aces of spades are equal, so set collapses them and len(set(shoe)) is 52 rather than 312. Use a Counter.
  • deal returning 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.shuffle and then being unable to test it. Inject a random.Random(seed); module-level random makes games unreproducible and shuffles unverifiable.
  • Rebuilding the list on every deal. self.cards = self.cards[n:] copies. An index cursor or a deque avoids it — minor here, but it is the kind of thing worth naming.
  • Over-building. CardFactory, AbstractDeckBuilder, a Suit hierarchy. The correct answer is genuinely small, and reaching for patterns is the failure mode this prompt tests.

Drill 1 — value semantics, and what you lose without them

Section titled “Drill 1 — value semantics, and what you lose without them”
They askWhat they’re checkingThe 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 missA 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 consequenceIdentity 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 boundFisher-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 enoughNot 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 propertyIt 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 argumentJ, 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 biteCards 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 modellingRaise. 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.”TestabilityInject 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 goesA 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 trapNo — 4 suits × 13 ranks is 52 subclasses to express two fields. Suit is an Enum, rank is a value
pch.quizTag pch.quizDefaultTitle
  1. Why `@dataclass(frozen=True)` rather than a plain `@dataclass` for Card?

    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.

  2. You use a plain class with no `__eq__`. What happens to `Card('A','spades') == Card('A','spades')` and to a set of both?

    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.

  3. Fisher-Yates with `randint(0, i - 1)` instead of `randint(0, i)`. How bad is it for three cards?

    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.

  4. How should you test a shuffle?

    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).

  5. Where does card ranking belong?

    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.

  6. What does blackjack prove about ranking?

    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.

  7. Six-deck shoe. What goes wrong with `set(cards)`?

    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.

  8. `deal(5)` on a three-card deck. Raise or return three cards?

    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.

  9. Why inject `random.Random(seed)` rather than calling module-level `random.shuffle`?

    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.

  • 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=True does three jobs: __eq__ by value, __hash__, and immutability. A plain @dataclass gives __eq__ and sets __hash__ to None, 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 hand silently fails.
  • Fisher-Yates needs randint(0, i), inclusive. Excluding i gives 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 Counter is 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 a Counter.
  • deal past the end raises (caller bug), where a full parking lot returns None (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.coffeeCta

pch.feedbackHeading

pch.feedbackSubheading