Ballonpoor.com is a companion site for the World Cup knockout rounds: live scores, the bracket, and win probabilities from POOR (Pairwise Opponent Outcome Rating model). This post is about how it’s put together, because the interesting part is less about the predictions and more about how little infrastructure you need to run a live-updating site through a month-long tournament.
The headline: the site went from empty repo to production in just over a day. The model behind it was its own sprint, about 39 hours in June, finished the night before the opening match. To do both, I leaned hard on AI-assisted development. Visual design was done through Claude Design and most of the code was written with an agent at the keyboard, but acceleration only works if the architecture underneath is simple enough to hold. So the decisions below were the ones I spent my time on.
One JSON file is the entire API
The site has no database, no application server, and no API in the usual sense. Everything you see (fixtures, live scores, the bracket, win probabilities and editorial) renders client-side from a single file: site.json, which was 33KB on launch day and grew to a few hundred KB of structured tournament state as the knockouts filled it in.
A pipeline on a Mac mini rebuilds that file and uploads it to Cloudflare R2. A Cloudflare Worker (21 lines, six of them an error branch) serves it alongside the static assets.
This buys failure isolation. The pipeline machine can catch fire and the site stays up; the data just goes stale. The publish step is a single atomic object upload, so there’s no deploy, no migration, no cache to invalidate, and no way to half-publish. During a live match the pipeline pushes a fresh site.json roughly every 15 seconds; between matchdays it idles.
I’d planned for the frontend to notice stale data and show a delayed-feed notice, and there’s even a feed.delayed flag in the data for it. I never wired it up. So if the Mac mini dies mid-match, you get a frozen score and no apology.
Cron plus a lock beats a job queue
The pipeline is deliberately unglamorous: cron fires a shell wrapper every minute. The wrapper decides whether anything is actually due and a fetcher gates every API call, allowing per-minute polling only inside a live window and a full refresh every six hours otherwise, because API quotas are a real budget. When a match is in play, the wrapper loops the fetch–build–publish cycle every ~15 seconds for the rest of its minute, and a mkdir-based lock makes overlapping cron fires no-ops. Stale locks from a killed run get reclaimed after five minutes.
No queue, no supervisor, no containers.
Every stage writes to a temp file and atomically renames, so a crash mid-write can never truncate the artifact a later stage depends on. If a build fails, the previous good site.json simply keeps being served. The failure modes are all “stale”, never “broken”, which is perfect for a site whose entire lifespan is one tournament.
The model: boring by design
POOR is two layers, kept strictly separate. The rating engine is Elo in the eloratings.net formulation (with match weights I tuned myself), computed point-in-time over nearly 50,000 internationals back to 1872. The probability layer maps an Elo difference to win/draw/loss through an ordered logit. Research published for the 2018 World Cup found that setup, with Elo as its only input, beat bivariate Poisson on the four World Cups before it. I didn’t run that test myself, but it was a good enough reason to start there.
Before trusting it I backtested against two honest floors: a base-rate model and a fixed-draw model, on a walk-forward split. The ordered logit clears both on log-loss and ranked probability score (0.844 on log-loss, against 0.867 and 1.046), and the calibration curve sits close to the diagonal, if a touch overconfident about its favourites.
It also takes a hint from the betting market. The bookmakers’ opening odds go in as one input next to the Elo gap, and they carry about 18% of the signal. So it’s mostly Elo, with a market nudge. The closing odds have a different job. I de-vig the closing consensus with Shin’s method (which accounts for informed money rather than just scaling the overround away), put it right beside the model’s number on the match page, and score the model against it. The model’s job on this site is to be honest first and foremost.
For matches in play there’s a small nowcast: a double-Poisson layer that updates the advancement probabilities from the current score and clock, so the live number on the home page and match page moves with the game. The bracket itself just shows the score. The nowcast is very much a first version. It assumes a flat 2.6 goals per 90 minutes, split by pre-match strength, and it has never heard of a red card.
I’ve written up how to build one yourself in a separate post.
A CMS with no server, and a draft gate
Editorial (match previews, recaps, the odd feature) is markdown with YAML frontmatter, in git. The CMS is Sveltia, which is a single static admin page that commits straight to the GitHub repo, so there’s no CMS backend to run or secure.
The plan was that when content landed on the main branch, a GitHub Action would poke the pipeline to patch the new editorial into the live site.json. That Action ran 59 times and failed 59 times, because I’d pointed it at an address GitHub’s servers couldn’t reach. It didn’t matter. The cron on the Mac mini was already pulling the content repo every minute and rebuilding whenever something changed, so a published edit was still live within about a minute. Belt and braces, minus the braces.
The one rule that makes this safe is that everything enters as draft: true, and drafts are stripped before the site data is built. Nothing reaches the public site without a human flipping the flag, which matters given some of the copy starts life AI-drafted. The publish gate is me. It works, occasionally too well. The semi-final, third-place and final previews never made it out of draft.
No framework, on purpose
The frontend is about 1,200 lines of hand-rolled vanilla JavaScript. No build step, no bundler, no dependencies — index.html loads one script, and the script renders everything from site.json.
For a project like this a framework is pure overhead: there’s exactly one data source, the render is one direction, and the site had to be finished in a day. I know because I tried. Partway through that day I scaffolded a separate Astro, Svelte and D1 version, and binned it about six hours later.
A few details do the heavy lifting: every dynamic value passes through an HTML escaper before it touches the DOM, and article markdown is escaped first and then has a small whitelist of tags reintroduced, so nothing that arrives in content can ever execute. Re-renders are diffed against the live DOM rather than blown away, so the page doesn’t flicker or lose your scroll position mid-match. The poller adapts: every 15 seconds while a match is live, otherwise it sleeps until just before the next kickoff. And kickoff times render from UTC in the reader’s own timezone, because a three-host World Cup spans four of them and nobody should be doing that arithmetic during the group stage.
What I’d tell you to steal
If you’re building anything tournament-shaped with live-ish data, fixed lifespan, one person operating it — the pattern is: compile all state into one artifact, publish it atomically to dumb storage, serve it from an edge worker, and let a cron loop with a lock be your scheduler. Keep the model honest with backtests against floors you’d be embarrassed to lose to. Put a human gate in front of any content generated. And spend the time you saved on the part people actually see.
Postscript: how it actually went
Added in October, along with a few corrections to the details above.
The tournament ran to 19 July. Spain won it, beating Argentina 1–0 after extra time in a final that was #1 against #2 on the model’s own ratings. The short version: the bookies won the group stage, and the model called three of the five knockout ties where they disagreed.
The group stage is the clean test, because I’d committed those 72 forecasts to git before a ball was kicked. The model’s log-loss was 0.911. The bookies’ was 0.851, on the last prices I’d captured before kickoff, often days out. Lower is better, and that’s not close. Across all 91 matches I have a price for, the model gave the actual result more probability than the bookies 45 times, nearly half, and still lost on average, because when it was wrong it was wrong with conviction. Ghana 1–0 Panama: model 16%, market 45%.
The knockouts were kinder. The model’s favourite went through in 25 of 32 ties. Where it and the market disagreed on who’d go through, it was right on USA–Belgium, France–Spain and the England–Argentina semi, where it had Argentina at 62% and the bookies had England at 53%. Argentina won 2–1. It was wrong on Australia–Egypt and Mexico–England. Five ties is an anecdote at best.
One confession: the site never logged its pre-match numbers, so the knockout figures are reconstructed. I replayed the model using only what it knew before each kickoff, and the replay matches all 18 numbers quoted in the match previews to within a point, including the three that never left draft. Next time I’m writing them down before kickoff.
So: predicting World Cup results, poorly, as advertised. A bit more poorly than the bookies, which for two days of modelling I’ll happily take.