Object-Oriented Design Interview

Course Content

Object-Oriented Design Interview

14 sections · 29 lessons

Blackjack: scoring aces, turn flow and extensions


The model is settled: hands that score themselves, a dealer with a fixed rule, and every casino variation in one rules object. This lesson writes the code where it matters — the ace scoring that trips up hasty candidates, the round's turn flow and the patterns in it — and then works the follow-ups, starting with the one that tests the model directly.

The ace problem, in code

A small piece of code that separates careful candidates from hasty ones. Ten lines, and most first attempts get it wrong in a way that is invisible until a specific hand appears.

Count the aces, then downgrade while busting11119012ace, softace, softTotal 31 busts, so one ace drops to 1 and the hand is 21; the second stays soft.
Counting aces and downgrading one at a time is correct; a per-card if-else is the version that breaks.

Why an ace is hard

An ace is worth 1 or 11, whichever helps. So a hand does not have a score; it has a set of possible scores, and the rule is: take the highest that does not exceed 21, and if all of them exceed 21, the hand is bust.

Three hands make the difficulty concrete:

HandPossible totalsBest score
A♠ 6♥7 or 1717 (a "soft 17")
A♠ 6♥ 9♦16 or 2616 (now a "hard 16")
A♠ A♥ 9♦11, 21, or 3121

The third row is where hasty code fails. Two aces cannot both be 11 — that alone is 22 — so at most one ace in a hand can ever count as 11.

The messy version, and its bug

Here is what a rushed attempt produces:

Java
// RUSHED — correct, but fragilepublic int score() {    int total = 0;    boolean hasAce = false;    for (Card c : cards) {        total += c.getRank().baseValue();     // ace counts as 1 here        if (c.getRank() == Rank.ACE) hasAce = true;    }    if (hasAce && total + 10 <= 21) total += 10;    return total;}

This one is actually correct, and it is worth understanding why before rejecting it: since only one ace can ever be 11, upgrading at most one ace is sufficient. The problem is that it is correct by an argument the reader has to reconstruct, and small variations of it are wrong. The common broken variants:

Java
// WRONG — upgrades every acefor (Card c : cards) if (c.isAce()) total += 10;   // A,A,9 → 31, "bust"// WRONG — branches per ace countif (aces == 1) { … } else if (aces == 2) { … } else if (aces == 3) { … }

The second is the tell. Branching on the number of aces means you have not seen the invariant, and the branches will disagree with each other on some hand.

The clean version: count aces, downgrade while busting

Score every ace as 11 first, then downgrade one at a time while the hand is over 21.

