Online Blackjack Game

Player, dealer, deck, hand, round. State machine on the round and a Strategy for dealer policy — the cleanest cards-game OOD.

System Intermediate
13 min read
ood case-study blackjack state-pattern strategy-pattern

Context#

Blackjack is a card game played between one or more players and a single dealer. Each player is dealt two cards and decides — independently — whether to take more cards (hit), stop (stand), double their bet (double down), or split a paired hand. The dealer then plays a fixed mechanical policy, and hands are settled against the dealer’s total. A “blackjack” is a two-card 21 and pays out at a premium rate; busting (going over 21) loses immediately.

The problem is a frequent OOD prompt because it has the right shape: a finite, well-defined rule set; a clear state machine on each round; and one obvious place where polymorphism (the dealer’s policy) is more honest than if-chains. It is also a good warm-up for the harder cards problems — the same skeleton scales to Poker variants and multi-deck shoes with minimal change.

The interviewer’s hidden objectives, roughly in order:

  • Can you separate the round flow from the player flow? Players take turns; the round has its own lifecycle.
  • Can you put the Ace-handling logic in one place — Hand — instead of scattered through the scoring code?
  • Can you defend a state machine on the round, naming all five states out loud?
  • Can you justify making the dealer’s policy a Strategy so the house rules (stand-on-17, hit-on-soft-17) can be swapped per table?

Requirements (functional and non-functional)#

Clarifying scope is what keeps blackjack a 45-minute problem and not a casino-software project.

Functional — in scope.

  • One dealer, one to seven players per round (typical table limit).
  • Standard 52-card deck; a “shoe” of one to eight decks shuffled together.
  • Each player places a bet before the round starts.
  • Deal two cards face up to each player, two to the dealer (one up, one down).
  • Players act in seat order: hit, stand, double down, split (if first two cards are equal rank).
  • Dealer reveals the hole card and plays the house policy until standing or busting.
  • Settle each player’s hand: blackjack pays 3:2, normal win pays 1:1, bust loses, push returns the bet.
  • An Ace counts as 11 unless that would bust the hand, in which case it counts as 1. A hand with an Ace counted as 11 is soft.

Functional — out of scope (called out explicitly). Insurance side bets, surrender, multi-hand split limits beyond one re-split, side bets like 21+3, tournament mode, multi-table sessions, dealer tells. These will not be discussed unless the interviewer adds them.

Non-functional.

  • In-process simulation; no persistence required.
  • A round completes in well under a second of compute — most time is waiting for player input.
  • Determinism for tests: the deck and RNG are injectable so a sequence of dealt cards can be replayed.

Use case diagram#

┌─────────────┐ ┌──────────────┐
│ Player │ │ Dealer │
└──────┬──────┘ └──────┬───────┘
│ │
┌───────────┼──────────┐ [play house policy]
▼ ▼ ▼ │
[place bet] [hit/stand] [double/split] │
│ │ │ │
└─────┬─────┴──────────┘ │
▼ │
┌──────────────────────────────────────────────────┐│
│ Blackjack Game │┘
│ (round lifecycle, deck mgmt, settlement) │
└──────────────────────┬───────────────────────────┘
┌──────────────────────┐
│ House (admin) │ ── configure rules, set deck count, audit RNG
└──────────────────────┘

Two primary actors (Player, Dealer); one secondary (House) for configuration. The House actor exists to make the configurability of dealer policy and rule set visible — that is where Strategy earns its place.

Class diagram#

