Skip to content
Unit 25 of 28

Capstone: Blackjack simulator

Contents · 13

This unit brings the course together in one complete program: a Monte Carlo simulator for a cut down game of blackjack. A Monte Carlo simulation estimates something that is hard to calculate by playing many random trials and counting what happens. You will turn a short and slightly ambiguous brief into precise rules, split the program into small pieces you can test, write a reference solution whose output you can reproduce exactly, and back every rule with a test.

The brief is a take home exercise of the kind employers send to developer candidates. An earlier solution to it passed all of its own tests and still broke the rules: it scored aces wrongly and printed the wrong summary. Its tests only checked the types of the results. Most of the work in this unit goes into making sure that cannot happen to your version.

You will be able to:

  • Turn an informal brief into precise rules, and record the decisions you make where it is silent
  • Score hands with "soft" aces correctly
  • Pass a seeded random generator into your code so that every run can be reproduced and tested
  • Test each game rule with a stacked shoe and pytest
  • Wrap the simulator in a small command line interface
  • Extend the design to several decks, several players and several processes

The brief

Here is the task, in this course's words.

Write a Monte Carlo simulator for a simplified blackjack in which one player plays against the dealer. Number cards score their face value; jacks, queens and kings score 10; an ace scores 1 or 11. A hand's total is the sum of its cards. The simulator follows these rules:

  1. The player takes all of their turns first; the dealer plays afterwards.
  2. The player hits (takes another card) while their total is below 17 and stands (takes no more cards) at 17 or more. They may hit several times.
  3. If the player goes bust (over 21), the dealer wins.
  4. If the two totals are equal, the round is a draw.
  5. While the player's total is higher than the dealer's, the dealer hits. The dealer may hit several times.
  6. An ace counts as 1 or as 11.

Every other casino rule, such as splitting a pair, is out of scope. The simulator must run for any number N of rounds and finish by printing exactly these four lines, with the blanks filled in:

You hand in the code, unit tests, a requirements.txt if you use third party packages, a short README.md that says how to run everything, and a note on anything you did not finish. The brief also asks three design questions: how would you make the simulator fast enough to run millions of times, how would you add more players, and how would you play with several packs of cards? Check yourself answers them, and the exercises build them.

Pinning down the rules

A brief this short leaves gaps. Writing your decisions down, in the README and in the tests, is part of the job. These are the decisions this unit makes.

Question the brief leaves openDecisionWhy
How many cards does each hand start with?Two each: the player's two, then the dealer's twoThe standard deal. With a well shuffled deck the dealing order does not change any probability
Does the dealer stop on a tie?YesRule 5 only makes the dealer hit while it is behind; rule 4 makes equal totals a draw
Does the dealer draw after the player busts?NoThe round is already decided by rule 3
Is a two card 21 ("blackjack") special?NoThe brief says to ignore the other rules
One deck or many? Reshuffle when?A fresh, shuffled 52 card deck for every roundRounds stay independent; several decks are an extension
How is N supplied?A command line argument, and a simulate(rounds, rng) functionUsable from a terminal and from tests
"There were 1 draws" is ungrammaticalKeep the exact wordingThe brief fixes the output, and a checker may compare it

One consequence is worth spotting before you write any code. A player who stands has 17 to 21, and the dealer then keeps drawing until it draws level, goes ahead, or busts. So the dealer can never finish below a standing player: the player wins only when the dealer busts. The earlier solution had two outcome branches, "player has 21" and "player is ahead", that could never run for exactly this reason.

The design

The program is a handful of small functions and classes. Only main knows about the command line, and only Shoe.shuffled draws random numbers, from a random.Random object that main creates and passes down.

