0

Design a blackjack game

by · 1 day ago· ⑂ 0 remixes

Question: Design a blackjack game

Companies: Amazon

Difficulty: Medium

Problem statement

Model a blackjack table with a dealer, players, a shoe of decks, bets, hit/stand/double/split, and payouts.

Requirements

  • One table, one dealer and several seated players, served in seat order. Each player has a bankroll in whole chips.
  • A shoe holds N standard 52-card decks (6 by default). It is reshuffled between rounds once the cut card is reached (75% penetration). It is never reshuffled in the middle of a round.
  • A round goes: bets placed, two cards each dealt in rotation (dealer gets one up and one hole card), player turns, dealer turn, settlement.
  • Bets must fall between the table minimum and maximum and fit in the player's bankroll. Each bet is taken from the bankroll when it is placed.
  • Player actions on the active hand: Hit (take a card; the hand ends on 21 or bust), Stand, Double (first two cards only: add an equal bet, take exactly one card, end the hand) and Split (two cards of equal value: add an equal bet and make two hands, up to 4 hands per player). Split aces get one card each and cannot act again. A 21 after a split is not a blackjack.
  • Dealer rules are configurable: stand on soft 17 (S17) or hit soft 17 (H17).
  • Payouts: a natural blackjack pays 3:2 (configurable, e.g. 6:5), a win pays 1:1, a push returns the bet, and a loss or bust loses the bet. A player bust loses even if the dealer then busts. If the dealer has a natural, the round ends at once. Player naturals push and every other hand loses.
  • Actions can come from several client threads, so the table must check for each one that it is that player's turn.
  • Out of scope: insurance, even money, surrender, side bets, re-splitting rules beyond the hand cap, card counting detection, persistence, UI and networking.

Core objects

  • Card: an immutable rank and suit. It knows its point value (ace = 11, face cards = 10).
  • Shoe: builds and shuffles N decks, deals cards, and reports when the cut card has been passed.
  • Hand: the cards, the bet and the status of one hand. It computes the best total and whether the total is soft, and checks for blackjack, bust and split eligibility.
  • Player: name, bankroll and the hands they hold in this round.
  • DealerPolicy (interface) with StandSoft17 and HitSoft17: decides whether the dealer draws.
  • TableRules: limits, the hand cap and the blackjack payout ratio.
  • Suit, Phase (BETTING, PLAYER_TURNS, SETTLED) and Action (HIT, STAND, DOUBLE, SPLIT): enums.
  • Table: the orchestrator. It owns the shoe, seats, dealer hand and phase. It enforces turn order, applies actions, runs the dealer and settles bets.

Class design

{"type":"excalidraw","elements":[
 {"type":"rectangle","id":"table","x":340,"y":0,"width":200,"height":140,"label":{"text":"Table\nphase, dealer hand\nplace_bet(), deal()\nact(), current()\npayout()"}},
 {"type":"rectangle","id":"player","x":0,"y":35,"width":180,"height":70,"label":{"text":"Player\nbankroll, hands"}},
 {"type":"rectangle","id":"shoe","x":720,"y":35,"width":180,"height":70,"label":{"text":"Shoe"}},
 {"type":"rectangle","id":"card","x":720,"y":260,"width":180,"height":70,"label":{"text":"Card"}},
 {"type":"rectangle","id":"hand","x":0,"y":230,"width":200,"height":170,"label":{"text":"Hand\nbet, cards, done,\nfrom_split\nvalue()\nis_blackjack()\ncan_split()"}},
 {"type":"rectangle","id":"policy","x":340,"y":-220,"width":200,"height":90,"label":{"text":"<<interface>>\nDealerPolicy\nshould_hit(hand)"}},
 {"type":"rectangle","id":"s17","x":60,"y":-420,"width":180,"height":70,"label":{"text":"StandSoft17"}},
 {"type":"rectangle","id":"h17","x":620,"y":-420,"width":180,"height":70,"label":{"text":"HitSoft17"}},
 {"type":"arrow","id":"table-player","x":340,"y":70,"width":-160,"height":0,"start":{"id":"table"},"end":{"id":"player"},"label":{"text":"has"}},
 {"type":"arrow","id":"table-shoe","x":540,"y":70,"width":180,"height":0,"start":{"id":"table"},"end":{"id":"shoe"},"label":{"text":"has"}},
 {"type":"arrow","id":"shoe-card","x":810,"y":105,"width":1,"height":155,"start":{"id":"shoe"},"end":{"id":"card"},"label":{"text":"has"}},
 {"type":"arrow","id":"player-hand","x":90,"y":105,"width":10,"height":125,"start":{"id":"player"},"end":{"id":"hand"},"label":{"text":"has"}},
 {"type":"arrow","id":"hand-card","x":200,"y":295,"width":520,"height":0,"start":{"id":"hand"},"end":{"id":"card"},"label":{"text":"holds"}},
 {"type":"arrow","id":"table-policy","x":440,"y":0,"width":1,"height":-130,"start":{"id":"table"},"end":{"id":"policy"},"label":{"text":"uses"}},
 {"type":"arrow","id":"s17-policy","x":210,"y":-350,"width":130,"height":175,"start":{"id":"s17"},"end":{"id":"policy"},"label":{"text":"implements"}},
 {"type":"arrow","id":"h17-policy","x":650,"y":-350,"width":-110,"height":175,"start":{"id":"h17"},"end":{"id":"policy"},"label":{"text":"implements"}}
]}

