Online Blackjack Game
Player, dealer, deck, hand, round. State machine on the round and a Strategy for dealer policy — the cleanest cards-game OOD.
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
Round—Betting → 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. SwapStandOn17forHitSoft17per table.
What is deliberately not in the diagram:
- No
Scoreclass. Hand totalling lives onHanditself 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 ofPlayer, 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 — andbestTotal()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.DealerPolicyhas no game knowledge beyond reading a hand. Adding “stand on hard 16, hit on soft 16” is one new class.
Trade-offs and extensions#
| Decision | Why | Cost if requirements change |
|---|---|---|
Hand.totals() returns a list | Soft/hard distinction is naturally multi-valued | Slightly more allocation per call; negligible in practice |
Round owns the state machine | Single source of truth for round flow | A round-scoped State pattern with separate classes would scale better at five+ states |
DealerPolicy as Strategy | House rules vary per table | None — exactly the right shape |
| In-memory shoe; no persistence | No requirement for resumable sessions | Adding resume means snapshotting Round state; record types make this cheap |
| Player as a flat class | No per-player behaviour beyond identity and balance | Becomes a problem only at the “VIP bonuses” level — composition with a BonusPolicy solves it |
Money arithmetic | Avoids double for payouts | None |
Likely follow-up extensions:
- Splitting hands.
Round.handsis already aMap<Player, List<Hand>>for exactly this reason. Add asplit(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
Playingbefore regular actions — or as a separateInsurancePhase. - 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()onPlayer?” — 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
shouldHitdecision 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
Gameaggregate, aBoard/Roundstate 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.
Related#
- Chess Game — the other game-as-OOD prompt; shares the round-as-state-machine shape.
- State Pattern — the round lifecycle is the textbook use.
- Strategy Pattern — dealer policy is the textbook use.
- Approaching the OOD Interview — the meta-script that shaped this writeup.
- Observer Pattern — the natural fit when “tournament leaderboard” or “live broadcast” are added as follow-ups.