A World Cup pick'em built from scratch

Personal project: a predictions web app that follows the 104 matches of the 2026 World Cup live, moves the standings during the game and nudges forgetful players at the right moment. Written in one week, before kickoff.

Personal project·PHP web app installable as a mobile app·Written in a week, fixed during the tournament
Pick'em dashboard on desktop: rank, points, match of the day and overall standings

The dashboard on the evening of the final. Participants' usernames and avatars are blurred.

104matches tracked and scored automatically, from the first group game to the final
932predictions recorded by 14 accounts over the whole tournament
476push reminders sent, only to players who had not predicted yet

Why this project

Two reasons, in this order. First: I wanted to take on real technical difficulties and carry a complete project from end to end, from the first line of code to real users relying on it every day for a month. Not a demo, not a tutorial followed line by line. Something that had to run on its own, including at 3 a.m. when a match ended with nobody watching.

Second: I love football, and a 48-team World Cup was the perfect excuse. A public schedule, scores coming in continuously, a convoluted competition format to model, and a small group of motivated players to test the app in real conditions.

The real timeline

Everything was written in the week before kickoff, outside my working hours. No testing phase: the first version went live, players signed up, and the tournament began. Everything that follows was therefore fixed and completed during the competition, based on what participants reported and on what I saw breaking live.

What that implies

Some decisions are deliberate choices, others are what I could make work in two hours on a weeknight with a match at 9 p.m. I separate the two in the last section.

The setting was constrained from the start, and that is exactly what made the exercise interesting:

  • Standard shared hosting, in PHP. No container, no long-running process, no application server to configure.
  • A single scheduled task slot at the host, to share with my other projects.
  • Zero budget for data. Every source had to be free, which means tight quotas and undocumented APIs.
  • Mostly mobile use, on the go: on the couch, ten minutes before kickoff.

A note on the screenshots on this page, to head off the question: two somewhat unusual accounts took part in the game. I had ChatGPT and Gemini predict all 104 matches and registered them as ordinary players, which explains why they appear in the standings below. The app treats them differently on one point only: prediction reminders explicitly exclude them, since they have no phone to ring.

An architecture without a database

The first trade-off was to do without a database entirely. The whole game state lives in JSON files placed in a /data folder protected by an .htaccess file: the schedule and scores in fixtures.json, accounts and their points in users.json, predictions in predictions.json, and so on for the chat, the poll and the pre-match statistics.

This choice holds up for a game with fourteen accounts: no migrations to manage, backups that come down to copying a folder, and above all the ability to open a file to see exactly what the app thinks it knows. It also has its limits, which I come back to below.

The mechanics rest on two separate triggers, and this is the most structural point of the architecture. Scores refresh when someone opens the app, which makes them immediate while costing nothing when nobody is looking. Prediction reminders, on the other hand, have to go out on time even if the app is deserted, and therefore depend on a scheduled task.

SCORES AND STANDINGS Page opened by anyone Live match? if not, 1× a day ESPN scores 30 s min, under lock fixtures.json 104 matches users.json points and ranks PREDICTION REMINDERS Scheduled task every 10 min Kickoff in 20 to 35 min Who has not predicted for this specific match Targeted push to them only Page opened by anyone Scheduled task every 10 min SCORES AND STANDINGS Live match? if not, 1× a day ESPN scores 30 s min, under lock fixtures.json 104 matches users.json points and ranks PREDICTION REMINDERS Kickoff in 20 to 35 min Who has not predicted yet Targeted push to them only

Two triggers, two chains. Scores follow the traffic, reminders follow the clock.

Fetching 104 matches from an undocumented API

Scores come from ESPN's public scoreboard, a free but unofficial API: no documentation, no stability guarantee, and an implicit limit of around a hundred events per response. With 104 matches scheduled, the first request was already truncating the tournament. The fix fits in one line: split the period into two calls, June 11 to July 5 and then July 6 to 19, and merge the results.

The real problem came right after. To record a score against the right match, you first need to know which match it is, and the identifiers did not always line up: some fixtures did not exist yet on ESPN's side when the local schedule was initialized, and team names arrived in English, with broken accents and variants from one day to the next.

The problem

No reliable matching key between the local schedule and the events returned by the API, especially for matches whose teams are not known yet.

What I did

