Fooball
A World Cup bracket and a Premier League draft game for one group chat.
SwiftUI
ActivityKitSupabase
Deno
Next.js
football-data.orgFPL API
Our group chat wanted to play the 2026 World Cup against each other, minus the money. Before there was an app, there was a PDF of rules, and the chat argued about it. Scoring it live is harder than it sounds, because football data is messy and a group chat notices the second someone is in the wrong place on the table.
Fooball is what came out of that argument. It started as a World Cup bracket: you pick every group and the full knockout tree, and the app scores the tournament live. The server never keeps a running score. It works everything out again from what's true. During the knockouts, we built a Premier League fantasy draft inside the same app, and the draft is the game on the App Store now.


Fixing the rules
The PDF had the basics. Later rounds give more points, and calling an underdog gives more again.
One friend asked what the round multiplier was for, if every round used the same 1.5×. He was right. Multiply everyone's knockout points by the same number and nobody's rank changes. The rules doc we wrote agrees with him in writing, then keeps a multiplier anyway, an escalating one this time.
The doc says "He's right." Then it keeps the multiplier.That version had its own problem, and you only see it when you add up a whole round. Knockout picks already double in value each round, while the number of matches halves. So with no multiplier, a perfect round is worth the same in every round. Stack an escalating multiplier on top, and the final alone is worth a third of every knockout point in the tournament.1


So the multiplier went. The doubling already does its job, and no single match decides the league.
The group stage got the same treatment. "Pick two teams that go through" became "pick the order", and the right two in the wrong order still get half a point each.
Paying for upsets
The best idea in the PDF was the upset bonus. Call an underdog, and if it wins, your points are multiplied by one plus the chance it was going to lose. Call a 28% team and you get ×1.72.
The catch is that a bonus needs a match. You lock your bracket before the round of 32, when almost none of the later matches exist yet. The first build guessed head-to-head odds for any pair from each team's price to win the whole tournament.2
The formula held up. The market didn't. A title price mostly says how far a team will go, and very little about one game between two teams that haven't qualified yet. So before a ball was kicked, we stopped pricing matches that didn't exist. Now an upset scores only when a real market prices the pick at the moment you make it. Group picks use each group's winner market, and round of 32 picks use each team's price to reach the round of 16. From the round of 16 on, picks score flat, because the doubling already makes them count.
One real match shows the difference. Germany played Paraguay in the round of 32. The title market gave Paraguay 1.4% of the pair. The market to reach the round of 16 gave Paraguay 17.3%.


The title price was mostly saying Paraguay wasn't going to win the World Cup. The round of 16 price was about this game. Paraguay won on penalties, and everyone who called it got ×1.83.
An underdog's chance is always under 50%, so a correct round of 32 pick is worth 1 point, a coin flip 1.5 and a long shot nearly 2.
Each stage's odds freeze when the stage locks, so you're scored on the numbers you saw when you picked. The app says win probability, the market's name stays on the server, and nothing links out to it.
Picking groups
The group screen shows the bonus before you pick. Each group is a grid of four flags with a chance on every tile, and you tap one team for first, one for second and one for third. Put an underdog in the top two and a multiplier chip appears on that tile, and only that tile.
The first build was a list, with a big green Lock it in button. But the app already saved each pick as you made it, and you could change picks until the deadline. In the words of the commit, the button "implied a finality the model never had". So it went. The bar now says saved automatically, edit until the deadline.


The rest of the look comes from Apple Sports: near-black screens, huge scores and the losing side dimmed. We copied the feel and swapped clubs for nations.
Filling the bracket
The knockout bracket is 31 picks in a fixed tree, keyed by FIFA's own match numbers. So one number is the slot in Swift, the row in Postgres and the match in the live feed.
Changing a pick has its own problem. Swap one team early and every later pick of that team is now impossible, but clearing everything after it would throw away picks that are still fine. So the app walks forward and clears a pick only if it's the team you just removed.
The bracket used to open only after all the group games. Now it fills in as it stands, a live projection from the current group tables. That meant the full 2026 tiebreak rules, and FIFA's table for where the eight best third-placed teams land.3