┌─────────────────────────────────┐
│ Game │
├─────────────────────────────────┤
│ players : List<Player> │
│ dealer : Dealer │
│ shoe : Shoe │
│ rules : RuleSet │
│ round : Round (active) │
├─────────────────────────────────┤
│ startRound() │
│ act(player, action) │
│ settle() │
└─────────────┬───────────────────┘
│ 1
┌─────────────────────────────────┐
│ Round │ ◇── state: RoundState
├─────────────────────────────────┤ (Betting/Dealing/Playing/
│ bets : Map<Player, Money> │ DealerTurn/Settle)
│ hands : Map<Player, List<Hand>> │
│ dealerHand : Hand │
└─────────────────────────────────┘
┌───────────────┐ ┌──────────────────────┐ ┌─────────────────────┐
│ Player │ │ Hand │ │ Shoe │
├───────────────┤ ├──────────────────────┤ ├─────────────────────┤
│ id, balance │ │ cards : List<Card> │ │ decks : List<Card> │
│ seat │ ├──────────────────────┤ │ cursor │
└───────┬───────┘ │ add(card) │ ├─────────────────────┤
│ 1..* │ totals() → softOpts │ │ draw() → Card │
│ │ bestTotal() │ │ shuffle(rng) │
▼ │ isBlackjack() │ └─────────────────────┘
┌───────────────┐ │ isBust() │
│ Hand │ │ canSplit() │ ┌─────────────────────┐
└───────────────┘ └──────────────────────┘ │ DealerPolicy │◁── StandOn17
│ (Strategy) │ HitSoft17
┌──────────────────────┐ ├─────────────────────┤
│ Card │ │ shouldHit(Hand) │
├──────────────────────┤ └─────────────────────┘
│ rank : Rank │
│ suit : Suit │
├──────────────────────┤ ┌─────────────────┐
│ value() → 1..11 │ │ Dealer │
└──────────────────────┘ ├─────────────────┤
│ hand : Hand │
│ policy : DealerPolicy │
└─────────────────┘

Two patterns carry the load:

  • State pattern on RoundBetting → Dealing → Playing → DealerTurn → Settle. Each transition is mechanical and the legal player actions depend entirely on the current state.
  • Strategy pattern on DealerPolicy — the dealer’s “should I hit?” decision is a property of the house, not of the dealer object or the game loop. Swap StandOn17 for HitSoft17 per table.

What is deliberately not in the diagram:

  • No Score class. Hand totalling lives on Hand itself because the soft-vs-hard distinction is inseparable from the card list. Splitting it produces a getter-only data class and an external scorer that re-reads the same fields.
  • No inheritance on Player. A “high roller” or “VIP” is a configuration of Player, not a subclass. Composition wins.

Sequence diagram (key flows)#

The full round, from a single player’s perspective:

Player Game Round Shoe Hand DealerPolicy
│ placeBet(b) │ │ │ │ │
│─────────────►│ │ │ │ │
│ │ startRound()│ │ │ │
│ │────────────►│ │ │ │
│ │ │ draw() │ │ │
│ │ │────────────►│ │ │
│ │ │ card │ │ │
│ │ │◄────────────│ │ │
│ │ │ add(card) ──────────────► │ │
│ │ hand │ │ │ │
│ │◄────────────│ │ │ │
│ hit() │ │ │ │ │
│─────────────►│ │ │ │ │
│ │ act(hit) │ │ │ │
│ │────────────►│ │ │ │
│ │ │ draw() ────►│ │ │
│ │ │ card │ │ │
│ │ │◄────────────│ │ │
│ │ │ add(card) ──────────────► │ │
│ │ isBust? │ │ │ │
│ │◄────────────│ │ │ │
│ ... stand │ │ │ │ │
│ │ settle() │ │ │ │
│ │────────────►│ │ │ │
│ │ │ dealer turn loop: │ │
│ │ │ shouldHit(dealerHand) ─────────────────────►│
│ │ │ true / false ◄──────────────────────────────│
│ │ │ resolve hands vs dealer │ │
│ │ payouts │ │ │
│ │◄────────────│ │ │
│ payout │ │ │ │
│◄─────────────│ │ │ │

Notice the Round orchestrates two distinct sub-flows — player actions on prompt, dealer policy on autopilot. Both go through the same Hand.add(card) so all scoring logic stays in one place.

Activity diagram (for non-trivial state)#

The round’s state machine — the spine of the game logic:

┌─────────┐
│ start │
└────┬────┘
┌────────────┐
│ Betting │── all bets placed ──► ┌────────────┐
└────────────┘ │ Dealing │
└─────┬──────┘
│ two cards each (players + dealer)
┌────────────┐
│ Playing │
└─────┬──────┘
┌─────────────────────┼─────────────────────┐
▼ ▼ ▼
[player hits] [player stands] [player busts]
│ │ │
▼ ▼ ▼
add card; re-check next seat next seat
│ │ │
└─────────┬───────────┴─────────────────────┘
all seats done?
│ yes
┌────────────┐
│ DealerTurn │
└─────┬──────┘
│ apply DealerPolicy until stand or bust
┌────────────┐
│ Settle │── compute payouts; return to Betting
└────────────┘