A three-level cascade, from the most reliable to the most tolerant. Each level only runs if the previous one failed, and team names are normalized to a single French spelling before any comparison, which neutralizes the API's broken accents and variants.

1. ESPN ID direct match, the usual case otherwise 2. Names of both teams case and order ignored otherwise 3. ±2 h window fixtures still “To be determined” as soon as one level matches Match found score, status, clock, winner 1. ESPN ID direct match, the usual case 2. Names of both teams case and home/away order ignored 3. ±2 h window for fixtures still “To be determined” otherwise otherwise Match found score, status, clock, winner updated

Three matching levels. If none matches, the match is simply created.

The sync only rewrites the files if something has actually changed: a score, a status, a kickoff time, the clock of a match in progress. Without that precaution, each pass would have rewritten the entire schedule for nothing, on every call.

The standings move during the match

This is the feature that most changed the way the group used the app. A scheduled task alone would not have been enough: between two passes, the displayed score would always have lagged several minutes behind, which kills the point of watching a match with the standings open next to it.

The sync is therefore triggered by traffic. Every time a page opens, the app checks whether a match has started without finishing. If so, it queries the score source and updates the display within the same second. In other words, the score is fresh at the moment you look at it, rather than at the moment of the last automatic pass.

The problem

If every visit triggers a call, ten people refreshing during a France-Norway match trigger ten simultaneous ones. Enough to overload the source and rewrite the same file from ten processes at once.

What I did

Two complementary safeguards. A minimum interval of 30 seconds between two queries, whatever the number of visitors. And a non-blocking file lock: the first to arrive takes over and fetches the scores, the others do not wait and immediately display the latest known data. A lock stuck for more than fifteen seconds is considered stale and released.

The result, on the player's side: the match card shows the minute of play, the current score and a pulsing dot. Above all, a match in progress counts immediately in the points calculation, just like a finished match. A goal in the 70th minute moves the overall standings before the final whistle, and the app keeps each player's previous rank to show the movement. In practice, we could see live who took the lead on a goal and who lost it on the next one. The game was as gripping as the match.

Points are still counted separately depending on whether the match is finished or in progress, so that a score changing late in the game corrects the standings instead of adding to them. Outside match periods, the app makes do with one sync per day: there is no point querying a live score source on a Tuesday morning.

Rebuilding the knockout bracket

A 48-team tournament produces a knockout stage with seven rounds, from the round of 32 to the final, plus a third-place match. As long as the qualified teams are unknown, the API returns provisional fixtures, and it returned them badly: inconsistent pairings, teams placed in the wrong half of the bracket. Copying that data as is would have shown fanciful semi-finals for days.

So I stopped trusting the API on this specific point and rebuilt the bracket locally. The principle: stop reading the announced fixtures and derive them from the results instead. As soon as a match is over, its winner moves down to the next round automatically; until it is, the app shows an explicit fallback label such as “Winner France/Sweden” rather than an invented fixture. The third-place match follows the same logic, picking up the semi-final losers.

The bracket is thus always consistent with what actually happened on the pitch, and it fills up round by round as the competition goes on, with no intervention.

Knockout bracket on a phone, from the round of 32 to the final, displayed as a mirror

The full bracket fits on a phone screen, from the round of 32 to the final, mirrored. It is rebuilt from the results rather than from the fixtures announced by the API.

Scoring, standings and a poll

The scoring rewards precision in tiers: 4 points for an exact score, 3 points if the winner and the goal difference are right, 2 points for the right winner only, and nothing otherwise. Guessing the winner of each group earns 2 extra points. From the knockout stage onward, all points are doubled.

That doubling was not my decision. It completely changes the dynamics at the end of the tournament, since it lets a player who has fallen behind catch up everything over eight matches. Rather than decide alone, I built a poll into the app: each participant voted for a single or doubled multiplier, and the result applied to everyone. Six votes cast, four for doubling.

The standings are fully recalculated on every sync, starting from zero and going back through all predictions. It is deliberately dumb and costly, but the operation is idempotent: correcting a score in the admin interface is enough to put the whole standings back in order, with no catch-up script. Ties are broken by the number of exact scores, and the app keeps each player's previous rank to show the movements after each match.

Window explaining the game's points scoring

The scoring rules, shown at first login.

Overall standings of the game's fourteen accounts

The overall standings at the end of the tournament.

