InterviewPrepKit

Home / Learn / Object-Oriented Design

11 — Blackjack Game

Design a blackjack game.

Blackjack is the classic rules engine problem: a system whose difficulty is not its algorithms or its scale, but the number of interacting special cases in its domain.

This chapter covers four things:

Every example is runnable Python, and every number is derived rather than asserted.

The nouns are given: card, deck, hand, player, dealer. The design is decided by three things: whether an ace is valued correctly, whether the betting unit is the player or the hand, and whether the dealer’s policy is data or an if.

Chapter 10 is the case where reaching for machinery is the failure. This is the opposite: the rules genuinely branch, and a design that flattens them into one play_round function cannot absorb a single casino variation.

The rules, once, so nothing below needs looking up

This section defines every term used later, so no prior knowledge of blackjack is required.

The table

Blackjack is played against the house, not against the other players. Several players sit at one table and each plays their own hand against a single dealer, who represents the casino. Two players at the same table never compete with each other.

Before any cards are dealt, each player puts money on the table. That stake is the bet.

The deal

Each player receives two cards face up.

The dealer receives two cards as well: one face up, called the upcard, and one face down, called the hole card. The upcard is the only information you have about the dealer while you decide what to do, which is why every strategy table in blackjack is indexed by it.

What the cards are worth

A hand’s total is the sum of its cards. 9 + 7 is 16. A + 6 is 17, counting the ace as 11 — or 7, counting it as 1. Both readings are live at once, and Decision 1 soft versus hard and the ace algorithm is entirely about resolving that.

The goal is to finish with a total closer to 21 than the dealer’s, without going over 21.

Your turn

Each player acts in turn. On your turn you may hit — take another card — as many times as you like, or stand, meaning take no more and end your turn.

If your total goes above 21 you have bust, and the hand loses immediately. It loses even if the dealer goes on to bust too, and that asymmetry is the main reason the casino has an edge (Decision 2 the hand is the betting unit).

The dealer’s turn

When every player has finished, the dealer turns over the hole card and draws cards according to a fixed, published rule. The dealer never exercises judgement and never reacts to what the players hold. This is the most useful fact about the domain, and Decision 3 the dealer is a strategy and it is worth real money builds a class around it.

Who gets paid

The “3:2” notation means three units paid for every two staked. 100 x 3 / 2 = 150.

Four optional actions

These four are optional at most tables, and each one bends the model in a way worth noticing now.

Where the rest of the series fits

Three links, offered for depth and not needed to follow this chapter. The interview method — how to spend the 45 minutes — is 02 — a framework for the object-oriented design (OOD) interview. The pattern vocabulary is 03 — object-oriented programming (OOP) fundamentals, and the two patterns used here are defined where they arrive: Strategy (a policy moved into its own object so it can be swapped without editing the caller) and Observer (a publisher that emits events to an unknown set of subscribers). Money is handled as integer cents throughout, for the reasons worked out in Money is the first design decision.

What goes in, and what comes out

Fix the shape of the problem before drawing a single class. Underneath the table felt, the system is a function from a hand plus a rule set to an amount of money.

The block below is that function’s signature in English. The three IN lines are everything the system needs to know; the two OUT lines are everything it has to produce. The arrows show a concrete example of each: a player holding an ace and a king with 100 staked, against a dealer who finished on 20, ending with the player up 150.

IN   a hand        the cards a player holds, and what they staked on them
                   -> Hand(cards=[A, K], bet=100)
IN   a dealer      the cards the dealer finished with
     hand          -> Hand(cards=[10, K])
IN   a rule set    which house variation is in force
                   -> Dealer(policy=StandSoft17())

OUT  a valuation   the total of a hand, and whether it is soft, bust, or a
                   blackjack
                   -> value 21, soft, blackjack

OUT  a settlement  net cents to the player, positive for a win and negative
                   for a loss
                   -> +150

The whole design exists to make that last line correct in every case, and there are more cases than there appear to be. A player who busts loses even if the dealer busts afterwards. A 21 built from three cards is not a blackjack. A 21 built from a split ace is not a blackjack either. Each of those is one clause, and getting the clauses in the right order matters more than any class diagram.

1. Clarifying questions that change the design

Five questions change the code, not merely the conversation. Each row pairs one of them with the structural consequence of its answer.

QuestionEffect on the design
Does the dealer hit soft 17?A real table-by-table variation worth measurable house edge, where “house edge” means the casino’s expected profit as a percentage of the amount wagered. It must be a policy object rather than a constant (Decision 3 the dealer is a strategy and it is worth real money)
Are split, double, insurance and surrender offered?Splitting is the one that reshapes the model: one player can be playing four hands with four independent bets. The bet belongs to the hand
How many decks, and where is the cut card?One deck versus six changes the odds and the shuffle lifecycle. It also decides whether card counting is a threat you have to model
Several players at one table?Turn order and a shared shoe. It does not change the hand logic at all, which is a good thing to notice out loud
Does blackjack pay 3:2 or 6:5?Ideally a payout table rather than an if, and integer money either way, so bet * 3 // 2 with a stated rounding rule (Money is the first design decision)

The code below makes good on the last row only partly, and What is data and what is code is where that gap is named.

2. Core objects, and why those

Each object below is paired with the plausible alternative it was chosen over. The alternatives are all defensible, which is why the reasons matter more than the list.

One term needs defining first. A shoe is the box a dealer draws from, holding several shuffled decks stacked together; six is the usual number. A larger card pool is harder to track and slower to exhaust.

The third column answers “why not do the obvious thing?”, because the obvious thing is what most candidates do.