Scoring live football
Live data is messy. A feed can report one penalty twice, once as a goal and once as a penalty. VAR can take a goal back after everyone's points have moved. A feed can fold a shootout into the full-time score, so 1–1 with a 3–4 shootout arrives as 4–5.
So the server does all the scoring, and it never adds to a running total. Each pass builds the full set of scoring events that should exist from the current results, writes the ones that changed and deletes the rest. Then each total is the sum of its events.


A correction needs no special undo. If group C's table changes after the fact, the next pass just gives a different answer, and no code needs to know what changed. Running a pass twice gives the same answer as running it once, which matters when it runs every minute of a match.
On the phone, every screen reads one copy of the live state. So Today and the leaderboard never disagree, and there's no refresh button anywhere.
Notifications needed the same idea from the other side, because you can't recompute a push once it's on a lock screen. Each one claims a key that names the goal itself, not the feed row, and only the first claim sends. So nobody hears about one goal twice, and when VAR takes one back, everyone who got the goal gets a "no goal".
The goal text grows with the moment. A normal goal says GOAL. A stoppage-time comeback gets GOOOOOAL!!!
The bracket also shares as a poster, and picks go gray as their teams go out.

Adding a draft game
We wanted it to keep going after the final. Our group already plays a Premier League fantasy draft every season. The official version has the right rules and data, but no app, no push notifications, and tables that update hours after the final whistle.
We built it in two days during the knockouts, with one rule above everything else: zero disruption, because people were still playing the World Cup in the same app. So the Premier League got its own tables, and nothing built for the draft could move a bracket score.
Four days after the final, we retired the World Cup. New leagues are draft leagues, and old World Cup leagues stay as a read-only archive.


Running the draft
The draft room is the first thing a new league does together. The order is a snake. Round one goes left to right and round two comes back.


Only the server decides if a pick counts. One database function checks that it's your turn, the player is free and your squad can still be completed, then advances the draft. A unique constraint means two people can't own the same player, whatever the network does.


When the pick clock runs out, the server picks from your watchlist. A human pick and an autopick both lock the draft first, because a last-millisecond tap and an expiring clock will collide eventually.
After the draft, the game is the lineup. You start 11 of your 15 in a legal formation, and the warning says what's wrong in words: play 3–5 defenders.


We decided early not to calculate points. Parts of the official scoring need data that isn't free, so we read the official points and promise parity. What we calculate is lineups, auto-subs and head-to-head results.
Auto-subs have one rule people forget. The bench goes in order, but a bench player only comes on if the formation stays legal.


Here, a defender in a 3-5-2 played no minutes. Bringing on the first outfield sub, a midfielder, would leave two defenders. So he's skipped, and the defender after him comes on.4
Waivers settle who gets a player when two managers want him. Claims are blind, and last place goes first. A manager whose claim succeeds goes to the back of the queue, so last place can't sweep every good player in one week.


One engine, two games
The draft didn't get a new way to score. It works everything out again from what's true, like the bracket, and a correction is one more pass. That's why we could add a second game during the knockouts without moving a single bracket score.
- Knockout picks are worth 1, 2, 4, 8 and 16 points across rounds of 16, 8, 4, 2 and 1 matches, so a perfect round is 16 points every time. The rules doc's escalating multiplier ran ×1.0, ×1.25, ×1.5, ×2.0 and ×3.0, which made perfect rounds worth 16, 20, 24, 32 and 48. ↩
- This is the Bradley–Terry model. Each team's title price, with the market's margin removed, becomes its share of the title. It has to be a ratio. With 48 teams, a favorite's share might be 0.15 and an underdog's 0.04, and 0.11 isn't the probability of anything. Divide instead, and the underdog's chance is 0.04 / 0.19 = 21%. ↩
- Eight of the twelve third-placed teams go through, and where each lands depends on which eight. There are 495 ways to choose them, and we checked the projection against every one. ↩
- "Played" means any minutes, or a yellow or red card, so a booked sub who didn't play still counts. The keeper is handled first, and only the backup keeper can replace him. ↩