Rendering diagram…
  • Card is a frozen dataclass, so cards are immutable and hashable. See Classes and objects.
  • Shoe holds the cards still to be dealt (a casino's card holder is called a shoe). You can build one from a shuffled deck, or from an exact list of cards for a test.
  • hand_value scores a hand, including soft aces.
  • play_round applies rules 1 to 5 and returns a Round with the outcome and both hands, so tests can see which cards each side took.
  • simulate plays N rounds and returns a Summary, whose report() produces the four lines.

play_round follows this flow:

Rendering diagram…

Scoring aces

A hand with an ace counted as 11 is called soft: an ace with a 6 is a soft 17. If the player hits and draws a 9, counting the ace as 11 would give 26, so the ace drops to 1 and the hand becomes a hard 16. The value of an ace therefore depends on cards that arrive after it.

The earlier solution decided each ace's value at the moment it arrived and never revisited it. Compare that with the correct method: count every ace as 1, then add 10 once if the result stays at 21 or less. Only one ace can ever count as 11, because two would already make 22.

The first two hands agree, which is why a few handpicked checks did not catch the bug. The last two show a hand of 15 or 16 scored as bust. In a long simulation the first draft makes the player bust about 1.4 percentage points more often than the rules allow.

Pitfall. A test that only asserts isinstance(result, int) passes for 25 and for 15 alike. Assert the exact value the rules require.

The reference solution

Here is the complete program, blackjack.py. It runs 10,000 rounds with seed 42 and prints the summary.

What to notice:

  • No global state. Nothing changes at module level while the program runs. The counts live in a Counter inside simulate, so you can call it twice in one process and get two independent answers.
  • Randomness comes in from outside. simulate takes a random.Random. A seed makes a run repeatable; a test can use a fixed seed, and a production run can pass random.Random() for fresh entropy. You do not need secrets here: this is a simulation, not security.
  • Rules are named. LIMIT and PLAYER_STANDS_ON say what 21 and 17 mean, and an extension can change them in one place.
  • Card is frozen. Every round can share the 52 Card objects in FULL_DECK, because nobody can modify them. That is also why the program is quick: 10,000 rounds take well under a second.
  • main takes argv and returns an exit status. Tests can call main(["20", "--seed", "3"]) directly, without starting a new process.
  • Errors are specific. Drawing from an empty shoe raises EmptyShoeError rather than a vague IndexError from deep inside a list, and a negative round count raises ValueError. See Errors and exceptions.

Note. The same seed gives the same counts on the same Python version. Python promises that random() keeps producing the same sequence for a given seed, but it does not make that promise for helpers such as shuffle, so another version may print slightly different numbers.

Running it as a project

As a project, the simulator is four files:

The simulator itself uses only the standard library. The tests need pytest, so requirements.txt contains one line, pytest. The README states the rules you decided on (the table above), how to run the program and the tests, and anything still missing. These are the commands it lists, on macOS or Linux:

On Windows, in PowerShell:

What the numbers say

In the run above the dealer won 51.8% of rounds, the player 36.6%, and 11.6% were draws. Over three million rounds the shares settle at about 50.8%, 37.1% and 12.1%. The dealer's edge comes from rule 1. It acts last, so it wins every round in which the player busts without drawing a card, and otherwise it keeps drawing with the player's final total in view. A casino dealer, by contrast, follows a fixed drawing rule and cannot react to your total.

Ten thousand rounds pin each share down only to within about one percentage point. Statistics in the exercises shows how to put a number on that uncertainty.

The test suite

The earlier solution had four tests, and every one of them only checked a type. A useful suite tests the rules. The key tool is a stacked shoe: a Shoe built from an exact list of cards, so you know every card that will be dealt. The helper rigged("10 6", "9 8", "5") deals the player 10 and 6, the dealer 9 and 8, and then a 5 to whoever hits next.

Seeds still have a place. They test properties that hold for every shuffle: the counts add up, a seed reproduces a run, and a shuffled shoe never deals the same card twice.

The block below first recreates blackjack.py so that it runs on its own. The copy is the reference solution with one change: its last line calls main() and reads the command line. In your project that file already exists. Your test_blackjack.py is then four imports from the top of the block (itertools, random, Counter and pytest), followed by everything from the from blackjack import line onwards. pytest runs in process, as in Testing with pytest.

Your timing will differ:

Each rule maps to at least one test:

Rule or propertyTests
Card values, soft aces, several aces, order independencetest_hand_value, test_hand_value_ignores_card_order
Deck integrity: 52 unique cards, 13 per suit, 4 per rank, a fresh list each timetest_new_deck_*, test_shuffling_keeps_every_card, test_a_shuffled_round_never_repeats_a_card
Dealing from the top, an empty shoe, no side effectstest_draw_*, test_shoe_leaves_the_callers_list_alone
Player hits below 17, stands at 17 (soft or hard), may hit several timestest_round[player-*]
A soft hand whose ace drops to 1 after a hit, inside a round, for the player and for the dealertest_round[player-soft-hand-ace-drops], test_round[dealer-soft-hand-ace-drops]
Dealer hits while behind, stands on a tie, hits past 17, can bust, does not draw after a player busttest_round[dealer-*], test_round[player-busts-dealer-does-not-draw]
One case per outcome rule: player bust, tie, dealer bust, dealer highertest_round
N rounds, N = 0, negative N, reproducibilitytest_simulate_*
Exact output and the command linetest_report_*, test_main_*, test_parser_*

Tip. Look at the player-busts-dealer-does-not-draw case. Its shoe holds no spare card, so a dealer that wrongly drew would raise EmptyShoeError and fail the test. Short shoes turn "must not happen" rules into failures you can see, and the id= names make a failing row easy to spot in pytest's report.

Self assessment rubric

Use this table to judge your own solution before you compare it with the reference.

CriterionNeeds workMeets the briefGoes further
RulesAces fixed when drawn; dealer logic guessedAll six rules, soft aces includedGaps in the brief decided and written down
OutputDifferent wording, or N missingThe four lines exactlyOptional JSON output for other programs
RandomnessGlobal random, never seededA random.Random passed in; --seedPer chunk seeds for parallel runs
StructureLogic inside if __name__ == "__main__":; "constants" used as countersSmall functions and dataclasses; no global stateReady for decks and players without rewriting the core
TestsType checks onlyOne test per rule with stacked shoes; soft aces; deck integrityParametrised; CLI and output tested; failure paths
DocumentationNoneREADME with run and test commandsStates every assumption and what is missing
PerformanceUntested10,000 rounds in about a second or lessMeasured scaling across processes

Check yourself

  1. Why does simulate take a random.Random argument instead of calling random.shuffle directly?
  2. Under these rules, why can the player win only when the dealer busts?
  3. Why can at most one ace in a hand count as 11?
  4. Why are stacked shoes better than seeds for testing the dealer's rules?
  5. The brief does not say whether the player or the dealer gets the first card. Why does that choice not change the results when each round uses a freshly shuffled deck?
  6. The brief asks how you would run millions of rounds. What would you do?
  7. How would you add more players?
  8. How would you play with several packs of cards, and what changes if the shoe is kept between rounds?

Suggested answers

  1. So that the caller controls the randomness. A seeded generator makes a run repeatable, tests can fix the seed, and two simulations in one program do not interfere through hidden global state.
  2. A standing player has 17 to 21. The dealer keeps hitting while it is behind, so it stops only when it has drawn level, gone ahead, or bust. Level is a draw and ahead is a dealer win, which leaves the dealer's bust as the player's only way to win.
  3. Two aces at 11 already make 22. So you count every ace as 1 and add 10 once, if the total stays at 21 or less.
  4. A stacked shoe states exactly which cards are dealt, so the test can name the rule it checks ("player 19, dealer 17, next card 3: the dealer hits and wins"). With a seed you would have to search for a shuffle that happens to produce the situation, and the test would break if the shuffle algorithm changed.
  5. Every order of a uniformly shuffled deck is equally likely, so any fixed rule for handing out the cards gives each hand a uniformly random set of cards. The individual rounds change, but the probabilities do not.
  6. Measure first. Then make each round cheaper (fewer objects, for example cards as small integers), and spread independent chunks of rounds across processes, because the work is CPU bound and processes are not limited by the GIL (Concurrency). Give each chunk its own seed so the total stays reproducible. Remember that precision grows slowly: four times as many rounds only halves the uncertainty.
  7. Give each seat its own hand, deal and play the seats in order, then let the dealer play once and settle each seat separately. The brief's dealer rule has to be restated for several players, for example "draw until level with the best standing hand".
  8. Build the shoe from k decks and shuffle them together. If the shoe is kept between rounds, rounds are no longer independent: the cards already seen change the odds of the next round, which is what card counters exploit. Casinos reshuffle when a cut card comes up, and your shoe needs the same rule, or it will eventually run out.

Exercises

These exercises are optional practice. Pick the ones that help you.

Each exercise extends the capstone. The solutions are self contained, so some repeat a compact version of the core rules, with cards as plain rank strings because suits never affect a score.

  1. Several decks. Write a MultiDeckShoe(decks, rng, reshuffle_at) that shuffles decks standard decks together (1 to 8, otherwise ValueError), deals from the top with draw(), and has a start_round() method that refills and reshuffles the whole shoe when fewer than reshuffle_at cards remain, counting how often that happens in reshuffles. With 6 decks, seed 42 and reshuffle_at=78, check that the new shoe holds 312 cards with 24 of each rank, then play 100 rounds of exactly 5 cards each. Expected: 282 cards left and 2 reshuffles.

  2. Several players. Write play_table(deck, players) for 1 to 5 players. Deal two cards to each seat in order, then two to the dealer. Each player hits below 17 as before. The dealer draws while it is behind the best total among players who did not bust, and does not draw at all if every player busted. Settle each seat with the brief's rules and return "player", "dealer" or "draw" per seat. Test it with a stacked deck: with the cards 10 7 10 9 10 6 9 8 K 2 and three players, the result is ["dealer", "draw", "dealer"]. Then report each seat's results over 10,000 rounds with three players and seed 42. Your exact counts depend on how you build and shuffle the deck, so compare the shape and not the digits: expect each seat to win about 46% of its rounds.

  3. Statistics. A 95% confidence interval for a share p measured over n rounds is roughly p ± z·√(p(1 − p)/n), where z is about 1.96 and comes from the normal distribution. Take the exact value from statistics.NormalDist().inv_cdf(0.975). Write share_interval(count, rounds) returning the share and its interval, and print it for the reference run (3,660 player wins, 5,183 dealer wins and 1,157 draws out of 10,000). Then write rounds_needed(margin) for the worst case p = 0.5, and compute how many rounds give a margin of ±0.1 percentage points. Expected: 960,365 (the rounded value z = 1.96 gives 960,400).

  4. A richer command line. Extend build_parser() with --decks (1 to 8, default 1), --players (1 to 5, default 1) and --format (text or json, default text). Show the parsed values for 5000 --decks 6 --players 3 --format json, and show that --decks 9, --format xml and a round count of -1 each stop with exit status 2.

  5. Using several processes. Write simulate_parallel(rounds, seed, workers, chunks=4) that splits the rounds into chunks, draws one seed per chunk from random.Random(seed), runs the chunks in a ProcessPoolExecutor, and adds up the counts. Show that 20,000 rounds with seed 42 give the same counts with 1 worker and with 2, and that both match running the same chunks one after another without a pool.

Solutions

1. Several decks.

The reshuffle check runs between rounds, as a casino's cut card does, so a round is never interrupted. Keep reshuffle_at comfortably above the most cards one round can use. To plug the class into the reference solution, fill the shoe with Card objects instead of tuples. play_round then needs nothing more than an object whose draw() method returns a Card; a typing.Protocol can state that requirement. Once the shoe lives across rounds, simulate should create it once instead of calling Shoe.shuffled for every round.

2. Several players.

Each seat now wins far more often than the lone player did: about 46% instead of 37%. The dealer has to chase the best standing hand at the table, so it draws more cards and busts more often, and a dealer bust pays every seat that is still standing. It is tempting to guess the opposite, which is exactly why you simulate instead of guessing.

Reading the deck through iter() and next() leaves the list untouched, so the same list can be shuffled again for the next round. Five seats cannot exhaust a 52 card deck. A hand only takes a card while its total is 20 or less, so it never passes 30 points (counting aces as 1). Six hands therefore use at most 180 points of cards, and the 36 smallest cards in a deck already add up to 180.

3. Statistics.

Compare these with the long run shares measured earlier (37.1%, 50.8% and 12.1%). The player and draw shares fall inside their intervals. The dealer's long run share sits just below its interval, which is expected now and then: a 95% interval misses the true value about one time in twenty. The margin shrinks with the square root of the number of rounds, which is why "run it millions of times" matters: ten times the precision costs a hundred times the rounds. This simple interval is fine for thousands of rounds and shares well away from 0 or 1; for small samples, look up the Wilson interval.

4. A richer command line.

argparse prints each error message and the usage line to standard error, which is why they do not appear in the output above. choices=range(1, 9) checks the converted integer, and metavar keeps the usage line short. For the JSON format, main can print json.dumps(dataclasses.asdict(summary)) instead of summary.report(). A test for each option can call parse_args directly, as the test suite does.

5. Using several processes.

The seeds belong to the chunks, not to the processes. Whichever worker picks up chunk 3, it plays the same rounds, so the total does not depend on how many workers you use or in what order they finish. If each process seeded itself, a run with two workers and a run with four would give different counts, and neither could be reproduced reliably. The if __name__ == "__main__": guard is required: on Windows and macOS (and on Linux since 3.14) worker processes import the main module afresh, as Concurrency explains. Starting processes costs time, so a speedup only appears with much larger round counts; time it with time.perf_counter() at 1,000,000 rounds and 1, 2 and 4 workers. For heavy statistical work, NumPy's SeedSequence is designed to produce independent seeds for parallel streams.