A small, private World Cup prediction game for a group of friends. Predict the knockout-stage matches of the 2026 World Cup, earn points for how close you get, and climb a shared leaderboard. It's just for bragging rights — no money, no stakes, only eternal glory (or until everyone forgets about it two days after the final).
- Per-match predictions — call the score of each upcoming knockout match, and pick how it's decided: 90 minutes, extra time, or penalties. Scores are validated (and a draw must go to penalties), and a saved pick locks the card until you hit Change prediction.
- Once-a-day Confidence 2× — double the points on the one match you feel surest about each day.
- Predictions lock at kickoff — automatically, and enforced on the server so nobody can sneak a late guess in.
- Shared leaderboard — total points per player, with ties broken by whose predicted scores landed closest to the real results.
- Automatic scoring — when a knockout match ends, real results are pulled in and everyone's predictions are scored within a few minutes. Nothing to refresh, no scores typed in by hand.
- Knockout bracket — a "Road to the Final" overview of the whole knockout tree that updates after every result; hover or tap any tie to trace where its teams came from and where they go next.
- Prediction recap — a "What did we predict?" page that lines up everyone's picks under each finished result, with a bit of playful ribbing for anyone who forgot to predict.
- Match reactions & Fun Stats — react to finished matches ("VAR robbed me", "Called it", "Devastated"…) and earn playful awards: biggest upset callers, shootout psychics, the most delusional predictors, and a reaction leaderboard.
- Hot takes — add a one-line comment to any prediction.
- Tidy your view — colour-coded match cards (upcoming, live, finished) and a one-click toggle to hide finished matches once the list gets long.
- Group-stage results — past group games are shown for reference (they aren't predicted).
- Profiles — pick a display name and a cartoon animal avatar.
- Magic-link login — sign in with a link sent to your email, no password to remember.
![]() Magic-link login |
![]() Predicting matches |
![]() Your profile |
- How it works
- Run it locally
- Tests
- Deploy to production (Vercel)
- Using a different competition
- License
- Match data comes from a free public football API
(football-data.org, competition code
WC). A scheduled job polls it and writes results back. - Only knockout matches are predictable; group games are stored for display.
- Predictions lock at kickoff — enforced by the database (Row Level Security), not just the UI. When a knockout match finishes, the sync job runs the scoring function and points appear on the scoreboard.
- Scoring is a single Postgres function (
calculate_match_scores); the tiers are documented insupabase/schema.sql.
Stack: SvelteKit (Svelte 5) · Supabase (Postgres + Auth + Row Level Security) · football-data.org for fixtures and results · deployed on Vercel with a GitHub Actions cron for score syncing.
Want to try it on your own machine? Here's the full path from zero to a running app. You'll need free accounts on two services (Supabase and football-data.org), both covered below.
You need Node.js 18 or newer (works on 22). Then, from the project folder:
npm install
cp .env.example .env # creates your local settings file — you'll fill it in belowSupabase is the free database + login service the app runs on.
- Create a project. From Project Settings → API, copy the project URL, the publishable/anon key, and the secret/service-role key.
- In the SQL Editor, run the whole of
supabase/schema.sql. This creates the tables, security rules, the scoring function, and the stats views. - Go to Authentication → Providers and enable Email. Then under URL
Configuration, set the Site URL and add
<site>/auth/callbackas a redirect URL (http://localhost:5173/auth/callbackfor local development). - Create your league and pick an invite code to share with friends:
insert into leagues (name, invite_code, created_by) values ('Our World Cup League', 'YOUR-INVITE-CODE', null);
Open the .env file you created earlier and fill in these values (never commit
this file):
| Variable | Where it comes from |
|---|---|
PUBLIC_SUPABASE_URL |
Supabase → Project Settings → API |
PUBLIC_SUPABASE_ANON_KEY |
Supabase (publishable/anon key) |
SUPABASE_SERVICE_ROLE_KEY |
Supabase (secret key — server-side only) |
FOOTBALL_DATA_TOKEN |
football-data.org dashboard (free token) |
CRON_SECRET |
any long random string you choose |
npm run seed # pulls all World Cup matches into the database (re-run to refresh the bracket)
npm run devOpen the local URL it prints (usually http://localhost:5173), log in with a
magic link, enter your invite code on the /join screen, and you're in.
npm test # API-parsing unit tests (Vitest)
node --env-file=.env scripts/verify-scoring.js # checks every scoring tier against the real DB functionOnce it runs locally, here's how to put it online so your friends can use it.
- Push the project to GitHub and import the repo at vercel.com/new.
- Add the same variables from your
.envfile as Vercel environment variables. - Deploy, then add your new Vercel URL to Supabase's Site URL + redirect URLs (same place as step 3 of the local setup).
- Score syncing runs via
.github/workflows/sync-scores.ymlevery 15 minutes. Add two GitHub Actions secrets —CRON_SECRETandSYNC_URL(https://<your-domain>/api/cron-sync-scores). The workflow reads both from secrets, so no deployment URL is committed to the repo.
Note: the Vercel adapter is pinned to
nodejs22.x.
To target a tournament other than the 2026 World Cup, change the football-data
competition code (WC) in
scripts/seed-matches.js and
src/routes/api/cron-sync-scores/+server.js.
MIT — do whatever you like.


