Moving beyond · Advanced

Optimal Blackjack

Don't memorise the strategy chart — compute it. Derive perfect blackjack from nothing but the rules, with dynamic programming.

The leap

Back in Station 4 you played blackjack with a hand-wavy rule ("hit until 17"). Real players memorise a basic-strategy chart — a grid telling you hit/stand/double for every hand. But where does that chart come from? It isn't guessed or tuned. It's calculated — the mathematically perfect play, provably. In this challenge you'll compute it yourself, from first principles, and never trust a memorised chart again.

The tool is dynamic programming: break a hard question ("what's the best play here?") into the same question about simpler positions, solve those, and build up. It's the workhorse behind optimal decision-making everywhere — from blackjack to option pricing to routing to reinforcement learning.

The plan

Three layers, each built on the last:

1. DealerThe dealer's play is fixed (hit below 17, stand on 17+). Compute the exact probability of each final total (17, 18, 19, 20, 21, bust) for every upcard — by recursion.
2. StandingGiven that dealer distribution, the EV of standing on any total is just arithmetic: sum up win / lose / push.
3. Best playThe EV of hitting is the average over the next card of the best EV of the new hand — a recursion into simpler hands. Double is "one card then stand, at double stake." Take the max: that's optimal play, and the argmax is your chart.
Rules to fix (so your numbers are checkable): an infinite deck (each draw: 2–9 with prob 1/13 each, a ten-value card 4/13, an ace 1/13), dealer stands on all 17, blackjack pays 3:2, you may double any two cards. You already built the soft/hard ace logic in Station 4 — reuse it.

Build it

Starter code — the skeleton (the recursions are yours)
from functools import lru_cache

# Infinite-deck blackjack, dealer stands on all 17.
# Each draw: 2..9 with prob 1/13 each, a ten-value card 4/13, an Ace 1/13.
DRAW = [(v, 1/13) for v in range(2, 10)] + [(10, 4/13), ('A', 1/13)]

def add_card(total, soft, card):
    """Add a card. `soft` = how many aces are still counted as 11 (Station 4!)."""
    if card == 'A':
        total += 11; soft += 1
    else:
        total += card
    while total > 21 and soft > 0:     # an ace drops from 11 to 1 to save you
        total -= 10; soft -= 1
    return total, soft

def dealer_dist(total, soft):
    """Probabilities of the dealer's FINAL total (17..21) or 'bust',
    starting from (total, soft). Dealer hits below 17, stands on 17+.
    Return a dict like {17:.., 18:.., ..., 'bust':..}. MEMOISE this."""
    # TODO: if total > 21 -> {'bust':1.0};  if total >= 17 -> {total:1.0}
    # TODO: else draw one card, recurse, and sum the weighted sub-distributions
    ...

def stand_ev(player_total, dealer):
    """EV of standing on player_total vs the dealer distribution `dealer`."""
    # TODO: for each dealer outcome k with prob p:
    #   win +1 if k=='bust' or player_total > k; push 0 if equal; else lose -1
    ...

def best_ev(total, soft, dealer, can_double):
    """Best EV over the legal actions."""
    if total > 21:
        return -1
    st = stand_ev(total, dealer)
    # hit: average over the next card of best_ev of the new hand (no more doubling)
    hit = sum(p * best_ev(*add_card(total, soft, c), dealer, False) for c, p in DRAW)
    best = max(st, hit)
    if can_double:
        # TODO: double = take exactly ONE card then stand, at 2x stake
        #   (a bust while doubling loses 2). best = max(best, that)
        ...
    return best

# dealer distribution for a single upcard u:  dealer_dist(*add_card(0, 0, u))
Hints

Memoise the dealer. dealer_dist is called a huge number of times with the same arguments — wrap it with @lru_cache (return a tuple of items, or freeze the dict) or keep your own memo dictionary. Without it, it's painfully slow.

The hit recursion always terminates because every card makes the total bigger (or an ace resolves); eventually you stand or bust. Busting returns −1.

Double is the easy one: draw one card, and if you didn't bust, stand — but everything is worth twice as much (and a bust costs −2).

Reading the chart: for each dealer upcard, build its distribution once, then for each player total call best_ev and also check which action won (compare the stand/hit/double EVs). That argmax is the basic-strategy chart.

If your dealer bust rate for a 6 comes out around 0.42, your dealer layer is right — that's the foundation everything else stands on.

🎯 Check your solver

Build the DP, then compute these. The first two prove your dealer layer; the third proves the player layer and nails the most famous decision in blackjack.

🏁
Milestone: you derived the entire basic-strategy chart from nothing but the rules — no chart memorised, every square computed — and your dealer bust rates and the notorious "hit 16 vs 10" fall out exactly right. That's dynamic programming: the same tool that prices options and trains game-playing agents, here handing you provably perfect blackjack.

Where this goes

You just solved a decision problem by working backwards from the end — the essence of dynamic programming and, one step further, reinforcement learning. The same skeleton (states, actions, expected values, take the max) prices American options, plans routes, and underlies how agents learn to play games. Want the next rung? Ask Smee about the market-making challenge.