Simulating Blackjack the Hard Way
I built a blackjack simulator. That sentence undersells it. What I actually built is a 28,000-line Python framework for simulating card games with an event-driven architecture, immutable state management, and realistic shuffle physics. For blackjack.
This is what happens when a software engineer gets curious about casino math.
The Itch
It started the way these things always start: I wanted to understand the numbers. Not the hand-wavy “the house has a 0.5% edge” stuff you read online, but the actual mechanics. How does basic strategy change when the dealer hits soft 17? What does card counting really buy you? How many riffle shuffles does it take before a deck is actually random?
I could have read a book. Instead I wrote a simulator. Then I rewrote it. Then I rewrote it again.
Getting the Simulation Right
The first rule I set for myself was no shortcuts. If you’re simulating blackjack to understand blackjack, you can’t approximate the parts you find inconvenient. Every card comes from a shoe. Every shuffle follows real physics. Every hand resolves by the actual casino rules: splits, doubles, surrender, insurance, the whole mess.
The shuffle simulation is where things got interesting. A perfect Fisher-Yates shuffle is trivial to implement, and it produces a uniformly random deck. But casino dealers don’t perform perfect shuffles. They do riffle shuffles, and the mathematics of imperfect riffles are well-studied. It takes about seven riffle shuffles to adequately randomize a deck. Fewer than that and there’s exploitable structure left in the card order. I implemented three shuffle types with configurable fidelity so I could study exactly how much information survives an imperfect shuffle.
Card counting was the other rabbit hole. The basic Hi-Lo system is straightforward: low cards add one, high cards subtract one, divide by decks remaining. But professional play deviations are where it gets complicated. The correct play changes based on the true count. Sometimes you should hit a 16 against a dealer 10. Sometimes you shouldn’t. The threshold depends on how deep you are into the shoe.
I implemented all of this because I wanted the numbers to be right.
The Architecture Obsession
The simulation code worked fine as a monolith. I could run thousands of hands and get statistically valid results. But I kept looking at the code and seeing things I wanted to fix.
The game logic was tangled up with the I/O. Strategy decisions were coupled to hand resolution. State was mutable and scattered across half a dozen objects. It worked, but it was the kind of code that made me uneasy. The kind where adding a new feature meant understanding every other feature first.
So I did a four-phase rewrite. Event-driven architecture, immutable state with frozen dataclasses, platform adapters to decouple the engine from any specific interface, and async support throughout. The kind of engineering that is completely unjustifiable for a side project and completely satisfying to build.
The core insight was treating every state change as an event. A card is dealt: that’s an event. A player hits: event. The dealer reveals their hole card: event. This makes the game engine a pure state machine. Feed it actions, get back new states. No side effects, no hidden mutations, easy to test, easy to verify.
It also means you can record an entire game as a sequence of events and replay it later. When my simulation produced a result that looked wrong, I could step through every decision and see exactly where the math diverged from my expectations. Usually the math was right and my expectations were wrong.
What 350,000 Hands Per Second Tells You
At this point the simulator can run about 350,000 games per second. That’s enough to get statistically meaningful results on almost any question you want to ask.
Some things I’ve confirmed that you probably already knew: basic strategy works. Card counting works, barely, under ideal conditions. The Martingale betting system is a reliable way to go broke slowly.
Some things that surprised me: the variance in blackjack is brutal. You can play perfect basic strategy and lose for hours. The math says you’ll come out slightly ahead over thousands of hands, but “slightly” is doing a lot of heavy lifting in that sentence. I have a much better intuition now for why card counting requires both a large bankroll and an iron stomach.
Why Build This
People build side projects for different reasons. Some want to ship a product. Some want to learn a technology. I wanted to understand a system, and building a simulation was the most thorough way I knew to do it.
The architecture work was its own reward. Not because anyone will ever need an event-driven blackjack engine with immutable state management, but because the patterns transfer. The same separation of concerns that makes a card game testable makes a distributed system debuggable. The same event-driven approach that lets me replay a hand of blackjack is the same approach that lets you replay a production incident.
The code is on GitHub if you want to look at it. Fair warning: it’s a side project that kept growing. Twenty-eight thousand lines of Python for a card game. Some projects are just like that.