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.

The Path view tracing Argentina round by round from Round of 32 up to Champion, with the points each round is worth.
You predict which teams reach each round, not a filled-in tree. Tap any team to trace its path. Deeper picks score more: 1 point for the round of 32, up to 20 for the champion.

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.

A standings card showing live odds to win the pool and to cash, above a list of upcoming games with which team to root for and why.
Live pool odds from the Monte Carlo, and who to root for in the next games.

The rest is plumbing: Next.js on a small AWS Lightsail server, SQLite, a cron polling scores, push-to-deploy.

← Writing