ObjectResponsibilityWhy not the obvious alternative
CardA rank and a suit, immutable and hashableIts value is not a property of the card: an ace is 1 or 11 depending on the rest of the hand. Putting value on Card is the first mistake
ShoeOne to eight shuffled decks, a cut card, a discard trayNot Deck. A six-deck shoe is not a list of six Deck objects; it is one card sequence with a reshuffle trigger
HandIts cards, its own bet, and its own resolutionNot a field on Player. After a split one player has several hands and the bets are independent
PlayerBankroll, seat, and the hands currently in playThe bankroll is the player’s total money; the wager is one hand’s
DealerA hand plus a DealerPolicyThe dealer never chooses. Its whole behaviour is one policy object
DealerPolicyA single method, should_hit(hand) -> boolStrategy, worked in Decision 3 the dealer is a strategy and it is worth real money
Wager / PayoutAn amount, its odds, and its resolutionExists so that insurance and side bets are the same kind of thing as the main bet

Two terms in that table are worth glossing. Immutable means the object cannot be changed after construction, and hashable means it can be used as a dictionary key or put in a set — the two go together, because a key that can change underneath the dictionary corrupts it. A card is the textbook case: the four of hearts is a value, not a thing with a history. And the cut card is a coloured plastic card inserted part-way into the shoe; when it is reached, the shoe is reshuffled at the end of the current round, which is the mechanism that limits how much of the shoe a card counter ever gets to see.

Hand owning the bet is the structural decision this question is really testing. If the bet lives on Player, splitting requires a parallel list of bets kept in lockstep with a list of hands, and every double-down has to find the right index into both. Interviewers ask about splitting because it breaks the naive model.

3. Class diagram

Those objects assemble into the diagram below. Two notes before reading it.

First, this is the whole model, including parts the chapter names but never codes. Card, Hand, DealerPolicy, Dealer, Shoe and the settlement function all appear as running Python later. Round, Player and Action do not; they are the plumbing, and the decisions live elsewhere.

Second, the lines between the boxes carry the design. Read the diagram once for shape, then read the two lists that follow it.

classDiagram
    class Card {
        +Rank rank
        +Suit suit
        +base() int
    }
    class Shoe {
        +int decks
        +float penetration
        +bool pending_shuffle
        +draw() Card
        +remaining_decks() float
    }
    class Hand {
        +int bet
        +bool doubled
        +bool from_split
        +hard() int
        +value() int
        +is_soft() bool
        +is_bust() bool
        +is_blackjack() bool
    }
    class Player {
        +int bankroll
        +List hands
        +decide(hand, upcard) Action
    }
    class Dealer {
        +Hand hand
        +play(hand, draw)
    }
    class DealerPolicy {
        <<interface>>
        +should_hit(hand) bool
    }
    class StandSoft17
    class HitSoft17
    class Round {
        +deal()
        +settle() int
    }
    class Action {
        <<enumeration>>
        HIT
        STAND
        DOUBLE
        SPLIT
        SURRENDER
    }

    Round "1" o-- "1" Shoe : shared across rounds
    Round "1" *-- "1" Dealer
    Round "1" o-- "1..*" Player : seated
    Player "1" *-- "1..4" Hand : owns, dies with the round
    Dealer "1" *-- "1" Hand
    Dealer "1" --> "1" DealerPolicy : delegates
    Hand "1" o-- "2..*" Card : drawn from the shoe
    DealerPolicy <|.. StandSoft17
    DealerPolicy <|.. HitSoft17
    Player ..> Action : returns

How to read the notation

This is a UML class diagram. UML is the Unified Modeling Language, the standard set of shapes for drawing software structure.

Inside a box: each box is a class, the lines inside it are its fields and methods, and a leading + means public — visible to code outside the class.

Two boxes carry a stereotype in double angle brackets. <<interface>> marks a class that is only a promise of methods, with no implementation of its own. <<enumeration>> marks a type whose values are a fixed, named, closed list, so HIT and STAND inside the Action box are values, not fields.

The quoted numbers at the ends of a line are multiplicities: how many objects sit at that end. 1 is exactly one, 1..* is one or more, 1..4 is between one and four, 2..* is two or more.

Five line styles appear, and they mean five different things.

Every line, read back as a sentence

One bullet per line in the diagram, in the order they are drawn.

The Round o-- Shoe versus Player *-- Hand contrast is the pair worth getting right, because reversing either says something false: a shoe that died with the round would make counting meaningless, and a hand that outlived the round would be money left on the table.

What this class structure assumes

A class diagram is a fixed bet about what will change. Every interface says “I expect this to vary”; every literal written into a method says “I expect this to hold forever”. Stating those bets is the transferable skill. The general form of the argument is What a class structure assumes; what follows is this design’s version.

Assumed to vary — and therefore given an interface, a parameter, or a table. The third column is the price of the alternative: what the code would look like if this had been hard-coded instead.

What variesHow the design absorbs itWhat it would cost to have got this wrong
The dealer’s drawing ruleDealerPolicy, an interface with one methodA boolean flag threaded through the draw loop, and a second flag for the next variation
How many decks, and how deep they are dealtdecks and penetration constructor arguments on ShoeA 52-card constant, and single-deck odds silently applied to a six-deck game
Where the shuffled order comes fromAn injected random number generatorNo deterministic test is possible, so no rule can be tested against a known deal
What a player decides to doPlayer.decide, returning an ActionThe round loop grows a branch per kind of player: human, basic strategy, counter
Side-bet payoutsA paytable, stored as data on the WagerEvery new side bet is a new method on the settlement path
How many hands a player holdsA list on Player, with the bet on each HandSplit becomes parallel lists indexed in lockstep

Assumed fixed — and therefore written into the structure rather than into a parameter:

The largest one contradicts a clarifying question, and it is better to say so than to let an interviewer find it. The 3:2 blackjack payout is written directly into the settlement code as bet * 3 // 2, even though Clarifying questions that change the design argues it should be a payout table. That is a deliberate trade: a table earns its keep once there are several payouts to vary together, and until then one arithmetic expression is easier to read and harder to mis-key. If a 6:5 table ever ships, the fix is one payout table consulted once, not a second branch.

