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 whole thing went from empty repo to production in just over a day. To do so, 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 from fixtures, live scores, the bracket, win probabilities, and editorial renders client-side from a single file: `site.json`, about 190KB of structured tournament state.
A pipeline on a Mac mini rebuilds that file and uploads it to Cloudflare R2. A Cloudflare Worker (21 lines, and most of that is an error branch) and 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, and the frontend notices (it compares the file’s `generated_at` stamp) and shows a delayed-feed notice. 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.
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, computed point-in-time over ~49,000 internationals back to 1872. The probability layer maps an Elo difference to win/draw/loss through an ordered logit, an architecture that has beaten bivariate Poisson out of sample on past World Cups.
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, and the calibration curve sits close to the diagonal. It also gets blended with the betting market: I de-vig the closing consensus with Shin’s method (which accounts for informed money rather than just scaling the overround away) and let the market anchor the model where they disagree. 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 bracket view moves with the game.
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. When content lands on the main branch, a GitHub Action pokes the pipeline, which patches the editorial fields into the live `site.json`. This is a content-only refresh, no model rebuild so a published edit is live within about a minute.
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.
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. 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.
The tournament runs to July 19. The model gives the favourites the numbers it gives them, the site will be there either way, quietly rebuilding a JSON file every minute, predicting World Cup results, poorly.