Deck of Cards
The canonical OOD warm-up. Build a deck that can shuffle, deal, and support any card game — Poker, Blackjack, Rummy — without rewriting the core.
Key Abstractions
Immutable value object holding suit and rank
Collection of cards with shuffle and deal operations
Cards held by a player, scored via a game-specific evaluator
Strategy interface, so Poker ranks hands differently from Blackjack
Template method that drives turn order, dealing, and winner selection
Class Diagram
The Key Insight
The trap is treating "a deck of cards" as one problem. It's three: a data model (Card, Deck, Hand), a randomization concern (shuffle), and a scoring concern (Poker vs Blackjack vs Rummy). Most candidates collapse them into one class and then panic when the interviewer asks for a second game.
Keep the data model dumb and immutable. Put shuffling inside Deck behind a seeded RNG so tests stay deterministic. Hide scoring behind a HandEvaluator strategy — Poker gets its ranking, Blackjack gets its own arithmetic, and the deck doesn't care. Adding Rummy later becomes a new evaluator class and nothing else moves.
Requirements
Functional
- Standard 52-card deck with four suits and thirteen ranks
- Shuffle and deal from the top, tracking remaining cards
- Reset the deck to play another round without rebuilding it
- Plug in different game rules (Poker, Blackjack) without editing Deck or Card
Non-Functional
- Card equality and hashing must work (so a Set of seen cards behaves correctly)
- Shuffle must be seedable for reproducible tests
- Deal must be O(1); no list shifting
Design Decisions
Why is Card immutable?
An immutable Card can safely live in a HashSet, be compared with equals, and shared across hands without defensive copying. If Card were mutable, the Ace of Spades in Alice's hand could mutate into the Three of Clubs via a stray setter — and every player tracking it would silently lose track.
Why a cursor instead of list.pop(0)?
Popping from the front of a list is O(n) — every deal shifts the rest. A cursor pointer makes deal() O(1). Reset is just cursor = 0 rather than rebuilding 52 cards.
Why inject the RNG?
Tests that depend on "random" behavior are nightmares to debug. Injecting a seeded Random turns the shuffle deterministic in tests while staying random in production. One constructor argument; huge debugging win.
Why a Strategy interface for scoring?
The alternative — a giant if/else inside Hand — couples the deck to every card game that exists. With a strategy, adding Rummy is one class. Swapping Texas Hold'em for Five-Card Draw is another. None of it touches the deck or card model.
Interview Follow-ups
- "How would Texas Hold'em work?" Two personal cards plus five community cards. Build a
BestFiveFromSevenhelper that tries every 5-card subset and feeds each into PokerEvaluator. - "How do multiple decks work for Blackjack?" Deck becomes
MultiDeckcomposing N single decks. Shuffle across the full combined list. Reshuffle when the cut card is dealt. - "What about jokers?" Add
Rank.JOKERand let evaluators decide whether jokers are wild. PokerEvaluator treats them as wildcards; BlackjackEvaluator rejects them. - "How would a dealing animation fit?" Deal returns an event object that the UI subscribes to. The domain stays headless; rendering is a separate concern.
Code Implementation
1 from __future__ import annotations
2 from abc import ABC, abstractmethod
3 from dataclasses import dataclass, field
4 from enum import Enum
5 import random
6
7
8 class Suit(Enum):
9 HEARTS = "H"
10 DIAMONDS = "D"
11 CLUBS = "C"
12 SPADES = "S"
13
14
15 class Rank(Enum):
16 TWO = (2, "2"); THREE = (3, "3"); FOUR = (4, "4"); FIVE = (5, "5")
17 SIX = (6, "6"); SEVEN = (7, "7"); EIGHT = (8, "8"); NINE = (9, "9")
18 TEN = (10, "T"); JACK = (11, "J"); QUEEN = (12, "Q"); KING = (13, "K"); ACE = (14, "A")
19
20 def __init__(self, value: int, label: str):
21 self._value_ = value
22 self.label = label
23
24
25 @dataclass(frozen=True)
26 class Card:
27 suit: Suit
28 rank: Rank
29
30 def __str__(self) -> str:
31 return f"{self.rank.label}{self.suit.value}"
32
33
34 class Deck:
35 """Standard 52-card deck. Deals from the top via a cursor, not list.pop."""
36
37 def __init__(self, rng: random.Random | None = None):
38 self._cards = [Card(s, r) for s in Suit for r in Rank]
39 self._cursor = 0
40 self._rng = rng or random.Random()
41
42 def shuffle(self) -> None:
43 # Fisher-Yates, in place. Resets cursor so the full deck is dealable again.
44 self._rng.shuffle(self._cards)
45 self._cursor = 0
46
47 def deal(self) -> Card:
48 if self._cursor >= len(self._cards):
49 raise RuntimeError("Deck is empty")
50 card = self._cards[self._cursor]
51 self._cursor += 1
52 return card
53
54 def remaining(self) -> int:
55 return len(self._cards) - self._cursor
56
57 def reset(self) -> None:
58 self._cursor = 0
59
60
61 @dataclass
62 class Hand:
63 cards: list[Card] = field(default_factory=list)
64
65 def add(self, card: Card) -> None:
66 self.cards.append(card)
67
68 def size(self) -> int:
69 return len(self.cards)
70
71
72 class HandEvaluator(ABC):
73 @abstractmethod
74 def score(self, hand: Hand) -> int: ...
75
76 @abstractmethod
77 def describe(self, hand: Hand) -> str: ...
78
79
80 class PokerEvaluator(HandEvaluator):
81 """Ranks 5-card Poker hands. Higher score beats lower."""
82
83 FLUSH = 5_000_000
84 STRAIGHT = 4_000_000
85 THREE_KIND = 3_000_000
86 TWO_PAIR = 2_000_000
87 PAIR = 1_000_000
88
89 def score(self, hand: Hand) -> int:
90 if hand.size() != 5:
91 raise ValueError("Poker hand must be exactly 5 cards")
92 ranks = sorted((c.rank.value for c in hand.cards), reverse=True)
93 suits = {c.suit for c in hand.cards}
94 counts = {r: ranks.count(r) for r in ranks}
95 high_card = ranks[0]
96
97 is_flush = len(suits) == 1
98 is_straight = len(set(ranks)) == 5 and ranks[0] - ranks[4] == 4
99
100 if is_flush and is_straight:
101 return self.FLUSH + self.STRAIGHT + high_card
102 if is_flush:
103 return self.FLUSH + high_card
104 if is_straight:
105 return self.STRAIGHT + high_card
106 if 3 in counts.values():
107 return self.THREE_KIND + high_card
108 pairs = sum(1 for v in counts.values() if v == 2)
109 if pairs == 2:
110 return self.TWO_PAIR + high_card
111 if pairs == 1:
112 return self.PAIR + high_card
113 return high_card
114
115 def describe(self, hand: Hand) -> str:
116 s = self.score(hand)
117 if s >= self.FLUSH + self.STRAIGHT: return "Straight Flush"
118 if s >= self.FLUSH: return "Flush"
119 if s >= self.STRAIGHT: return "Straight"
120 if s >= self.THREE_KIND: return "Three of a Kind"
121 if s >= self.TWO_PAIR: return "Two Pair"
122 if s >= self.PAIR: return "Pair"
123 return "High Card"
124
125
126 class BlackjackEvaluator(HandEvaluator):
127 """Face cards are 10, Ace is 11 or 1 to avoid busting."""
128
129 def score(self, hand: Hand) -> int:
130 total = 0
131 aces = 0
132 for c in hand.cards:
133 v = c.rank.value
134 if v >= 11 and v <= 13: # J, Q, K
135 total += 10
136 elif v == 14: # Ace
137 total += 11
138 aces += 1
139 else:
140 total += v
141 while total > 21 and aces:
142 total -= 10
143 aces -= 1
144 return total
145
146 def describe(self, hand: Hand) -> str:
147 s = self.score(hand)
148 if s > 21: return f"Bust ({s})"
149 if s == 21: return "Blackjack"
150 return str(s)
151
152
153 @dataclass
154 class Player:
155 name: str
156 hand: Hand = field(default_factory=Hand)
157
158
159 class GameEngine(ABC):
160 """Template-method orchestrator. Subclasses supply the deal rule; the base
161 runs the game and picks the winner via the injected evaluator."""
162
163 def __init__(self, deck: Deck, evaluator: HandEvaluator, players: list[Player]):
164 if len(players) < 2:
165 raise ValueError("need at least two players")
166 self._deck = deck
167 self._evaluator = evaluator
168 self._players = players
169
170 @abstractmethod
171 def deal_initial(self) -> None: ...
172
173 def play(self) -> Player:
174 self._deck.reset()
175 self._deck.shuffle()
176 self.deal_initial()
177 return self._pick_winner()
178
179 def _pick_winner(self) -> Player:
180 return max(self._players, key=lambda p: self._evaluator.score(p.hand))
181
182
183 class PokerGame(GameEngine):
184 CARDS_PER_HAND = 5
185
186 def __init__(self, deck: Deck, players: list[Player]):
187 super().__init__(deck, PokerEvaluator(), players)
188
189 def deal_initial(self) -> None:
190 # Reset hands so replaying a game starts fresh.
191 for p in self._players:
192 p.hand = Hand()
193 for _ in range(self.CARDS_PER_HAND):
194 for p in self._players:
195 p.hand.add(self._deck.deal())
196
197
198 class BlackjackGame(GameEngine):
199 OPENING_CARDS = 2
200
201 def __init__(self, deck: Deck, players: list[Player]):
202 super().__init__(deck, BlackjackEvaluator(), players)
203
204 def deal_initial(self) -> None:
205 for p in self._players:
206 p.hand = Hand()
207 for _ in range(self.OPENING_CARDS):
208 for p in self._players:
209 p.hand.add(self._deck.deal())
210
211
212 if __name__ == "__main__":
213 # Seeded RNG so the sample run is reproducible.
214 deck = Deck(rng=random.Random(42))
215
216 alice = Player("Alice")
217 bob = Player("Bob")
218 game = PokerGame(deck, [alice, bob])
219 winner = game.play()
220
221 poker = PokerEvaluator()
222 print(f"Alice: {[str(c) for c in alice.hand.cards]} -> {poker.describe(alice.hand)}")
223 print(f"Bob: {[str(c) for c in bob.hand.cards]} -> {poker.describe(bob.hand)}")
224 print(f"Winner: {winner.name}")
225
226 # Same deck, different game. Fresh players get fresh hands.
227 bj = BlackjackGame(deck, [Player("Alice"), Player("Bob")])
228 bj_winner = bj.play()
229 bj_eval = BlackjackEvaluator()
230 for p in bj._players:
231 print(f"Blackjack {p.name}: {[str(c) for c in p.hand.cards]} -> {bj_eval.describe(p.hand)}")
232 print(f"Blackjack winner: {bj_winner.name}")Common Mistakes
- ✗Making Card mutable. Once a card is dealt its identity shouldn't change — equality and hashing break.
- ✗Hardcoding Poker scoring inside Hand. Reuse dies the moment Blackjack shows up.
- ✗Using Math.random() without seeding in tests. Nondeterministic shuffles make bugs impossible to reproduce.
- ✗Dealing from the end of a list with O(n) shifts. Deal from the top with an index pointer.
Key Points
- ✓Card is immutable. Two cards with the same suit and rank are equal — no hidden state.
- ✓Deck owns shuffling. Fisher-Yates in place keeps deal O(1) from the top.
- ✓HandEvaluator is a Strategy so Poker, Blackjack, and Rummy can share the same Deck and Hand.
- ✓Enums for Suit and Rank. String-typed cards cause silent bugs when a typo compiles fine.