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.
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:
| Hand | Possible totals | Best score |
|---|---|---|
| A♠ 6♥ | 7 or 17 | 17 (a "soft 17") |
| A♠ 6♥ 9♦ | 16 or 26 | 16 (now a "hard 16") |
| A♠ A♥ 9♦ | 11, 21, or 31 | 21 |
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:
1// RUSHED — correct, but fragile2public int score() {3 int total = 0;4 boolean hasAce = false;5 for (Card c : cards) {6 total += c.getRank().baseValue(); // ace counts as 1 here7 if (c.getRank() == Rank.ACE) hasAce = true;8 }9 if (hasAce && total + 10 <= 21) total += 10;10 return total;11}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:
1// WRONG — upgrades every ace2for (Card c : cards) if (c.isAce()) total += 10; // A,A,9 → 31, "bust"34// WRONG — branches per ace count5if (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.
1public int bestScore() {2 int total = 0;3 int aces = 0;4 for (Card card : cards) {5 total += card.getRank().valueHigh(); // ACE = 11, picture cards = 106 if (card.getRank() == Rank.ACE) aces++;7 }8 while (total > 21 && aces > 0) { // each downgrade costs 109 total -= 10;10 aces--;11 }12 return total;13}1415public boolean isSoft() { // an ace is still counted as 1116 int aces = (int) cards.stream().filter(c -> c.getRank() == Rank.ACE).count();17 return aces > 0 && rawHighTotal() - 10 * (aces - countDowngraded()) != bestScore();18}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:
1public record Score(int total, boolean soft) {}23public Score score() {4 int total = 0, aces = 0;5 for (Card c : cards) { total += c.getRank().valueHigh(); if (c.isAce()) aces++; }6 int highAces = aces;7 while (total > 21 && highAces > 0) { total -= 10; highAces--; }8 return new Score(total, highAces > 0); // soft if an ace is still worth 119}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.
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.
1public interface MoveStrategy {2 Action decide(HandView hand, Card dealerUpCard, Set<Action> allowed);3}45public class BasicStrategy implements MoveStrategy { // the published chart6 public Action decide(HandView hand, Card up, Set<Action> allowed) {7 if (hand.canSplit() && shouldSplit(hand, up) && allowed.contains(SPLIT)) return SPLIT;8 if (hand.total() <= 11 && allowed.contains(DOUBLE) && shouldDouble(hand, up)) return DOUBLE;9 return hand.total() < standThreshold(hand, up) ? HIT : STAND;10 }11}Three details worth narrating:
allowedis 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 thandecide(hand, upCard).HandViewis 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."
1public boolean shouldDraw() {2 Score s = hand.score();3 if (s.total() < 17) return true;4 return s.total() == 17 && s.soft() && rules.dealerHitsSoft17();5}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.
1public List<Hand> split(Hand hand, Shoe shoe) {2 if (!hand.canSplit()) throw new IllegalActionException("not a pair");3 if (player.getHands().size() > rules.maxSplits()) throw new IllegalActionException("split limit");4 player.debit(hand.getBet().getAmount()); // the new hand needs its own stake56 Hand second = new Hand(hand.removeSecondCard(), new Bet(hand.getBet().getAmount()));7 hand.add(shoe.draw());8 second.add(shoe.draw());9 player.addHand(second);10 return List.of(hand, second);11}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.
1public void doubleDown(Hand hand, Shoe shoe) {2 player.debit(hand.getBet().getAmount());3 hand.getBet().doubleStake();4 hand.add(shoe.draw());5 hand.stand(); // exactly one card, no choice after6}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.
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.