The transitions are total. From Playing, the only ways out are “all hands resolved” (→ DealerTurn) or a table-level abort. DealerTurn → Settle is unconditional — the dealer’s hand either stood or busted, and either resolves cleanly against each player.

Java implementation#

A representative slice — the round’s spine, scoring on Hand, and the dealer policy strategy.

public enum Rank { TWO, THREE, FOUR, FIVE, SIX, SEVEN, EIGHT, NINE, TEN, J, Q, K, A }
public enum Suit { CLUBS, DIAMONDS, HEARTS, SPADES }
public record Card(Rank rank, Suit suit) {
public int baseValue() {
return switch (rank) {
case TWO -> 2; case THREE -> 3; case FOUR -> 4; case FIVE -> 5;
case SIX -> 6; case SEVEN -> 7; case EIGHT -> 8; case NINE -> 9;
case TEN, J, Q, K -> 10;
case A -> 1; // Ace base; soft total adds 10 elsewhere
};
}
public boolean isAce() { return rank == Rank.A; }
}
public final class Hand {
private final List<Card> cards = new ArrayList<>();
public void add(Card c) { cards.add(c); }
/** All legal totals (Ace as 1 produces one; Ace as 11 produces another). */
public List<Integer> totals() {
int base = cards.stream().mapToInt(Card::baseValue).sum();
int aces = (int) cards.stream().filter(Card::isAce).count();
// Each Ace can add 10 once; only one can do so usefully without busting.
List<Integer> out = new ArrayList<>();
for (int i = 0; i <= aces; i++) out.add(base + i * 10);
return out;
}
/** Best non-bust total, or the smallest if all bust. */
public int bestTotal() {
int best = -1;
for (int t : totals()) {
if (t <= 21 && t > best) best = t;
}
return best == -1 ? Collections.min(totals()) : best;
}
public boolean isSoft() { return totals().stream().anyMatch(t -> t != Collections.min(totals()) && t <= 21); }
public boolean isBust() { return Collections.min(totals()) > 21; }
public boolean isBlackjack() { return cards.size() == 2 && bestTotal() == 21; }
public boolean canSplit() { return cards.size() == 2 && cards.get(0).rank() == cards.get(1).rank(); }
public List<Card> cards() { return Collections.unmodifiableList(cards); }
}
public interface DealerPolicy {
boolean shouldHit(Hand h);
}
public final class StandOnAll17 implements DealerPolicy {
@Override public boolean shouldHit(Hand h) { return h.bestTotal() < 17; }
}
public final class HitOnSoft17 implements DealerPolicy {
@Override public boolean shouldHit(Hand h) {
int total = h.bestTotal();
if (total < 17) return true;
return total == 17 && h.isSoft();
}
}
public final class Round {
public enum State { BETTING, DEALING, PLAYING, DEALER_TURN, SETTLE }
private State state = State.BETTING;
private final Shoe shoe;
private final DealerPolicy dealerPolicy;
private final Map<Player, Money> bets = new LinkedHashMap<>();
private final Map<Player, List<Hand>> hands = new LinkedHashMap<>();
private final Hand dealerHand = new Hand();
public Round(Shoe shoe, DealerPolicy dealerPolicy) {
this.shoe = shoe; this.dealerPolicy = dealerPolicy;
}
public void placeBet(Player p, Money amount) {
require(State.BETTING);
bets.put(p, amount);
}
public void deal() {
require(State.BETTING);
state = State.DEALING;
for (Player p : bets.keySet()) {
Hand h = new Hand();
h.add(shoe.draw()); h.add(shoe.draw());
hands.put(p, new ArrayList<>(List.of(h)));
}
dealerHand.add(shoe.draw()); dealerHand.add(shoe.draw());
state = State.PLAYING;
}
public void hit(Player p, int handIndex) {
require(State.PLAYING);
Hand h = hands.get(p).get(handIndex);
h.add(shoe.draw());
}
public void stand(Player p, int handIndex) {
require(State.PLAYING);
// No-op on the hand itself; the round driver advances to next seat.
}
public void runDealer() {
require(State.PLAYING);
state = State.DEALER_TURN;
while (dealerPolicy.shouldHit(dealerHand)) {
dealerHand.add(shoe.draw());
}
}
public Map<Player, Money> settle() {
require(State.DEALER_TURN);
state = State.SETTLE;
Map<Player, Money> payouts = new LinkedHashMap<>();
int dealerTotal = dealerHand.bestTotal();
boolean dealerBust = dealerHand.isBust();
for (var entry : hands.entrySet()) {
Money bet = bets.get(entry.getKey());
Money payout = Money.ZERO;
for (Hand h : entry.getValue()) {
if (h.isBust()) payout = payout.minus(bet);
else if (h.isBlackjack()) payout = payout.plus(bet.times(1.5));
else if (dealerBust ||
h.bestTotal() > dealerTotal) payout = payout.plus(bet);
else if (h.bestTotal() < dealerTotal) payout = payout.minus(bet);
// equal totals → push, payout unchanged
}
payouts.put(entry.getKey(), payout);
}
return payouts;
}
private void require(State expected) {
if (state != expected) throw new IllegalStateException("Round in state " + state + ", expected " + expected);
}
}

