Building a Bracket Buster
A probabilistic March Madness simulator — from idea to stat-backed analysis
Every March, 68 college basketball teams tip off in one of sports' most chaotic single-elimination tournaments. Brackets get busted in the first round. Cinderellas run all the way to the Final Four. A #1 seed hasn't been upset by a #16 seed in... well, until Saint Peter's showed everyone that it can happen.
The question that kicked off this project was simple: can I build a simulator that captures both the chalk (favorites winning) and the chaos (upsets) in a tunable, principled way? The answer turned out to be yes — and the math to do it is beautifully simple.
The Core Idea: A Single Chaos Knob
The first design decision was the most important one. How do you model the probability that Team A beats Team B? In a real game, dozens of factors matter — injury reports, pace of play, three-point shooting variance. But for bracket prediction, you only reliably know one thing ahead of tip-off: seed numbers.
The model needed to do two things at once:
- Respect seeds — a #1 seed should beat a #16 seed most of the time.
- Allow for upsets — because this is March Madness, not a scripted event.
The solution is a single function:
def win_probability(seed1: int, seed2: int, chaos: float) -> float:
base_prob = seed2 / (seed1 + seed2)
return base_prob * (1.0 - chaos) + 0.5 * chaos
Let's unpack this. The base_prob line is the key insight:
- #1 vs #16:
16 / (1 + 16) = 94.1%chance the #1 seed wins. - #8 vs #9:
9 / (8 + 9) = 52.9%— effectively a coin flip, which matches reality. - #5 vs #12:
12 / (5 + 12) = 70.6%— favors the #5 but not overwhelmingly.
The chaos parameter then blends this seed-based probability with a pure 50/50:
| Chaos Value | Behavior |
|---|---|
0.0 | Pure seed logic — lower seeds win proportionally more often |
0.5 | Half seed-weighted, half random — bracket-busting territory |
1.0 | Pure coin flip — seeds mean nothing |
This linear interpolation is elegant because the entire "personality" of a bracket — conservative vs. chaotic —
lives in one number between 0 and 1. You can run the same simulator ten times at chaos=0.0
and get roughly similar chalk brackets, or crank it to chaos=0.8 and watch #15 seeds reach the Final Four.
Getting Live Bracket Data
A simulator is only as good as its input data. The NCAA doesn't offer an open public API,
but the community-maintained henrygd/ncaa-api
proxy does the heavy lifting. The bracket data comes back as a flat list of game slots with
bracketPositionId values — integers that encode round and region.
Position IDs aren't arbitrary. 100–199 are First Four games, 200–299 are the Round of 64, 300–399 are the Round of 32, and so on. Processing games in sorted order guarantees that every predecessor game is resolved before the game that depends on its winner.
To avoid hammering the API during iterative development, the fetcher writes a local
.cache_basketball-men_2026.json file on the first call and reads from it on every
subsequent run. A --no-cache flag bypasses this for live data.
Simulating the Bracket
With live data in hand, the simulation engine processes the bracket as a directed acyclic graph (DAG): each game slot knows which two upstream slots feed winners into it. The engine walks the sorted position IDs and resolves each game by sampling from the probability distribution:
def pick_winner(game: Game, chaos: float) -> Team:
t1, t2 = game.team1, game.team2
p = win_probability(t1.seed, t2.seed, chaos)
return t1 if random.random() < p else t2
The result propagates forward — the winner of game 201 might be team1 in game 301. Sixty-three games later, you have a complete predicted bracket, expressed as a markdown table with seeds, team names, and a trophy emoji next to the champion.
Batch Simulation and Summary Stats
Running one simulation is interesting. Running a hundred is revealing.
The --simulations N flag runs the full tournament N times and aggregates results
into a champion frequency table:
$ python bracket_buster.py --simulations 100 --chaos 0.4
Champion Summary (100 simulations):
Kansas ████████████████ 18 wins (18.0%)
Duke ██████████ 12 wins (12.0%)
Houston ████████ 10 wins (10.0%)
...
This surfaces something that a single bracket can't: which teams are consistently in the conversation at a given chaos level, vs. which are one-hit wonders that only win when the randomness breaks their way.
Evaluation: Working Backward from the Winner
The latest and most analytically interesting feature is --evaluate.
Instead of predicting forward, it looks at a set of already-generated bracket files
and asks: given the seed matchups that actually occurred, how likely was each bracket outcome?
Each bracket is scored by multiplying the probabilities of every game result across all 63 games. Because these are small numbers multiplied together sixty-three times, the math is done in log space to avoid floating-point underflow:
log_prob = sum(log10(p) for p in game_probabilities)
# A "perfect chalk" bracket would score around log_prob = -8
# A massive upset bracket might score -25 or lower
The evaluator then ranks brackets from most to least probable, surfaces the upsets that drove the biggest probability penalties, and reports how often each team won the championship across the evaluated set. It's a way to pressure-test your bracket instincts: was your Final Four actually defensible by the numbers, or were you just wishful thinking?
Architecture: Keeping It Small
The entire application is 677 lines of Python in a single file. That's a deliberate choice. This isn't a product — it's a focused analytical tool. Breaking it into packages would add overhead without adding clarity. The structure within the file is logical:
- Dataclasses —
Team,Game,BracketResults - API layer — fetch, cache, parse
- Simulation engine — probability model, game resolution, DAG traversal
- Output formatting — markdown tables by region and round
- Evaluation — log-probability scoring, upset detection, ranking
- CLI —
argparsewiring everything together
One external dependency: requests. Everything else is standard library.
What I'd Build Next
A few natural extensions worth exploring:
- Historical calibration — fit the chaos parameter to real historical upset rates by seed matchup, rather than choosing it intuitively.
- Head-to-head records — incorporate adjusted efficiency metrics (like KenPom) as an additional signal beyond seed.
- Interactive web UI — let users drag the chaos slider and watch the bracket update in real time.
- Pool scoring — score a simulated bracket against standard ESPN/Yahoo scoring rules to optimize for expected pool points, not just accuracy.
March Madness is fun precisely because it's unpredictable. A good simulator doesn't try to eliminate that uncertainty — it quantifies it. The chaos parameter isn't a hack; it's an honest acknowledgment that seed numbers explain a lot, but not everything. Sometimes the #12 seed just hits.
The code is on GitHub. Fill out your bracket responsibly.