Key flows

Place bets and deal

  1. Table.place_bet checks the phase is BETTING, the amount is within the limits and the bankroll covers it. It debits the bankroll and gives the player one Hand(bet).
  2. Table.deal reshuffles the Shoe if the cut card has been passed. It then deals two passes: one card to each betting player, then one to the dealer.
  3. A player natural is marked done. If the dealer has a natural, every hand is marked done and the table settles at once. Otherwise the phase becomes PLAYER_TURNS.

Player action (Table.act)

  1. Under the table lock, current() finds the first hand that is not done, in seat order and then hand order. If it does not belong to the caller, the action is rejected.
  2. Hit adds a card. Stand marks the hand done. Double checks there are two cards, charges a second bet, deals one card and ends the hand. Split checks can_split and the hand cap, charges the bet, moves one card into a new hand inserted after this one, and deals one card to each hand.
  3. Any hand at 21 or over is marked done. When no open hand is left, the dealer plays.

Dealer turn and settlement

  1. If any hand is still live (not bust and not a natural), the dealer draws while DealerPolicy.should_hit is true.
  2. payout(hand) returns what goes back to the bankroll, stake included: 0 for a loss, the bet for a push, 2x the bet for a win, and the bet plus the bet times 3/2 for a natural. The phase becomes SETTLED. new_round() clears the hands and returns to BETTING.
From Event To
BETTING deal(), dealer has no natural PLAYER_TURNS
BETTING deal(), dealer natural or every hand is a natural SETTLED
PLAYER_TURNS action closes the last open hand SETTLED (the dealer draws on the way)
SETTLED new_round() BETTING

Code

import random, threading
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from enum import Enum
from fractions import Fraction
from typing import Optional

Suit = Enum("Suit", "CLUBS DIAMONDS HEARTS SPADES")
Phase = Enum("Phase", "BETTING PLAYER_TURNS SETTLED")
Action = Enum("Action", "HIT STAND DOUBLE SPLIT")
RANKS = ["A"] + [str(n) for n in range(2, 11)] + ["J", "Q", "K"]

@dataclass(frozen=True)
class Card:
    rank: str
    suit: Suit
    @property
    def points(self) -> int: return 11 if self.rank == "A" else 10 if self.rank in ("J", "Q", "K") else int(self.rank)

class Shoe:
    def __init__(self, decks: int = 6, penetration: float = 0.75, rng: Optional[random.Random] = None):
        self.decks, self.penetration, self.rng = decks, penetration, rng or random.Random()
        self.shuffle()
    def shuffle(self) -> None:
        self.cards = [Card(r, s) for _ in range(self.decks) for s in Suit for r in RANKS]
        self.rng.shuffle(self.cards)
        self.cut = int(len(self.cards) * (1 - self.penetration))
    def needs_shuffle(self) -> bool: return len(self.cards) <= self.cut
    def draw(self) -> Card: return self.cards.pop()  # cut card leaves enough for a round