Java
public int bestScore() {    int total = 0;    int aces = 0;    for (Card card : cards) {        total += card.getRank().valueHigh();       // ACE = 11, picture cards = 10        if (card.getRank() == Rank.ACE) aces++;    }    while (total > 21 && aces > 0) {               // each downgrade costs 10        total -= 10;        aces--;    }    return total;}public boolean isSoft() {                          // an ace is still counted as 11    int aces = (int) cards.stream().filter(c -> c.getRank() == Rank.ACE).count();    return aces > 0 && rawHighTotal() - 10 * (aces - countDowngraded()) != bestScore();}

Trace it on A♠ A♥ 9♦: total starts at 11 + 11 + 9 = 31 with two aces. Over 21, so downgrade one: 21, one ace left. Not over 21, so stop. Answer 21. Correct.

Trace it on A♠ 6♥ 9♦: 11 + 6 + 9 = 26, one ace. Downgrade: 16. Correct, and correctly a hard 16.

Five lines, no branching on ace count, and provably correct: the loop terminates because each iteration removes an ace, and it cannot over-downgrade because it stops the moment the total is legal.

Simplifying isSoft

The version above is awkward. Track it directly instead — compute both numbers in one pass:

Java
public record Score(int total, boolean soft) {}public Score score() {    int total = 0, aces = 0;    for (Card c : cards) { total += c.getRank().valueHigh(); if (c.isAce()) aces++; }    int highAces = aces;    while (total > 21 && highAces > 0) { total -= 10; highAces--; }    return new Score(total, highAces > 0);          // soft if an ace is still worth 11}

One pass, one small value object, and both questions answered. Returning a Score rather than an int is a small design decision worth narrating: the hand has two facts about its value, and a method that returns one of them forces the caller to recompute the other.

Turn flow and patterns

Three things here: the round as a state machine, player decisions behind a Strategy, and dealer rules as configuration.

The round as a state machine

A blackjack round is strictly sequential, which makes its state machine simple and worth drawing — it is the frame everything else hangs on.

BETTINGDEALINGPLAYER TURNDEALER TURNSETTLINGbets placedtwo cards eachhit — still under21standbust — over 21dealer stands orbustsnext roundnatural blackjackTwo arrows skip the dealer entirely: a player bust and a natural blackjack both settle immediately. Missing either one is the usual bug.
The dealer's turn is skipped on a bust and on a natural — the shortcuts are where the rules actually live.

The two shortcuts on that diagram are worth calling out in the interview. If the dealer shows an ace or a ten and has blackjack, the round ends before anyone acts. If every player busts, the dealer does not play at all — the house has already won, and in a real casino the dealer does not reveal the hole card. Both are small, both are real, and mentioning them shows domain attention.

Player decisions behind a Strategy

Same shape as the tic tac toe bot, and for the same reason.

Java
public interface MoveStrategy {    Action decide(HandView hand, Card dealerUpCard, Set<Action> allowed);}public class BasicStrategy implements MoveStrategy {          // the published chart    public Action decide(HandView hand, Card up, Set<Action> allowed) {        if (hand.canSplit() && shouldSplit(hand, up) && allowed.contains(SPLIT)) return SPLIT;        if (hand.total() <= 11 && allowed.contains(DOUBLE) && shouldDouble(hand, up)) return DOUBLE;        return hand.total() < standThreshold(hand, up) ? HIT : STAND;    }}

Three details worth narrating:

  • allowed is passed in, so the strategy cannot choose an illegal action — you cannot double on three cards, and the rules cap splits. Validity is the game's job; preference is the strategy's. That separation is the reason this signature is better than decide(hand, upCard).
  • HandView is read-only, so a strategy cannot deal itself a card. Same reasoning as tic tac toe.
  • A human player is a strategy too — one that blocks on input. The game loop cannot tell the difference, which is requirement N2.

Dealer rules as configuration

The dealer needs no strategy at all, and saying why is a good moment:

"The dealer doesn't get a Strategy, because the dealer doesn't decide anything — the rule is fixed and public. So it's a method reading two fields of GameRules. Giving the dealer a strategy interface would be a pattern with one permanent implementation, which is the over-engineering case from earlier."

Java
public boolean shouldDraw() {    Score s = hand.score();    if (s.total() < 17) return true;    return s.total() == 17 && s.soft() && rules.dealerHitsSoft17();}

Three lines, and it is where the soft flag from the ace-scoring code above earns its existence. This is the final version of Dealer.shouldDraw: the sketch in the model used bestScore() and isSoft(), and this one reads both facts from the single Score record instead. Every casino variation of the dealer rule is expressible by changing one boolean.

Extensions

1. Splitting, which tests the earlier modelling choice

This is the follow-up that checks whether you put scoring and bets on Hand.

Five follow-ups, and one is a trapBlackjackextensionsSplitting a pairDoubling downInsuranceCounting a shoeMulti-player rules
Splitting is the audit: it only costs a line if the bet and the score already lived on Hand.
Java
public List<Hand> split(Hand hand, Shoe shoe) {    if (!hand.canSplit()) throw new IllegalActionException("not a pair");    if (player.getHands().size() > rules.maxSplits()) throw new IllegalActionException("split limit");    player.debit(hand.getBet().getAmount());                 // the new hand needs its own stake    Hand second = new Hand(hand.removeSecondCard(), new Bet(hand.getBet().getAmount()));    hand.add(shoe.draw());    second.add(shoe.draw());    player.addHand(second);    return List.of(hand, second);}

If Hand owns its cards, its bet, and its scoring, this is ten lines. If scoring lives on Player, this extension is a redesign in front of the interviewer — which is precisely what the question is for.

Three rules to mention: split aces usually receive exactly one card each and cannot be hit again; a ten-value card on a split ace is 21 but not a blackjack (so it pays even money, not 3:2); and re-splitting is capped by rules.maxSplits().

2. Doubling down

Double the bet, take exactly one card, then stand automatically.

Java
public void doubleDown(Hand hand, Shoe shoe) {    player.debit(hand.getBet().getAmount());    hand.getBet().doubleStake();    hand.add(shoe.draw());    hand.stand();                                  // exactly one card, no choice after}

The design point is that doubleDown ends the hand's turn itself. If the game loop has to remember "after a double, do not ask again", the rule has leaked out of the class that owns it.

3. Insurance

When the dealer's up-card is an ace, players may bet up to half their stake that the dealer has blackjack, paying 2:1. It is a separate bet on a separate outcome, which is the modelling point: it is not a modification of the main bet, so it is a second Bet on the hand with its own settlement rule.

Worth adding one honest line: insurance is a losing proposition for the player at ordinary deck compositions, which is why counters treat it as a special case. Naming that is a nice domain touch and it sets up the next extension.

4. Card counting in a shoe

Counting works because a shoe is not reshuffled after every hand (requirement N3). As small cards leave the shoe, the remaining cards favour the player, and a counter raises their bet.

The design absorbs this with no changes, which is the point to make: a counting bot is another MoveStrategy, plus a BetStrategy that sees the same observed cards.

Java
public interface BetStrategy { Money betFor(ShoeView shoe, Money bankroll); }

The interesting design question is what a strategy is allowed to see. ShoeView exposing cards dealt so far permits counting; exposing only the count of cards remaining does not. That is an access-control decision expressed in an interface, and saying it that way — "whether counting is possible is decided by what I put on ShoeView" — is a strong observation.

5. Multi-player table rules

Several players change three things: turn order (left of the dealer first), the shared shoe (everyone draws from one), and settlement (each hand settles independently against the dealer, not against other players).

The last one is worth stating because it is a genuine simplification: blackjack is not a player-versus-player game, so settlement is a loop over hands with no interaction between them. A design that tried to compare players to each other has misread the game.