Once a match is locked, everyone sees everything: each player's prediction and the points it earned, sorted from best to worst result. It is by far the most viewed feature of the game.

Comparison of all players' predictions for the final, with the points earned

Predictions for the final, once the match was locked.

Odds and the betting simulation

I wanted to show odds on every match, and above all to offer a simulation of fictional bets: how much a player would have won or lost by staking money on their own predictions. For the figure to mean anything, it needed real odds.

The app therefore fetches the real odds of each match from an external source and stores them alongside the score, in the same file. Each match carries its three result odds: home win, draw, away win.

The rest is bookkeeping. For each finished match, the app rereads the player's prediction, deduces the winner they called, and replays the bet as if they had staked €10 on it at the real odds of the time. It then adds up stakes and winnings over the whole tournament to display a balance.

That figure says more than the standings. A player can perfectly well lead on points by always backing the favorites, while being well down in euros, because favorites pay little. Conversely, two or three bold predictions that come off are enough to turn the balance around. The standings reward consistency, the simulation rewards risk-taking, and the two do not crown the same winner.

The balance stays hidden until the player chooses to reveal it, to avoid permanently showing a sometimes cruel figure on the profile page.

Profile page: success statistics, points per match and betting simulation

The player profile, with the betting simulation balance hidden by default.

The pre-match sheet: four sources in one view

This is the part that took me the most work, and the one I find most polished. Before each match, the app gathers in a single sheet what actually helps to predict: the FIFA ranking of both nations, the market value and average age of the squad with their rank among the 48 teams, injured or suspended players, recent form, head-to-head history, group standings, TV broadcasters and the weather forecast at the stadium.

When the official line-ups come out, an hour before kickoff, the sheet shows both teams laid out on a pitch. The positions adapt to each team's tactical formation, from a 4-4-2 to a 4-2-3-1, with shirt numbers, the captain, the substitutes and the value of the starting eleven.

Pre-match sheet: FIFA ranking, squad value, average age, absent players and TV coverage

Pre-match data, aggregated from several sources. The two figures in badges place each team among the 48 in the tournament.

The Squads tab is the densest part. It does more than show a starting eleven: it fetches the full squad of both national teams, places the starters on the pitch in their real positions, puts a value on each side's starting eleven, and lists the substitutes with their number and position. A selector lets you switch from one team to the other for the bench.

Squads tab: both line-ups on a pitch, the value of each eleven, and the list of substitutes with number and position

The full Squads tab: both formations face to face with the value of each eleven, then the bench of the selected team, position by position.

The weather is a good example of the kind of detail that takes longer than expected. Stadiums are only identified by the name of their city, with labels that vary from one match to the next. The app therefore resolves that name into geographic coordinates, falling back on a free geocoding service when it does not recognize the label. Once the location is known, it fetches the hourly forecast and keeps the reading closest to kickoff, giving up if the gap exceeds six hours rather than showing an irrelevant forecast.

One caveat about the screenshots in this section, and it is a shame: two blocks appear empty. The weather at the stadium hides itself once the match has been played, and the recent form over the last five matches now only returns dashes. With the tournament over since July, there is no capturable trace left of them, whereas in real conditions these two blocks were among what we looked at most before a match.

Staying within free quotas

Line-ups, injuries and squad values come from a second source, whose free plan is capped at fifty requests per day. On a day with four matches, two teams each, the budget goes fast. Several safeguards combine so that it is never exceeded.

  • A narrow sync window. Squad data is only refreshed in the twelve hours before kickoff, once per team and per match. Outside that window, no call is made.
  • Reading the remaining quota. Each response returns the number of available requests in its headers. The app stores it and refuses to call again when it approaches zero.
  • Key rotation. Two keys are configured. As soon as a response comes back over quota, the app switches to the next one and retries immediately, with no intervention.
  • Timestamping before the call. The date of the last sync is written before sending the request. If the API does not respond, or if the match cannot be found, the app will not retry in a loop on every visit.
  • A disk cache. Detailed match responses are kept as they are, which avoids querying the source again to display a sheet that has already been viewed.

The mechanism held for the whole tournament without my touching the configuration.

Notifications that only wake the right people

A predictions game lives or dies on one detail: do players remember to submit before kickoff. A generic notification sent to everyone before each match would have been unbearable after three days, with four matches a day in the group stage.