Underneath that sit six more fixed bets. Each is a place where the code would have to be edited, not configured.

What a different assumption would have produced.

What is data and what is code

Blackjack is a rules engine, so the question worth answering explicitly is which rules are stored as values you can change without recompiling, and which are written as control flow. A rule expressed as data can be edited, versioned and shipped by someone who cannot read the code; a rule expressed as code can be read as a sentence and checked by a type checker. You pay for whichever one you did not pick.

Every rule in this chapter, sorted by which side of that line it falls on. The data rules come first, the code rules after; the last row is the decisive one.

RuleWhere it livesConsequence
Which ranks and suits existData: the RANKS and SUITS listsA Spanish deck with no tens is a one-line change
Deck count, penetration, shuffle orderData: constructor arguments on ShoeSingle-deck and six-deck games are the same class
Whether the dealer draws on soft 17Data-shaped code: which DealerPolicy object is installedThe variation worth 0.22 percentage points is a constructor argument
Side-bet payoutsData: a tuple of outcome-and-multiplier pairsA new side bet is a new constant, not a new code path
The bet, and whether a hand was doubled or splitData: fields on HandSettlement reads state instead of remembering history
Hi-Lo counting tagsData: a mapping from rank to +1, 0 or -1A different counting system is a different mapping
An ace is 1 or 11Code: the hard + 10 calculationCorrect, fast, and unchangeable without an edit
21 is the target and the bust thresholdCode: a literal in four propertiesA different target is four edits
What counts as a blackjackCode: two cards, 21, and not from a splitCasino-specific exceptions have no seam
The order settlement clauses are tested inCode: the sequence of if statements in settleThis is the one that must not become data

The dealer’s rule became an object almost for free; the settlement order could not, and the difference between those two cases is the real lesson. should_hit is a pure predicate: a function of one hand, returning a yes or a no, with no dependence on anything else in the system and no interaction with any other rule. Predicates like that generalize to data cheaply, because a table of them composes in any order. Settlement is the opposite. Its correctness is the ordering — the player-bust clause must be tested before the dealer-bust clause, or the house edge disappears — and orderings expressed as table rows are exactly the thing that goes wrong, as Decision 1 promotions are strategies and they do not commute works through at length in a different domain.

So the rule of thumb, worth saying in the interview: move predicates into data, leave precedence in code. A fully table-driven blackjack engine stops producing stack traces you can read. A bug becomes “row 14 of the settlement table fired before row 9”, the type checker can check nothing, and each rule is no longer a sentence anybody can review. The opposite mistake, leaving everything in code, means every casino variation is a code deployment. The design here sits deliberately near the code end, and the one thing that had to move to the data end moved: the dealer’s policy, because that is the variation with money attached. The mirror-image judgement for a problem where almost nothing should move is What is data and what is code.

4. Decision 1 — soft versus hard, and the ace algorithm

The whole ace rule follows from one line of arithmetic, plus two subtleties that break real implementations. A table of hands proves all of it.

An ace is worth 1 or 11, whichever helps. The naive implementation tries every combination: with a aces in the hand that is 2^a different sums to evaluate, 2 sums for one ace, 8 for three, 2048 for eleven. None of that is needed, and the reason is one line of arithmetic:

two aces valued 11 each   11 + 11 = 22

22 is already a bust, so at most one ace in any hand can ever count as 11. No matter how many aces you hold, all but one of them are worth 1.

That collapses the whole problem into arithmetic with no loops and no 2^a. Here it is as pseudocode; the next block is the same thing as running Python.

flowchart TD
    A["Sum every card, ace = 1: hard"] --> B{"Hand has an ace and hard + 10 fits under 21?"}
    B -- yes --> C["value = hard + 10, hand is SOFT"]
    B -- no --> D["value = hard, hand is HARD"]
    C --> E{"hard over 21?"}
    D --> E
    E -- yes --> F["bust"]
    E -- no --> G["in play"]
hard  = sum of card values with every ace counted as 1
if the hand has an ace and hard + 10 <= 21:
        value = hard + 10     and the hand is SOFT
else:
        value = hard          and the hand is HARD
bust  = hard > 21             (never `value > 21` -- value is already capped)

The + 10 is the promotion of one ace from 1 to 11. It is a gain of 10 rather than of 11 because that ace was already counted once, as a 1, inside hard.

Work one hand through it. A 6: hard is 1 + 6 = 7. There is an ace and 7 + 10 = 17 fits under 21, so value is 17 and the hand is soft. Now add a 10. hard is 17, 17 + 10 = 27 does not fit, so value stays 17 and the hand is hard. The ace demoted itself with no code doing anything special.

That is what soft means: the promotion applied. The name matters because a soft hand cannot bust on the next card. If the extra card would take it over 21, the promoted ace silently drops back to 1, exactly as it just did. A player holding soft 17 has a free hit; a player holding hard 17 does not. That single property is what basic strategy and the dealer’s own policy both branch on.

Two subtleties trip implementations, and both are worth volunteering.

The test set that proves it

Two blocks. This first one is the model — Card and Hand, with the pseudocode above written out as the hard, value, is_soft and is_bust properties. The second block is the table of hands that proves those properties right.

Notice that Card has no value. It has base, which counts an ace as 1, and the decision to promote one ace to 11 lives on Hand where the rest of the cards are visible. That split is the whole point of Core objects and why those’s first row.

from dataclasses import dataclass, field
from enum import Enum
from typing import List

class Suit(Enum):
    SPADES = "S"
    HEARTS = "H"
    DIAMONDS = "D"
    CLUBS = "C"