@dataclass(eq=False)  # identity equality: two hands holding equal cards are still distinct
class Hand:
    bet: int = 0
    cards: list[Card] = field(default_factory=list)
    done: bool = False
    from_split: bool = False
    def value(self) -> tuple[int, bool]:
        total, aces = sum(c.points for c in self.cards), sum(c.rank == "A" for c in self.cards)
        while total > 21 and aces: total, aces = total - 10, aces - 1
        return total, aces > 0  # soft when an ace still counts as 11
    @property
    def total(self) -> int: return self.value()[0]
    def is_blackjack(self) -> bool: return len(self.cards) == 2 and self.total == 21 and not self.from_split
    def is_bust(self) -> bool: return self.total > 21
    def can_split(self) -> bool: return len(self.cards) == 2 and self.cards[0].points == self.cards[1].points

@dataclass(eq=False)
class Player:
    name: str
    bankroll: int
    hands: list[Hand] = field(default_factory=list)
class DealerPolicy(ABC):
    @abstractmethod
    def should_hit(self, hand: Hand) -> bool: ...
class StandSoft17(DealerPolicy):
    def should_hit(self, hand: Hand) -> bool: return hand.total < 17
class HitSoft17(DealerPolicy):
    def should_hit(self, hand: Hand) -> bool: return hand.total < 17 or hand.value() == (17, True)
@dataclass(frozen=True)
class TableRules:
    min_bet: int = 10; max_bet: int = 500; max_hands: int = 4
    blackjack_pays: Fraction = Fraction(3, 2)

class Table:
    def __init__(self, shoe: Shoe, policy: DealerPolicy, rules: TableRules = TableRules()):
        self.shoe, self.policy, self.rules = shoe, policy, rules
        self.players: list[Player] = []
        self.dealer, self.phase, self._lock = Hand(), Phase.BETTING, threading.RLock()
    def _charge(self, p: Player, amount: int) -> None:
        if amount > p.bankroll: raise ValueError("insufficient bankroll")
        p.bankroll -= amount
    def place_bet(self, p: Player, amount: int) -> None:
        with self._lock:
            if self.phase is not Phase.BETTING or p.hands: raise ValueError("betting closed")
            if not self.rules.min_bet <= amount <= self.rules.max_bet: raise ValueError("bet out of limits")
            self._charge(p, amount)
            p.hands = [Hand(bet=amount)]
    def deal(self) -> None:
        with self._lock:
            active = [p for p in self.players if p.hands]
            if self.phase is not Phase.BETTING or not active: raise ValueError("nothing to deal")
            if self.shoe.needs_shuffle(): self.shoe.shuffle()
            for _ in range(2):
                for p in active: p.hands[0].cards.append(self.shoe.draw())
                self.dealer.cards.append(self.shoe.draw())
            for p in active:  # a dealer natural ends every hand; a player natural ends that hand
                p.hands[0].done = self.dealer.is_blackjack() or p.hands[0].is_blackjack()
            self.phase = Phase.PLAYER_TURNS
            self._maybe_finish()
    def current(self) -> Optional[tuple[Player, Hand]]:
        return next(((p, h) for p in self.players for h in p.hands if not h.done), None)
    def act(self, p: Player, action: Action) -> None:
        with self._lock:
            cur = self.current()
            if self.phase is not Phase.PLAYER_TURNS or cur is None or cur[0] is not p:
                raise ValueError("not your turn")
            hand = cur[1]
            if action is Action.HIT: hand.cards.append(self.shoe.draw())
            elif action is Action.STAND: hand.done = True
            elif action is Action.DOUBLE:
                if len(hand.cards) != 2: raise ValueError("double only on two cards")
                self._charge(p, hand.bet)
                hand.bet, hand.done = hand.bet * 2, True
                hand.cards.append(self.shoe.draw())
            elif action is Action.SPLIT:
                if not hand.can_split() or len(p.hands) >= self.rules.max_hands: raise ValueError("cannot split")
                self._charge(p, hand.bet)
                second = Hand(bet=hand.bet, cards=[hand.cards.pop()], from_split=True)
                hand.from_split, aces = True, hand.cards[0].rank == "A"
                p.hands.insert(p.hands.index(hand) + 1, second)
                for h in (hand, second):
                    h.cards.append(self.shoe.draw())
                    h.done = aces  # split aces get exactly one card
            for h in p.hands: h.done = h.done or h.total >= 21
            self._maybe_finish()
    def _maybe_finish(self) -> None:
        if self.current() is not None: return
        live = any(not h.is_bust() and not h.is_blackjack() for p in self.players for h in p.hands)
        while live and not self.dealer.is_blackjack() and self.policy.should_hit(self.dealer):
            self.dealer.cards.append(self.shoe.draw())
        for p in self.players: p.bankroll += sum(self.payout(h) for h in p.hands)
        self.phase = Phase.SETTLED
    def payout(self, h: Hand) -> int:  # chips returned to the bankroll, stake included
        d = self.dealer
        if h.is_bust(): return 0
        if h.is_blackjack(): return h.bet if d.is_blackjack() else h.bet + int(h.bet * self.rules.blackjack_pays)
        if d.is_blackjack(): return 0
        if d.is_bust() or h.total > d.total: return 2 * h.bet
        return h.bet if h.total == d.total else 0
    def new_round(self) -> None:
        with self._lock:
            for p in self.players: p.hands = []
            self.dealer, self.phase = Hand(), Phase.BETTING