The reminder is therefore calculated. On each pass, the scheduled task spots the matches starting in twenty to thirty-five minutes, lists the players who have not yet entered a prediction for that specific match, and sends the notification only to them, by name, through their account ID. A player who is up to date never receives anything. The message names the fixture concerned and links straight to the right section of the app.

A guard prevents duplicates: the match ID is archived as soon as the first notification goes out, which stops a second pass of the scheduled task in the same window from pinging everyone again. Over the whole tournament, 476 reminders were sent for 92 matches.

A single scheduled slot for several projects

The hosting only offered a single scheduled task, already used by other projects. The pick'em therefore did not get its own slot: its jobs are chained after the others, from a single entry point called every ten minutes.

This setup has a weakness I discovered in production: as long as the links are chained in the same script, a fatal error in the middle takes down everything after it. A failure in an unrelated job was enough to deprive players of their reminders, without the slightest signal. Each link ended up isolated so it fails on its own, and the execution log serves as proof that the chain runs: a little over ten thousand passes over the duration of the tournament, ten minutes apart.

An app, not a website

Since real use was mobile, the app can be installed on the home screen: manifest, service worker, install banner offered at the right moment. Once on the phone, it opens full screen, without an address bar, and receives notifications like a native app.

The interface changes logic depending on screen size. On desktop, everything is visible at once, in two columns. On a phone, a bottom navigation bar with five entries takes over and windows slide up from the bottom of the screen. One detail worth knowing: the service worker deliberately lets requests to other domains through without intercepting them, to work around an iOS browser bug that made the flags fail to load.

Predictions screen on a phone
Today's predictions
Pre-match sheet on a phone
Pre-match sheet
Squads and substitutes on a phone
Squads and bench
Knockout bracket on a phone
Knockout bracket
Overall standings on a phone
Overall standings
Group chat on a phone
Group chat

The same features, rearranged for the thumb. Scroll the strip horizontally.

The rest is about group life: a built-in chat with an unread message counter and moderation for the administrator, customizable avatars from a cropped photo or a silhouette with the country's flag, an editable username and password recovery through a secret question. Passwords and secret answers are hashed with the algorithm recommended by PHP.

What I would do differently

The project reached its goal, which does not stop me from being clear-eyed about its weaknesses. One week of development before kickoff, with no testing phase and outside working hours, shows in places.

  • The JSON files should have become an SQLite database. Flat-file storage held up very well with fourteen accounts, but nothing protects two simultaneous writes to the same file. With ten times more players, the risk of losing a prediction becomes real. SQLite would have taken the same effort and settled the question.
  • Avatars have no business in the accounts file. Photos are stored encoded as text, directly in the file read on every page view. Image files on disk would have been lighter and simpler to serve.
  • The main file has become a monolith. Eleven thousand lines mixing rendering, game logic and the entry points called in the background. It works, it is hard to read, and each addition calls for more caution than the last.
  • API keys sit in plain text in a versioned configuration file. Acceptable for a game among friends on a private server, to be banned as soon as the project changes scale.

These four points share one trait: none was a problem at the scale of the game, and all would have become one at the next scale. Identifying them by living through them rather than reading about them is exactly what I was after when I set up this project.

Frequently asked questions

Why PHP and not a modern framework?
Because the starting constraint was shared hosting, with no permanent process or container. PHP runs there without installing anything, the scheduled task is provided, and the cost stays at zero. A server-rendering framework would have required different hosting for a debatable benefit on a project of this size.
Are players' data visible on this page?
No. The screenshots come from the real app with its real data, but participants' usernames and avatars were blurred before publication, including inside the group chat messages. Only scores, points and standings remain readable, and they identify no one.
Were the odds shown real?
Yes, they are fetched from an external source and stored with the match. They only feed a simulation of fictional bets: no real money was ever involved, and the game never had any stakes.
How long did development take?
One week before kickoff, outside my working hours, with no testing phase at all. The first version went live as it was. Everything else, the line-ups on a pitch, the complete pre-match sheet, the API key rotation, arrived during the tournament, in response to problems met in real conditions and to participants' feedback.
Is the game still accessible?
The app is still running, but it remains private: access requires an account, and the tournament has been over since July 2026. The screenshots on this page were taken afterwards, which explains why some blocks reserved for the pre-match phase appear empty.

A slightly tricky project to get off the ground?

Start a project →