@dataclass(frozen=True)
class Card:
    rank: str
    suit: Suit = Suit.SPADES

    @property
    def base(self) -> int:
        """Value with an ace counted as 1. The 11 is a HAND-level decision."""
        if self.rank == "A":
            return 1
        return 10 if self.rank in ("10", "J", "Q", "K") else int(self.rank)

@dataclass
class Hand:
    cards: List[Card] = field(default_factory=list)
    bet: int = 0                 # integer cents; the bet belongs to the HAND
    doubled: bool = False
    from_split: bool = False

    @property
    def hard(self) -> int:
        return sum(c.base for c in self.cards)

    @property
    def aces(self) -> int:
        return sum(1 for c in self.cards if c.rank == "A")

    @property
    def value(self) -> int:
        """At most one ace can be 11, because 11 + 11 = 22 is already a bust."""
        h = self.hard
        return h + 10 if self.aces and h + 10 <= 21 else h

    @property
    def is_soft(self) -> bool:
        return bool(self.aces) and self.hard + 10 <= 21

    @property
    def is_bust(self) -> bool:
        return self.hard > 21           # NOT value > 21: value is already capped

    @property
    def is_blackjack(self) -> bool:
        return len(self.cards) == 2 and self.value == 21 and not self.from_split

    @property
    def wager(self) -> int:
        return self.bet * 2 if self.doubled else self.bet

    def can_split(self) -> bool:
        return (len(self.cards) == 2
                and self.cards[0].base == self.cards[1].base)

The Python idioms in that block

Skip this if the syntax is already familiar; nothing in it is blackjack.

@dataclass is a decorator that writes a class’s constructor, its equality test and its printable form from the annotated fields alone. frozen=True on Card makes instances immutable and hashable, which is exactly the “a card is a value” claim from Core objects and why those enforced by the language. Hand is not frozen, because a hand grows. field(default_factory=list) gives each new hand its own empty list: writing cards: List[Card] = [] instead would share one list between every hand ever created, which is a classic Python bug, and dataclasses refuse it outright. @property turns a method into an attribute read, so hand.value computes on access rather than being a stored field that can go stale — which is the right choice here, since the value of a hand is always a function of its cards. sum(c.base for c in self.cards) is a generator expression, summing a computed value over the cards without building an intermediate list; sum(1 for c in ... if ...) is the standard way to count matches. Suit.SPADES as a default means every card in the tests below is a spade, which is harmless because nothing here depends on suit.

The hands

Now the second block. CASES is a table written as Python: each row is a set of ranks, then the three answers that hand must produce — its hard total, its value, and whether it is soft. The loop underneath checks all three on every row, plus the bust test.

The rows are chosen to cover the ace rule’s corners: one ace, two aces, three aces, a hand where the ace is forced back down to 1, and one absurd hand with eleven aces. The asserts after the loop are a separate point: 21 and blackjack are not the same thing.

def h(*ranks: str, **kw) -> Hand:
    return Hand([Card(r) for r in ranks], **kw)

#  ranks                      hard  value  soft?
CASES = [
    (("A", "6"),                  7,   17,  True),   # the canonical soft 17
    (("A", "6", "10"),           17,   17,  False),  # the ace demoted to 1
    (("A", "A"),                  2,   12,  True),   # only one ace can be 11
    (("A", "A", "9"),            11,   21,  True),
    (("A", "A", "A", "8"),       11,   21,  True),   # three aces, still one 11
    (("A", "A", "9", "K"),       21,   21,  False),  # forced hard 21
    (("A", "5", "5"),            11,   21,  True),
    (("A", "K"),                 11,   21,  True),   # blackjack, and soft
    (("A", "2", "3", "4", "5"),  15,   15,  False),
    (("9", "9", "4"),            22,   22,  False),  # bust
    (tuple("A" * 11),            11,   21,  True),   # eleven aces is exactly 21
]

for ranks, hard, value, soft in CASES:
    hd = h(*ranks)
    assert hd.hard == hard, (ranks, hd.hard)
    assert hd.value == value, (ranks, hd.value)
    assert hd.is_soft is soft, (ranks, hd.is_soft)
    assert hd.is_bust is (hard > 21), ranks

# 21 is not the same thing as blackjack.
assert h("A", "K").is_blackjack
assert not h("A", "5", "5").is_blackjack            # three cards
assert not h("A", "A", "9").is_blackjack            # three cards
assert not h("A", "K", from_split=True).is_blackjack  # split ace + ten
assert not h("8", "9").can_split()

The helper h uses two Python conventions worth naming. *ranks collects every positional argument into a tuple, so h("A", "K") passes ("A", "K"); **kw collects every keyword argument into a dictionary and forwards it unchanged, which is how h("A", "K", from_split=True) reaches the Hand constructor. And tuple("A" * 11) builds eleven separate single-character strings, because multiplying a string repeats it and tuple splits the result into characters — a compact way to write out an eleven-ace hand. An assert raises immediately if its condition is false, so a block of them that runs to completion is a passing test; the value after the comma is the message printed if it fails.

Two of those rows earn their place. A A A 8 has three aces, and exactly one of them is promoted, so the hand is hard 11 and value 21. The eleven-ace hand is hard 11 and value 21 as well, the extreme statement of the same rule. Naming a hand like A A A 8 before the interviewer does shows you have tested the corner cases.

5. Decision 2 — the hand is the betting unit

Split, double, insurance and surrender all fall out of a single modelling choice, the same choice that makes the settlement function below possible.

The choice is one sentence: the bet is a field on Hand, not on Player. Here is what each of the four optional actions costs once you have made it.

ActionWhat it does to the model
SplitOne Hand becomes two, each keeping the original bet, each drawing a second card, each played and settled independently. from_split=True on both, so neither can be a blackjack
Doubledoubled=True, exactly one more card, then a forced stand. The wager doubles; the bet field does not, so the original stake is still recoverable for reporting
InsuranceA separate wager of half the main bet, paying 2:1, resolved before the main hand plays. It is not an attribute of the hand — it is a second bet on a different proposition
SurrenderReturn half the wager and end the hand. One early-exit branch