table = Table(Shoe(decks=6, rng=random.Random(7)), HitSoft17())
alice, bob = Player("alice", 1000), Player("bob", 1000)
table.players += [alice, bob]; table.place_bet(alice, 100); table.place_bet(bob, 50); table.deal()
while (turn := table.current()):  # toy strategy: hit below 17
    table.act(turn[0], Action.HIT if turn[1].total < 17 else Action.STAND)
print(table.phase, table.dealer.total, alice.bankroll, bob.bankroll)

Design patterns

  • Strategy: DealerPolicy holds the house's S17/H17 rule, and TableRules.blackjack_pays holds the payout ratio. A casino can change either without touching Table.
  • State (lightweight): Phase guards each public method, so a bet after the deal or a hit after settlement is rejected. There are only three phases, so an enum with guard checks is clearer than separate state classes. If the phase list grows (insurance, peek), move to State objects.
  • Facade / controller: Table is the only entry point that changes state. Players, hands and the shoe are plain data it drives (a split just adds a Hand to the player's list), and this keeps the turn checks and the single lock in one place.

Extensions

  • Insurance and surrender: add an INSURANCE phase between dealing and PLAYER_TURNS when the dealer's up card is an ace, plus an insurance_bet field on Hand. Add Action.SURRENDER, which is valid on the first two cards only, refunds half the bet and marks the hand done. payout gets one more branch for each.
  • House rule variants: add flags to TableRules, such as double_after_split, resplit_aces and double_on (any two cards or 9–11 only). act reads the flags in its validation step. A RuleSet object can also hold the dealer policy and the payout ratio.
  • Multiplayer over the network and timeouts: wrap Table in a per-table actor or event loop. Clients send commands. A turn timer auto-stands the hand returned by current(). Publish domain events (CardDealt, HandSettled) to observers for UI and audit. Only the dealer's up card goes out before the dealer's turn.
  • Money correctness and audit: replace the in-memory bankroll with a wallet service. Each charge or payout is a ledger entry with an idempotency key (round id, hand id), so a crash in the middle of settlement cannot pay twice. Use a cryptographic RNG for the shoe and log the seed to support dispute replay.

Interviewer expectations

Mid-level (L4)

  • Clean Card/Shoe/Hand/Player/Table split, with ace soft/hard totals done correctly.
  • Hit, stand and dealer drawing to 17 work, and basic win/lose/push payouts are right.
  • Bets are checked against the limits and the bankroll.

Senior (L5)

  • Split and double are correct: bet accounting, the one-card rule for split aces, and no blackjack after a split.
  • Blackjack 3:2 vs 1:1, dealer-natural short-circuit, player bust loses even if dealer busts.
  • Explicit round phases and turn ownership. Dealer policy and payout ratio are pluggable. Reshuffling happens only between rounds.

Staff (L6+)

  • Handles concurrency and authority: one lock or actor per table, server-side checks of whose turn it is, and the hole card kept secret from clients.
  • Money integrity: idempotent ledger entries, settlement that survives a crash, RNG choice and auditability.
  • Shows how rule variants, new phases (insurance, surrender) and observers fit in without rewriting Table, and says when an enum-based state machine should grow into State objects.

Sign in to comment, vote or remix.