Notes interviewers reward:

  • Hand.totals() returns every legal interpretation — and bestTotal() picks among them. Ace-handling is one method, one source of truth.
  • Shoe is injectable. Tests pass a deterministic shoe; production uses a shuffled one.
  • require(State) guards every transition. The state machine is not aspirational — it is enforced in code.
  • DealerPolicy has no game knowledge beyond reading a hand. Adding “stand on hard 16, hit on soft 16” is one new class.

Trade-offs and extensions#

DecisionWhyCost if requirements change
Hand.totals() returns a listSoft/hard distinction is naturally multi-valuedSlightly more allocation per call; negligible in practice
Round owns the state machineSingle source of truth for round flowA round-scoped State pattern with separate classes would scale better at five+ states
DealerPolicy as StrategyHouse rules vary per tableNone — exactly the right shape
In-memory shoe; no persistenceNo requirement for resumable sessionsAdding resume means snapshotting Round state; record types make this cheap
Player as a flat classNo per-player behaviour beyond identity and balanceBecomes a problem only at the “VIP bonuses” level — composition with a BonusPolicy solves it
Money arithmeticAvoids double for payoutsNone

Likely follow-up extensions:

  • Splitting hands. Round.hands is already a Map<Player, List<Hand>> for exactly this reason. Add a split(Player, handIndex) method that creates a second hand from one of the pair and deals a card to each.
  • Doubling down. A single-hit-then-stand variant; double the bet on the affected hand. Add doubleDown(Player, handIndex).
  • Insurance. When the dealer’s up-card is an Ace, players may bet up to half their stake that the hole card is a 10. Implement as a sub-state in Playing before regular actions — or as a separate InsurancePhase.
  • Multi-deck shoe with cut card. Replace Shoe.draw() to track penetration depth and shuffle when the cut card appears.
  • Networked play. The whole class diagram is unchanged; Game.act(player, action) becomes a remote method. The state machine prevents most ordering bugs that would otherwise surface.

Mock interview follow-ups#

  • “Why not put score() on Player?” — Scoring belongs to the hand, not the player. A player with three split hands has three distinct scores; tying score to player breaks the multi-hand case immediately.
  • “How would you handle a five-Ace hand?”totals() already handles arbitrary Ace counts. Each Ace can contribute 1 or 11; only the smallest interpretation matters once you bust.
  • “Where does the Strategy pattern fit?” — On dealer policy. The dealer’s shouldHit decision is the only thing the casino tunes per table, and it varies along one dimension (soft-17 behaviour).
  • “What’s the state machine on the round?” — Five states: Betting, Dealing, Playing, DealerTurn, Settle. Transitions are total. Walk the activity diagram.
  • “How do you make this testable?” — Inject the shoe (deterministic deck for replay) and the dealer policy. The Round.settle() method is a pure function of round state — perfect for unit tests on edge cases (blackjack on both sides, all bust, dealer 21 vs player 21).
  • “How does this compare to a Chess implementation?” — Same shape: a Game aggregate, a Board/Round state holder, and a state machine. Blackjack has fewer pieces but a more interesting payout calculation; Chess has more pieces but a simpler “win/draw/loss” terminal state.
Search ESC

Keyboard shortcuts

Shortcuts are disabled while typing in inputs.