Notice that bet and wager are deliberately two different things. bet is what was originally staked and never changes; wager is what is actually at risk right now, which is double after a double-down. Keeping both means a report can still say “this player bets 100 a hand” after a session full of doubles.

Insurance is the one that exposes a weak model. If a Hand carries an insurance field, then a player with four split hands has four insurance bets, which is not the rule — insurance is offered once, on the dealer’s ace, against the original wager. Modelling all wagers uniformly (main, insurance, side bet) and attaching them to the seat at the table rather than to a hand makes that fall out correctly, and makes the side bets of Now add side bets nearly free.

Settlement, clause by clause

Settlement is six clauses, and the order they are tested in is the design. Read them top to bottom; each one returns, so the first that matches wins.

  1. Player bust — lose, before the dealer has drawn anything.
  2. Both have blackjack — push.
  3. Player has blackjack — pay 3:2.
  4. Dealer has blackjack — lose.
  5. Dealer bust — win.
  6. Otherwise compare totals: higher wins, lower loses, equal pushes.

The asserts underneath the function are nine worked hands, one per rule that people get wrong.

def settle(player: Hand, dealer: Hand) -> int:
    """Net cents to the player. Positive is a win, negative is a loss."""
    w = player.wager
    if player.is_bust:
        return -w                        # settled before the dealer even draws
    if player.is_blackjack and dealer.is_blackjack:
        return 0
    if player.is_blackjack:
        return player.bet * 3 // 2       # 3:2, floored. State the rounding rule.
    if dealer.is_blackjack:
        return -w
    if dealer.is_bust:
        return w
    return w if player.value > dealer.value else (
        -w if player.value < dealer.value else 0)

assert settle(h("A", "K", bet=100), h("10", "K")) == 150         # 3:2
assert settle(h("A", "K", bet=100), h("A", "Q")) == 0            # both blackjack
assert settle(h("10", "K", bet=100), h("A", "Q")) == -100
assert settle(h("10", "9", "5", bet=100), h("10", "8", "6")) == -100   # both bust
assert settle(h("10", "K", bet=100), h("10", "8", "6")) == 100
assert settle(h("10", "K", bet=100), h("10", "K")) == 0          # push
assert settle(h("A", "K", bet=100, from_split=True), h("10", "K")) == 100
assert settle(h("10", "K", bet=100, doubled=True), h("10", "9")) == 200
assert settle(h("10", "9", bet=100, doubled=True), h("10", "K")) == -200

bet * 3 // 2 uses //, floor division, which discards the remainder rather than producing a fraction. On a 100-cent bet that is exactly 150; on an odd bet it rounds against the player, and the point is not which way it rounds but that the rule is stated at all — an unstated rounding rule is how a cent per hand quietly becomes a lawsuit. The final return is a nested conditional expression, which reads as: the wager if the player’s total is higher, the negative wager if it is lower, and zero if they are equal, that last case being the push.

The fourth assertion is the rule people get wrong: the player busted with 24, so the hand was already lost, and the dealer’s later bust with 24 does not save it. That asymmetry, the player acting first and being settled first, is the reason the house has an edge.

The seventh assertion is the split payoff. The player holds an ace and a king for 21, which would normally be a blackjack paying 150, but from_split=True blocks it, so the hand beats the dealer’s 20 and pays 100.

6. Decision 3 — the dealer is a Strategy, and it is worth real money

This is the one place in the design where an interface is unambiguously correct, and the variation behind it can be priced, so the choice is justified in money rather than in taste.

The interface, and the two policies behind it

The dealer makes no decisions. should_hit is the entire behaviour, and there are two real-world versions, known at the tables as S17 and H17 — stand on soft 17, and hit soft 17.

The difference is one hand: A 6, which is soft 17. An S17 dealer stands on it. An H17 dealer takes one more card. Every other total is played identically by both. That is the entire variation, and the second half of this section shows it is worth real money anyway.

from abc import ABC, abstractmethod

class DealerPolicy(ABC):
    @abstractmethod
    def should_hit(self, hand) -> bool:
        ...

class StandSoft17(DealerPolicy):
    """S17. Better for the player."""

    def should_hit(self, hand) -> bool:
        return hand.value < 17

class HitSoft17(DealerPolicy):
    """H17. The dealer takes one more card on A-6."""

    def should_hit(self, hand) -> bool:
        return hand.value < 17 or (hand.value == 17 and hand.is_soft)

class Dealer:
    def __init__(self, policy: DealerPolicy):
        self.policy = policy

    def play(self, hand, draw) -> None:
        while self.policy.should_hit(hand) and not hand.is_bust:
            hand.cards.append(draw())

ABC stands for abstract base class, Python’s way of declaring a class that exists only to be subclassed; @abstractmethod marks a method that a subclass must supply, and attempting to instantiate a class that has not supplied it raises a TypeError rather than failing later in mysterious ways. The ... in the method body is the ellipsis literal, used here as a placeholder statement meaning “no implementation”. Dealer.play takes draw as an argument — a function that returns the next card — rather than reaching for a shoe itself, which is dependency injection: the dealer is handed what it needs instead of finding it, so a test can pass a function returning a scripted sequence of cards.

Run both dealers on the same cards

Injecting draw means you can hand both dealers the identical deal and watch the rule diverge. Below, both start on A 6 — soft 17 — and both would receive a 5 and then a 6 if they asked for cards.

Trace the H17 dealer by hand before reading the asserts. A 6 is soft 17, so it hits. The 5 makes A 6 5, hard 12: the ace can no longer be 11, so the total is 12, which is under 17, so it hits again. The 6 makes hard 18, and it stands. The S17 dealer never draws at all.

