A World Cup bracket pool with an AI advisor and live Monte Carlo odds
Every World Cup my friends want to run a bracket pool. The apps for it are full of ads and upsells and still bad at the actual job, so I built our own: no ads, one private pool for the group chat. I had a second problem too, which is that I don't really follow soccer and had no idea who to pick. That second problem turned into the part I spent the most time on.
No bracket tree. Instead of filling in a grid, you predict which teams reach each round -- R32, R16, up to Champion. That was deliberate: a real bracket forces you to encode FIFA's third-place pairing table, and skipping it means everyone gets graded on the same ladder regardless of who draws whom. Group top-two is worth a little, a correct Champion is worth a lot, and the back of the bracket is where the pool is won.
An AI advisor. If you don't know the field, you can talk to it. It's a Bedrock
Converse stream grounded by a system prompt that hands the model the verified
48-team roster, the exact scoring rules, and the one constraint that trips models
up: the knockout rounds have to nest, at exact sizes. Champion is one team, and it
has to be one of your two finalists, who have to be two of your four
semifinalists, and so on down. The model doesn't get to hand me a bracket; it
fills a single propose_bracket tool. I don't trust its output -- it hallucinates
team ids, or picks a team into a round it never advanced to -- so a tolerant
mapping layer filters to valid ids, enforces group membership, clamps every round
to its size, and nests the picks upward, so a deep pick the model forgot to also
list in an earlier round gets added back instead of thrown away. The result is
always a clean, internally consistent draft, no matter how messy the model was.
To enforce the nesting, I build the rounds bottom-up: a team the model put in a later round is forced into every earlier one, then the model's own picks fill the rest up to the round's exact size.
function buildRound(modelPicks: string[], forced: string[], cap: number) {
const out: string[] = [];
for (const id of [...forced, ...modelPicks]) {
if (out.length >= cap) break; // clamp to the exact round size
if (!out.includes(id)) out.push(id); // dedupe, keep order
}
return out;
}
// champion ⊆ final ⊆ semifinals ⊆ quarterfinals ⊆ round of 16
const final = buildRound(model.final, namedChampion, 2);
const sf = buildRound(model.sf, final, 4);
const qf = buildRound(model.qf, sf, 8);
const r16 = buildRound(model.r16, qf, 16);
Talking to the AI costs money. Each player gets an AI budget in game-dollars equal to the buy-in: $50. Every turn spends from it, and the models are priced differently -- Haiku is cheap, Opus is stronger but you'll get maybe ten turns out of it. So picking a model is a real decision: explore wide on Haiku, then spend big on Opus for the final read. Same budget for everyone, so nobody can buy a better bracket. That same tariff also caps my real Bedrock bill, which stayed in single-digit dollars across the whole group.
The odds. To show your live chance of winning the pool I run a Monte Carlo: 10,000 full tournaments, each team's Elo turned into expected goals, scores sampled as independent Poissons, draws falling out on their own, knockout ties broken by an Elo-weighted coin. That's about a second of CPU, too slow for a page load, so it runs on the server after each batch of results and the page only reads cached numbers. One bug worth mentioning: I was seeding the random draw off everything, including a "games starting soon" window, so the odds visibly jittered every twenty minutes with nothing actually decided. Now the seed comes only from completed results, so the championship number moves when a goal matters and at no other time. On top of that sits a "who should you root for tonight" feature, which is just the swing in your win probability between a game's best and worst outcome for you. If that swing is under a third of a percent, the app tells you the game doesn't matter to you instead of pretending it does.
The rest is plumbing: Next.js on a small AWS Lightsail server, SQLite, a cron polling scores, push-to-deploy.
← Writing