Design a deck of cards and Blackjack
Cards, shuffling, hands, dealer rules and an extensible scoring interface.
- Value objects and immutability — Objects defined by their value, never changed, safely shared.
- Enums versus class hierarchies — A fixed list of kinds, or a family of classes that behave differently.
- Template method — A fixed skeleton of steps with a few steps left to subclasses.
- Strategy — Swapping an algorithm at runtime by passing an object.
- Liskov substitution — A subclass must be usable wherever its parent is.
01The design, in the six-step framework
1. Requirements and use cases
One candidate reported a Google phone screen that asked for a 21-point game in object-oriented style, and a mock run by a Google engineer took the dealer's rules into counting and probability. So the design must model cleanly, and the rules must be simple enough to compute over.
- A deck of 52 cards (or a shoe of several decks) is shuffled and dealt from the top.
- Each player places a bet, and two cards go to each player and to the dealer, one dealer card face down.
- Each player in turn hits or stands; going over 21 is a bust and loses at once.
- The dealer then plays by fixed rules: draw on 16 or less, stand on 17 or more.
- The round settles: blackjack pays 3 to 2, a win pays even money, a push returns the bet.
Out of scope: splitting, doubling down, insurance, multiple rounds of table management. Constraints: scoring is reusable for other card games; player decisions are pluggable, so a human, a bot and a test script use one interface; shuffling must be uniform and seedable.
2. Core entities and relationships
Suit,Rank: enums. Thirteen ranks and four suits are a closed set.Card: an immutable rank and suit.Deck: an ordered list withshuffleanddraw.Hand: the cards held; it answers total, soft, bust and blackjack.Participant: an abstract player of one turn; the skeleton is fixed, and the decision is the varying step.Player,Dealer: the two concrete participants.Decision: the strategy a player uses to choose hit or stand.Round: deals, runs the turns, settles the bets.
A participant composes its hand. Cards are aggregated by both deck and hand, since a card moves between them and is immutable, so the same object can be shared.
3. Interfaces and key methods
import random
from abc import ABC, abstractmethod
from dataclasses import dataclass
from enum import Enum
from typing import Optional, Protocol
class Suit(Enum):
CLUBS = "c"
DIAMONDS = "d"
HEARTS = "h"
SPADES = "s"
class Rank(Enum):
TWO = 2; THREE = 3; FOUR = 4; FIVE = 5; SIX = 6; SEVEN = 7; EIGHT = 8
NINE = 9; TEN = 10; JACK = 11; QUEEN = 12; KING = 13; ACE = 14
@dataclass(frozen=True)
class Card:
rank: Rank
suit: Suit
class Decision(Protocol):
def hit(self, hand: "Hand", dealer_up: Card) -> bool:
"""True to take another card."""
class Participant(ABC):
def take_turn(self, deck: "Deck") -> None:
"""Template: keep drawing while the hand is live and the participant wants a card."""
@abstractmethod
def wants_card(self) -> bool:
"""The one step that differs between a player and the dealer."""
Invariants: a deck never deals the same card object twice; Hand.total is the best value not over 21 when one exists; a bust hand never draws again; the dealer's rule never depends on any player's bet.
4. Design patterns and the choices behind them
Enums for the closed set, frozen cards for identity
The force: suits and ranks are fixed for all time, so a class per rank would be noise. Enums fit a closed list with no differing behaviour. A card is a value object: two seven-of-hearts from two decks compare equal, it can sit in a set, and nothing can mutate it while a hand holds it. Blackjack's values are not stored on the card, because an ace or a king is worth something else in another game; scoring belongs to the hand.
The ace rule in one place
The subtle part: an ace counts 11 or 1. Count every ace as 1, then add 10 once if that does not bust. At most one ace can ever count 11, since two would make 22, so a single adjustment suffices. A hand is soft when that adjustment is in use. There is no need to enumerate combinations.
A template for a turn, a strategy for the choice
The force: the player's and dealer's turns have the same shape: while not bust and wanting a card, draw. Only the decision differs. take_turn is the template method and wants_card is the hook. Inside Player, the decision is delegated to a strategy, so a human prompt, a basic-strategy bot and a scripted test share a single interface. A template and a strategy together may seem like two patterns for one job, but they separate different things: the template separates dealer from player, and the strategy separates kinds of player.
Liskov substitution holds because Round calls take_turn on any participant and never asks which kind it has. Only settlement treats the dealer specially, and it reaches the dealer through its own field rather than an isinstance test.
5. Core flow and code
Round.playshuffles the deck and deals two cards to each player and the dealer.- Each player's
take_turnruns: while live andwants_card, draw from the deck. - If any player is still live, the dealer's
take_turnruns under the fixed rule. _settlecompares each hand with the dealer's and returns the money result per player.
class Hand:
def __init__(self) -> None:
self.cards: list = []
def add(self, card: Card) -> None:
self.cards.append(card)
@staticmethod
def _value(card: Card) -> int:
if card.rank is Rank.ACE:
return 1
return min(card.rank.value, 10)
def _hard(self) -> int:
return sum(self._value(c) for c in self.cards)
def is_soft(self) -> bool:
return any(c.rank is Rank.ACE for c in self.cards) and self._hard() + 10 <= 21
def total(self) -> int:
return self._hard() + (10 if self.is_soft() else 0)
def is_bust(self) -> bool:
return self._hard() > 21
def is_blackjack(self) -> bool:
return len(self.cards) == 2 and self.total() == 21
class Deck:
def __init__(self, rng: random.Random, decks: int = 1) -> None:
self.rng = rng
self.cards = [Card(r, s) for _ in range(decks) for s in Suit for r in Rank]
def shuffle(self) -> None:
for i in range(len(self.cards) - 1, 0, -1): # Fisher-Yates: uniform
j = self.rng.randint(0, i)
self.cards[i], self.cards[j] = self.cards[j], self.cards[i]
def draw(self) -> Card:
if not self.cards:
raise RuntimeError("the deck is empty")
return self.cards.pop()
class Participant(ABC):
def __init__(self) -> None:
self.hand = Hand()
def take_turn(self, deck: Deck) -> None:
while not self.hand.is_bust() and self.wants_card():
self.hand.add(deck.draw())
@abstractmethod
def wants_card(self) -> bool: ...
class Dealer(Participant):
def up_card(self) -> Card:
return self.hand.cards[0]
def wants_card(self) -> bool:
return self.hand.total() < 17
class Player(Participant):
def __init__(self, name: str, bet: int, decision: Decision, dealer: Dealer) -> None:
super().__init__()
self.name, self.bet, self.decision, self.dealer = name, bet, decision, dealer
def wants_card(self) -> bool:
return self.hand.total() < 21 and self.decision.hit(self.hand, self.dealer.up_card())
class HitBelow:
def __init__(self, threshold: int = 17) -> None:
self.threshold = threshold
def hit(self, hand: Hand, dealer_up: Card) -> bool:
return hand.total() < self.threshold
class Round:
def __init__(self, deck: Deck, dealer: Dealer, players: list) -> None:
self.deck, self.dealer, self.players = deck, dealer, players
def play(self) -> dict:
self.deck.shuffle()
for _ in range(2):
for p in self.players + [self.dealer]:
p.hand.add(self.deck.draw())
for p in self.players:
p.take_turn(self.deck)
if any(not p.hand.is_bust() for p in self.players):
self.dealer.take_turn(self.deck)
return {p.name: self._settle(p) for p in self.players}
def _settle(self, p: Player) -> int:
hand, dealer = p.hand, self.dealer.hand
if hand.is_bust():
return -p.bet
if hand.is_blackjack() and not dealer.is_blackjack():
return p.bet * 3 // 2
if dealer.is_blackjack() and not hand.is_blackjack():
return -p.bet
if dealer.is_bust() or hand.total() > dealer.total():
return p.bet
return 0 if hand.total() == dealer.total() else -p.bet
def dealer_bust_probability(hand_total: int = 0, soft: bool = False, memo: Optional[dict] = None) -> float:
"""Infinite deck: each draw is 2-9, ten (4 in 13), or ace. Memoised on (total, soft)."""
memo = {} if memo is None else memo
if hand_total > 21 and soft:
hand_total, soft = hand_total - 10, False
if hand_total > 21:
return 1.0
if hand_total >= 17:
return 0.0
key = (hand_total, soft)
if key not in memo:
p = sum(dealer_bust_probability(hand_total + v, soft, memo) for v in range(2, 10)) / 13
p += 4 * dealer_bust_probability(hand_total + 10, soft, memo) / 13
p += dealer_bust_probability(*((hand_total + 1, soft) if soft or hand_total + 11 > 21 else (hand_total + 11, True)), memo) / 13
memo[key] = p
return memo[key]
if __name__ == "__main__":
h = Hand()
for r in (Rank.ACE, Rank.SIX):
h.add(Card(r, Suit.SPADES))
assert h.total() == 17 and h.is_soft()
h.add(Card(Rank.KING, Suit.SPADES))
assert h.total() == 17 and not h.is_soft()
deck = Deck(random.Random(7))
dealer = Dealer()
result = Round(deck, dealer, [Player("ann", 100, HitBelow(), dealer)]).play()
assert result["ann"] in (-100, 0, 100, 150)
assert 0.2 < dealer_bust_probability() < 0.35
6. Extensions, concurrency and what interviewers probe
- "What is the chance the dealer busts?" With an infinite deck each draw is independent, so states are just (total, soft).
dealer_bust_probabilityis that recursion with a memo; state your assumption about the deck first, as the mock interviewer expected, and note the recursion is at most about ten draws deep. - "Real deck, not infinite." The state must include the remaining counts, and the memo key grows accordingly; say that the number of states rises sharply.
- "Add splitting." A player then holds several hands:
Participantgets a list of hands, and the template loops over them. Doubling is one more decision value, sohitbecomes an enum-returningchoose. - "Reuse this for poker."
Card,Deckand the enums carry over unchanged, which is why values live on the hand; see poker. - Test first: ace counted as 11 then falling to 1; a three-card 21 is not blackjack; a player bust loses even if the dealer also busts; a seeded shuffle is repeatable.
- Common mistake: storing an ace's value on the card, and writing shuffle as a swap with any random index over the whole deck each step, which is not uniform.
02Where this question was reported
- Educative · Grokking LLD chapter 12 'Online Blackjack Game' (breakout is ride sharing)
- DesignGurus · DesignGurus case study (Games and Rules)
- ByteByteGo · ByteByteGo OOD chapter 11
- dev.to · Educative top-10: cards, decks, shoe, hand, players, game sequence
- 1point3acres · Candidate report: Google SDE phone screen asked to build a 21-point (blackjack) game in OO style (page blocks bots; via search snippet)
- Codemia · Codemia Medium; also 'Design a Deck of Cards' (Easy)
- Codemia · Codemia Easy; 'Multi-player Card Game Framework' Hard
- GitHub · 'Design a deck of cards + a card game' (oop); no follow-ups logged
- interviewing.io · Mock with a Google engineer for an L4 onsite: infinite deck, count dealer bust ways; probed assumptions, recursion depth, memoisation, bust probability (coding-
- Glassdoor · Create a deck-of-cards class, write shuffle and poker-hand scoring (snippet only; URL mapping from search cluster)