def scripted(cards):
    """A draw() that deals a fixed sequence, so a test is reproducible."""
    it = iter(cards)
    return lambda: next(it)

s17_hand = h("A", "6")
h17_hand = h("A", "6")
assert s17_hand.value == 17 and s17_hand.is_soft      # both start on soft 17

assert StandSoft17().should_hit(s17_hand) is False
assert HitSoft17().should_hit(h17_hand) is True

Dealer(StandSoft17()).play(s17_hand, scripted([Card("5"), Card("6")]))
Dealer(HitSoft17()).play(h17_hand, scripted([Card("5"), Card("6")]))

assert len(s17_hand.cards) == 2 and s17_hand.value == 17   # stood at once
assert len(h17_hand.cards) == 4 and h17_hand.value == 18   # drew the 5, then the 6

# Same player, same cards, different table rule: a win becomes a push.
assert settle(h("10", "8", bet=100), s17_hand) == 100
assert settle(h("10", "8", bet=100), h17_hand) == 0

Those last two lines are the whole argument in miniature. A player standing on 18 beats the S17 dealer’s 17 and pushes against the H17 dealer’s 18. One line of policy, one hundred cents.

What the variation is worth

Now the money. The block below is the exact consequence of the rule, computed by recursion over an infinite deck — ranks 2 through 9 and the ace at 1/13 each, ten-valued cards at 4/13 because four of the thirteen ranks are worth ten — for the dealer showing an ace.

Each row is one way the dealer’s hand can finish. The first two columns are the probability of that ending under each rule; the third is H17 minus S17. Look at the final 17 row first, since it is the one the rule change is aimed at, then at where those 7.33 points went.

                S17       H17     change
bust          0.1153    0.1389   +0.0236
final 17      0.1308    0.0575   -0.0733
final 18      0.1308    0.1432   +0.0124
final 19      0.1308    0.1432   +0.0124
final 20      0.1308    0.1432   +0.0124
final 21      0.3616    0.3740   +0.0124

The S17 and H17 columns are each a probability distribution over how the dealer’s hand finishes, so each sums to 1 up to the rounding shown. An “infinite deck” means every draw is treated as independent with fixed rank probabilities, which is the standard simplification: it ignores the small effect of cards already dealt and gets the answer right to within a few hundredths of a percentage point.

H17 busts 2.36 points more often, which sounds good for the player. It is not. The 7.33 points of dealer-17 that disappear do not all turn into busts — only 2.36 do. The other 4.97 turn into 18, 19, 20 and 21, which are hands that beat or tie the player.

Work it through for a player standing on 18 against a dealer ace. A dealer bust or a dealer 17 is a win for that player; a dealer 19, 20 or 21 is a loss; a dealer 18 is a push, which is why it appears in neither column below.

S17   win  0.1153 + 0.1308     = 0.2461
      lose 0.1308 + 0.1308 + 0.3616 = 0.6232
      EV   0.2461 - 0.6232     = -0.3771

H17   win  0.1389 + 0.0575     = 0.1964
      lose 0.1432 + 0.1432 + 0.3740 = 0.6604
      EV   0.1964 - 0.6604     = -0.4640

EV is expected value: the average outcome per unit staked, so -0.3771 means losing about 37.71 percent of the bet on average in that spot. The remaining probability in each case is the dealer also finishing on 18, which is a push and contributes nothing. The difference between the two rule sets is:

-0.4640 - -0.3771    = -0.0869

That is 8.69 percent of the bet, on that one cell of the strategy table. A percentage point is one hundredth, so 8.69 points of a 100-unit bet is 8.69 units. Weighted by how often an ace is the upcard, which is one rank in thirteen, that single cell alone is worth:

0.0869 / 13    = 0.0067

That is 0.67 percentage points if every player hand behaved like a stand-on-18. They do not, since most hands face a different upcard and play differently, and the published aggregate effect of H17 across all upcards under optimal play is about 0.22 percentage points of house edge. Either way the conclusion holds: this is a real rule variation with real money attached, so it must be swappable at construction rather than a constant inside a method.

What Strategy costs here: essentially nothing, which is why it is the right call. The interface has one method, there is no shared state, and the two implementations are three lines each. The player is different. Player.decide looks like the same shape but is not: basic strategy, the published lookup table of the mathematically best action for every combination of player hand and dealer upcard, is three tables; card counting adds a running count carried between hands; a human player is input and output. That one does deserve a Strategy of its own, and it is the second one to name.

7. Extension scenarios

Three follow-ups, each stated the way an interviewer states it. For each one the useful answer has the same parts: what changes, what does not, why the design absorbed it, and what it still costs.

“Now support multiple decks and a cut card”

What changes. Shoe gains a decks count, a penetration fraction, and a flag saying a shuffle is due. Nothing else moves.

What does not change. Hand, settle, DealerPolicy and Round are untouched. Cards come from shoe.draw() and always did.

Penetration is the fraction of the shoe that gets dealt before the cut card is reached — 0.75 means three quarters of the cards are used and the last quarter is never seen. It is the single most important number to a card counter, because a count is only worth acting on once a meaningful share of the shoe is known.

In the code below, watch two things: draw sets a flag rather than reshuffling, and every number in the asserts is derived from decks and penetration rather than hard-coded. Six decks is 312 cards; a penetration of 0.75 leaves a quarter of them — 78 — behind the cut card, so the flag trips after 234 draws.

One simplification to be aware of: this standalone Shoe deals (rank, suit) tuples rather than Card objects, so it can be read without the §4 block in scope. A full implementation returns Card, as the class diagram says.

import random
from typing import List, Optional

RANKS = ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"]
SUITS = ["S", "H", "D", "C"]

