project
World Cup 2026 prediction pool for groups of friends
App where groups of friends predict World Cup scores, with predictions that lock at kickoff and results that come in on their own.
context
A World Cup pool among friends almost always lives in two places: a WhatsApp group and a spreadsheet someone keeps by hand. It works, but it has the usual problems. Someone has to type every result and redo the ranking math. Everyone's guesses are in plain sight, so they can be copied. And there is always the argument about who sent a score before or after the game started.
I wanted to solve that with an app that runs itself, and I used it to build something end to end. Two constraints drove the decisions: run it for free, with no paid API and no server of my own, and use a login a friend will not abandon halfway. Passwords and sign-up forms push people away; signing in with Google is one tap.
The core idea is simple: you create or join a group through a link, predict the scores, and the ranking updates on its own as the games happen.
demo: "https://bolao-da-copa-tau.vercel.app/" repo: "https://github.com/andreldss/bolao-da-copa"
what was built
Groups
- Group creation by any user, with an invite link
- Joining through the link or by typing the code on the home page
- Open or closed group, controlled by the owner
- Leaving a group (for non-owners) and deleting a group (for the owner)
- Limit of five groups per person
Predictions
- One score prediction per person, per match, inside each group
- Locks at the match kickoff time
- Other people's predictions visible only after the match starts
- Copying every open prediction from one group to another at once
Scoring and ranking
- Exact score is worth 3 points, getting only the result right is worth 1
- Ranking per group, computed from finished matches
- Member list and ranking on the group page itself
Matches and results
- Matches imported from a public source (openfootball), with no API key
- Results updated automatically by a scheduled job
- Matches split into open and finished, with the official score
Access
- Google sign-in, session in an HTTP-only cookie
- Installable on the phone as a PWA
technical decisions
Server as the only access to group data
The groups, members and predictions tables are not read or written directly from the browser. Every access goes through API routes that check who the user is before any operation. I tried solving this with RLS in Postgres first, but a "is a member of the group" rule references itself and runs into recursion. Centralizing the check on the server is simpler to read and does not have that problem. The interface hides what the user cannot do, but the decision lives in the backend.
Prediction lock on the server
A prediction is only accepted if the match has not started yet, and that check runs on the server, comparing the kickoff time with the current time. The interface also hides the field after kickoff, but that is only convenience. The real lock is the backend one, because anything in the browser can be bypassed.
Other people's predictions hidden until the match starts
The read rule returns someone else's prediction only if that match has already started. Before that, each person sees only their own. Without it, you could just open the screen to see what everyone guessed and copy it. The fairness of the pool depends on this rule being on the server, not on the screen.
Results from a scheduled job, with no paid API
Matches and scores come from openfootball's worldcup.json, which is open. A route downloads that file and inserts or updates the matches. To avoid running it by hand, a GitHub Action calls this route every 30 minutes, authenticated with a shared secret. I used GitHub Actions because Vercel's free cron only runs once a day, too little for a day with several matches. Nobody types in results.
Idempotent import keyed by a stable id
Each match has a stable identifier, and the import upserts by that key. Running it again never duplicates anything, it only updates what changed (the score, or the team decided after the group stage). Without a stable key, re-importing would create duplicate matches.
Google sign-in with an HTTP-only cookie
The session lives in an HTTP-only cookie, refreshed in the middleware on each request, and never touches the frontend JavaScript. I chose Google over email and password because the audience is a group of friends, and a password sign-up is where people give up.
screenshots

Home with the user's pools and the form to create or join by code

Predictions screen, with the kickoff lock and tabs for open and finished matches

Group participants
what I learned
The two rules that make the pool fair, locking the prediction at kickoff and hiding other people's predictions, are only real because they live on the server. Their version in the interface is just visual comfort. Anything that depends on integrity cannot live in the browser.
A cost constraint became a design decision, not an obstacle. Vercel's free cron running once a day led me to the GitHub Action; not having a budget for a football API led me to openfootball. Both choices ended up simple and good enough.
What cost me the most time was not a feature, it was an old service worker serving stale content from cache. Caching is easy to turn on and hard to debug. Next time I think twice before caching, and I register the service worker only where it needs to exist.