The 100 problems, solved / Games / 070 · Design a deck of cards and Blackjack

Design a deck of cards and Blackjack

Cards, shuffling, hands, dealer rules and an extensible scoring interface.

Concepts this design leans on

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 with shuffle and draw.
  • 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.
RoundParticipantsCards2..*0..*0..*Rounddeck: Deckplayers: list[Player]dealer: Dealerplay() -> dictParticipanthand: Handtake_turn(deck)wants_card() -> boolPlayernamebetdecision: DecisionDealerup_card() -> CardDecisionhit(hand, dealer_up) -> boolHandcards: list[Card]total() -> intis_soft()is_bust()is_blackjack()Deckcards: list[Card]rngshuffle()draw() -> CardCardrank: Ranksuit: Suit

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

  1. Round.play shuffles the deck and deals two cards to each player and the dealer.
  2. Each player's take_turn runs: while live and wants_card, draw from the deck.
  3. If any player is still live, the dealer's take_turn runs under the fixed rule.
  4. _settle compares 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_probability is 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: Participant gets a list of hands, and the template loops over them. Doubling is one more decision value, so hit becomes an enum-returning choose.
  • "Reuse this for poker." Card, Deck and 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)