class Shoe:
    """N decks with a cut card at `penetration`. Reshuffle between rounds only."""

    def __init__(self, decks: int = 6, penetration: float = 0.75,
                 rng: Optional[random.Random] = None):
        self.decks, self.penetration = decks, penetration
        self.rng = rng or random.Random()
        self.shuffle()

    def shuffle(self) -> None:
        self.cards: List[tuple] = [(r, s) for _ in range(self.decks)
                                   for r in RANKS for s in SUITS]
        self.rng.shuffle(self.cards)
        self.cut_at = int(len(self.cards) * (1 - self.penetration))
        self.pending_shuffle = False

    def draw(self) -> tuple:
        if not self.cards:
            raise RuntimeError("dealt past the shoe; cut card was ignored")
        card = self.cards.pop()
        if len(self.cards) <= self.cut_at:
            self.pending_shuffle = True    # honoured at END of round, not now
        return card

    def remaining_decks(self) -> float:
        return len(self.cards) / 52.0

shoe = Shoe(decks=6, penetration=0.75, rng=random.Random(7))
assert len(shoe.cards) == 312                 # 6 * 52
assert shoe.cut_at == 78                      # 312 * 0.25
for _ in range(234):                          # 312 - 78
    shoe.draw()
assert shoe.pending_shuffle and len(shoe.cards) == 78
assert shoe.remaining_decks() == 1.5           # 78 / 52, and the count divisor

rng is a random number generator passed in from outside, with rng or random.Random() supplying a fresh one only when the caller did not. Constructing it with random.Random(7) seeds it, meaning the same sequence of “random” cards comes out every run — which is what makes the assertions above reproducible and what makes any rules test possible at all. Optional[random.Random] is a type hint meaning “a generator or None”. The nested comprehension builds every combination of deck copy, rank and suit, giving 6 x 13 x 4 = 312 cards, and self.cards.pop() takes the last one, treating the list as a stack. int(...) truncates toward zero, so cut_at is 78 exactly.

The design point is pending_shuffle, not the shuffle. Reshuffling the instant the cut card appears would change the composition of the shoe in the middle of a round, which is both a rules violation and a race against any player who is mid-decision. The flag defers the reshuffle to the round boundary. A candidate who models the cut card as “shuffle when we run low” has not read the rule.

What deck count is worth

The odds shift from deck count is small and real. The probability that the opening two cards are a blackjack, counting both orders of ace-then-ten and ten-then-ace, is:

1 deck    2 * 4 * 16 / (52 * 51)      = 0.0483
6 decks   2 * 24 * 96 / (312 * 311)   = 0.0475

The numerators read as: 2 for the two orders, then the number of aces, then the number of ten-valued cards — 4 aces and 16 tens in one deck, 24 and 96 in six. The denominators are the ways to draw two cards in order.

0.0483 - 0.0475    = 0.0008

A blackjack pays 0.5 extra units over a normal win, so 0.0008 x 0.5 is about 0.04 percentage points of house edge from deck count alone, before any counting effects. That is small next to H17’s 0.22, and knowing which is which is the point.

What it costs. The shoe now has a state that must be honoured by whoever drives the round, and nothing in the type system enforces it: if the round loop forgets to check pending_shuffle, the shoe deals past the cut card until it raises. That is why draw raises a RuntimeError with an explicit message rather than returning None.

“Now add card-counting detection”

Card counting is the practice of tracking which cards have already been dealt in order to bet more when the remaining shoe is rich in tens and aces, which is when the player’s edge is highest. It is not cheating and it is not illegal; casinos simply want to know who is doing it.

What changes. An observer on the shoe maintains a running count, plus a per-seat record pairing the count at the moment of each bet with the size of that bet. Detection is a correlation, not a threshold.

What does not change. Nothing in the game. The detector reads the same draw() events the game already produces, which is the one place Observer genuinely earns its keep in this chapter — the set of listeners (pit surveillance, analytics, the shoe’s own tracker) is not known when Shoe is written, which is exactly the condition chapter 10 says the pattern requires and does not find.

Hi-Lo is the standard counting system: every card dealt adds a tag to a running total, +1 for the low cards, 0 for the middle, -1 for the high ones.

The block below is the tag assignment. Each line is a rank group, the number of cards that group has in one 52-card deck, and the tag each of those cards carries — and the +1 group and the -1 group are exactly the same size:

2-6      5 ranks x 4 suits = 20 cards at +1
7-9      3 ranks x 4 suits = 12 cards at  0
10-A     5 ranks x 4 suits = 20 cards at -1
20 + 12 + 20            = 52

The last line is the check that the three groups account for a whole deck. Twenty cards tagged +1 and twenty tagged -1 per deck means a fully dealt shoe returns the running count to zero — so a count that is high partway through is genuine information about what is left, not an artefact of the tagging.

The running count is the sum of those tags over every card dealt so far. It starts at 0 after a shuffle and moves up when small cards come out, because small cards leaving means the shoe that remains is rich in tens and aces — good for the player.

A running count of +8 means something very different with six decks left than with one, so the true count normalizes it by the decks still in the shoe:

true = running / remaining_decks

That divisor is exactly what Shoe.remaining_decks() returns. Using the shoe from the block above, which stopped at the cut card with 78 cards left:

running count           +8
remaining_decks()       78 / 52       = 1.5
true count              8 / 1.5       = 5.33

A true count of 5.33 is a strong player advantage; the same +8 running count with six decks still to come would be a true count of 1.33 and worth almost nothing. This division is the whole reason remaining_decks exists on Shoe.

