I built a football prediction game for the 2026 World Cup. A month long, 104 matches,
one winner at the end. It worked, people played, nothing caught fire.
Then I pointed the same app at a domestic league: Ligue 1, 306 matches, 34 matchdays,
nine months. Same data model, same scoring, same templates. Nothing crashed. Nothing
threw. Every test stayed green.
And almost every product decision inside the app was suddenly wrong.
Here are the four that mattered, because none of them were technical, and none of them
would have shown up in a test suite.
1. A cumulative leaderboard is decided by October
In a tournament, the overall standings are the whole game. You play for four weeks and
the table at the end is the story.
Over 34 matchdays, that table stops being a game around week eight. The player who
started well is 60 points ahead, the player who joined in November is mathematically out,
and everyone else is reading a scoreboard they cannot change. The product still worked.
It just had no stakes left.
The fix was not a better algorithm, it was a second unit of time: a per-matchday
leaderboard, so each weekend has its own winner, plus a season honours table counting
how many matchdays each player has won. Same points, same scoring, sliced differently.
A player who is 14th overall can still win this weekend, and that is the thing that
makes them come back on Friday.
Worth noting what I did not do: no reset, no handicap, no catch-up bonus. Anything
retroactive on a scoring system that people are currently playing destroys trust in the
standings, and the standings are the entire asset.
2. A sliding 24 hour reminder becomes 100 emails
The reminder job was built for a tournament: "if a player has unpredicted matches
kicking off in the next 24 hours, email them." Matches trickle in daily, so that reads
as one email a day, and it is fine.
A Ligue 1 matchday runs from Friday 20:45 to Sunday 20:45. The same job, unchanged,
would have sent three emails per weekend to the same person: one on Friday for one
match, one on Saturday for three, one on Sunday for five. Around a hundred emails per
season, per player. That is not a reminder, that is a spam complaint with extra steps.
It would also have arrived on the morning of the first match, when the natural gesture in
a league is the opposite: you fill all nine games in one sitting, once, whenever you
think of it.
So the job now has two disjoint code paths in the same command. Tournaments keep the
sliding window. Leagues get one email per matchday, fired when the first kickoff of
that matchday is 24 to 48 hours out, listing every still open match of the matchday.
The part I like: there is no reminder_sent table. Idempotency comes from the window
itself. "The first kickoff is between 24 and 48 hours away" is true on exactly one
calendar day, and the cron runs once a day. The one case that legitimately produces a
second email is a postponement that drags the first kickoff back into the window, and a
calendar that moved is exactly when you want to remind people again.
The tradeoff is written in the crontab in plain words: doubling the cron frequency would
double the emails. A comment is cheaper than a table, as long as the comment is where the
mistake would be made.
3. "Upcoming matches" is not "all future matches"
The dashboard listed every future match. In a tournament that is at most a few dozen
cards, and the progress badge reads "12/18 predicted", which feels achievable.
In a league it is 306 cards and "12/306", which feels like homework.
Now the list is bounded to the next two matchdays. The subtle part is how you pick
them. My first version took MIN(round_number) over unplayed matches. That is wrong in
any real league, because postponements are routine: one match of matchday 3 replayed in
November would have pinned the dashboard to matchdays 3 and 4 and hidden the actual
weekend. The window follows nearest kickoff times, not round numbers, and the
postponed match reappears by itself when its new slot comes around.
4. In a tournament there is always something to do
Four weeks of a World Cup is four weeks of permanent attention. Nine months is not. Most
of a league season, an engagement product is competing with the user forgetting it
exists.
The two moments I built for are both moments the app already knew about and was throwing
away: the player who just won a matchday (one per week, peak pride) and the player
who just finished predicting the coming matchday (everyone, 34 times a season, peak
engagement and then several days of nothing). Both now offer a share, native share sheet
on mobile with a WhatsApp fallback, and both link to the public competition page rather
than the private league.
That last detail took a minute of thought and is worth the minute: sharing a league
invite code from a mobile share sheet means it can land in a public post, and a private
standings table with strangers in it is not a feature.
The actual lesson
The data model was right. Event, Game, Prediction, a round_number column that was
already there. Not one migration was needed for any of this.
What was wrong was every assumption about cadence: how often the user shows up, how
long a unit of competition lasts, how far ahead they can see, how long they wait between
two moments of interest. Those assumptions are almost never in your schema. They are
spread across cron expressions, query limits, email conditions, and empty state copy,
which is exactly where nobody looks when they say "we just need to support a new
competition format".
If you are about to reuse a working product on a longer or shorter timescale, grep your
codebase for time: every 24 hours, every setMaxResults, every "next" and "current"
and "upcoming". That is your real diff.
The app is a free prediction game for friends and coworkers, no betting and no money
involved, built with Symfony, Turbo Streams over Mercure for live standings, and 16
locales. It is at pronoarena.com if you want to see the result,
and the Ligue 1 season starts on August 21.
Top comments (1)
"Every
24 hours, everysetMaxResults" is the part I'd underline. Those constants are facts with a shelf life, and nothing about them looks like an assumption when you read the line.I ran into the same class from a different side. My scanner scores my own projects and its penalty weights are hardcoded numbers I picked once, against a codebase that has since roughly tripled. Nothing failed. The numbers just quietly stopped describing the thing they were calibrated on, and every report kept looking authoritative.
What helped wasn't better constants — it was giving each one a birth certificate: the date I set it, what the distribution looked like on that date, and how far it's allowed to drift before something complains. The comparison is mechanical now, so the day it stops fitting, I get told instead of noticing in a retro.
Your four bugs share a property I'd never named: they're all decisions that were correct for a cadence nobody wrote down. A test suite can't catch them because there's nothing to assert against — the assumption was never expressed as a value anywhere.