A counter’s tell is not the count itself — it is the bet spread correlated with it. A basic-strategy player bets the same amount every hand; a counter bets one unit when the true count is 1 or below and eight to twelve units when it is 4 or above. The detection signal is therefore the correlation between the true count at the moment of the bet and the size of that bet, measured over a few hundred hands. Correlation here means the ordinary statistical measure of whether two quantities rise and fall together, running from -1 to +1, with 0 meaning no relationship. The honest caveat is that random variation over 100 hands makes the measurement unusable — this is a long-session statistic, and treating it as a per-shoe alarm produces false accusations.

“Now add side bets”

A side bet is an optional extra wager on a proposition other than beating the dealer — for example that your first two cards are a pair.

What changes. A Wager with its own paytable, resolved against the first two cards plus the dealer’s upcard, before the main hand plays.

What does not change. settle, DealerPolicy and Shoe are untouched. Side bets resolve on the deal and never interact with hitting or standing.

Why it is cheap: Decision 2 the hand is the betting unit already made insurance a separate wager rather than a field on Hand. A side bet is the same shape with a different paytable, so it is one dataclass and one resolver.

The example below is a pair side bet. Its three outcomes are ranked by how unlikely they are: a suited pair is two cards of the same rank and the same suit, a coloured pair is the same rank and the same colour but different suits, and a mixed pair is the same rank in different colours. Rarer pays more.

Read the payout method for its fall-through. Any outcome not named in the paytable loses the stake.

from dataclasses import dataclass
from typing import Tuple

@dataclass
class Wager:
    amount: int                      # integer cents
    paytable: Tuple[Tuple[str, int], ...]   # (outcome, numerator) at :1 odds

    def payout(self, outcome: str) -> int:
        for name, num in self.paytable:
            if name == outcome:
                return self.amount * num
        return -self.amount

PAIR_PLUS = (("suited pair", 25), ("coloured pair", 12), ("mixed pair", 6))
w = Wager(500, PAIR_PLUS)
assert w.payout("suited pair") == 12500
assert w.payout("mixed pair") == 3000
assert w.payout("no pair") == -500

The paytable is a tuple of pairs, each naming an outcome and the multiplier it pays at odds of that-many-to-one: a suited pair returns 25 times the 500-cent stake, which is 12,500 cents. Any outcome not listed falls through the loop and loses the stake, which is why payout("no pair") is -500the default is a loss, and encoding it as the fall-through rather than as a table row is what stops a missing row from silently paying out. Tuple[Tuple[str, int], ...] is the type hint for “a tuple of any length whose elements are all string-and-integer pairs”; the ... here means “repeated”, not “unimplemented”.

What it costs. Side bets have far worse odds than the main game and are resolved against a different set of information, so they must not share the main bet’s settlement path or the accounting silently mixes two different edges into one number. Keep them in a separate list on the seat with their own ledger lines.

8. What interviewers probe

Everything above, in the form it actually arrives: a question, and the answer that ends the follow-up.

ProbeAnswer that lands
“How do you value an ace?”Count all aces as 1, then add 10 once if it still fits under 21. At most one ace can be 11 because 11 + 11 = 22. The hand is soft if that +10 applied
“Show me A A A 8Hard 11, value 21, soft. Three aces, still exactly one counted as 11
“Is A K after a split a blackjack?”No. Two cards and 21, but from_split blocks it, so it pays even money and not 3:2
“Player busts and dealer busts — who wins?”The dealer. The player acts first and is already settled. That asymmetry is the house edge
“Where does the bet live?”On the Hand. Split creates two hands with two independent bets; putting the bet on Player forces parallel lists that drift
“Insurance?”A separate wager at 2:1 on half the main bet, offered once per seat on a dealer ace, resolved before the hand plays. Not a field on Hand
“H17 versus S17 — does it matter?”Yes. The dealer-ace distribution moves 7.33 points out of “final 17”, and a player standing on 18 loses 8.69 more points of the bet. The published aggregate is about 0.22 points of house edge
“When do you reshuffle?”At the round boundary after the cut card has been passed, never mid-round. pending_shuffle is a flag, not an action
“Should the Shoe be a Singleton?”No. A casino has many tables and your test suite needs a seeded deterministic shoe. Inject a Shoe built with an injected random.Random(seed) (chapter 03)
“Which rules are data and which are code?”Deck count, penetration, paytables and the dealer’s policy are data; the ace algorithm, the 21 threshold and the settlement precedence are code. Predicates move to data cheaply, precedence does not
“How would you test this?”A seeded generator plus hand-constructed Hand objects, exactly like Decision 1 soft versus hard and the ace algorithm. The rules engine must be testable without dealing a single card

9. Cheat sheet

Everything above, reduced to what fits on one card the morning of the interview.

Acehard counts every ace as 1. value is hard + 10 when an ace exists and it fits. Soft exactly when that +10 applied. Bust on hard > 21
Why only one 1111 + 11 = 22 is already a bust, so no 2^a enumeration is ever needed
BlackjackExactly 2 cards, totalling 21, and not from_split. Pays 3:2 as bet * 3 // 2 in integer cents
Bet lives onHand, not Player. Split gives independent hands, independent bets, independent settlement
InsuranceA separate wager at 2:1 on half the main bet, on a dealer ace, resolved first
DealerZero decisions. One DealerPolicy.should_hit. S17 versus H17 is a real variation worth about 0.22 points
Player bustLoses immediately, even if the dealer busts later. This is the house edge
ShoeN decks, cut card at a penetration fraction, pending_shuffle honoured between rounds
CountingHi-Lo, 20 cards at +1 and 20 at -1 per 52. Detection is the correlation of bet size with true count over a long session
Data, not codeRanks and suits, deck count, penetration, the seed, paytables, counting tags, the installed dealer policy
Code, not dataThe ace algorithm, the 21 threshold, the blackjack definition, and above all the order of the settlement clauses
Do notPut value on Card, put the bet on Player, make Shoe a Singleton, or reshuffle mid-round

Back to 08 — Elevator System for the scheduling problem, or 09 — Grocery Store System for the money one.