⚽ World Cup Pool
πŸ‡ΊπŸ‡Έ πŸ‡²πŸ‡½ πŸ‡¨πŸ‡¦

2026 FIFA World Cup Pool

USA Β· Mexico Β· Canada

June 11 – July 19, 2026

Architecture

A deep-dive into how the World Cup Pool is built, from the request path down to the database, plus the CI/CD topology and the domain model. Written for engineers extending or operating the system.

Executive summary

The World Cup Pool is a self-serve bracket competition for the 2026 FIFA World Cup. Friends create a pool, share a 6-character code, and each enters their predictions; a live leaderboard scores everyone as real results come in β€” no accounts, no passwords. It runs on fully managed infrastructure (Next.js on Vercel, Postgres on Neon), with privacy-friendly analytics, a daily automated database backup, and an end-to-end test suite that gates every deploy. An admin enters tournament results in a few clicks β€” or pulls them automatically from a live football-results API and confirms each change before it is applied.

Framework
Next.js 16 (App Router)
Runtime
React 19 Β· Node
Data
Prisma 6 Β· Neon Postgres
Host
Vercel β€” CI Β· CDN Β· Analytics

Operations

Visitor analytics

Vercel Web Analytics records page views via <Analytics /> in the root layout β€” privacy-friendly, no cookies.

Daily automatic backup

A scheduled GitHub Action runs pg_dump every day, storing each snapshot as a 90-day downloadable artifact.

End-to-end tests guard every deploy

Playwright runs the full create β†’ pick β†’ leaderboard flow against a real Postgres in CI on every push, and the unit suite gates the production build β€” a failing test blocks the deploy.

1 Β· Runtime request path

Every page is a React Server Component that reads through a single Prisma client into Neon. Interactive surfaces hydrate as client components and mutate state through /api route handlers.

Browser β€” React 19 clientClient components: HomeClient Β· PicksClient Β· AdminClient Β· Navigation Β· ThemeToggleState: useState/useMemo bracket model Β· wcpool_pid cookie (httpOnly)Next.js 16 App Router Β· Vercel (Node runtime)Server Components render pages (dynamic = force-dynamic)Route Handlers /api/* β€” pools Β· join Β· picks Β· admin/resultslib/session.ts reads/writes the player cookiePrisma 6 Client (global singleton)Type-safe queries Β· generated at build Β· pooled connectionsNeon Postgres (serverless)Tables: Pool Β· Player Β· Pick Β· TeamDATABASE_URL (pooled) Β· DATABASE_URL_UNPOOLED (migrations)HTTPS Β· fetch() / navigationprisma.* querySQL over TCP

2 Β· CI/CD & deployment topology

main is branch-protected; all work lands via PRs. Each push builds on Vercel, and the build is gated by the unit-test suite before Next.js compiles.

Developerfeature branchlocal: npm run buildGitHubmain (protected)PR + checksVercel Buildprisma generatevitest run (gate)next buildProductionCDN + Node fns→ Neon Postgres

3 Β· Data model

Four entities. Team is reference data seeded from data/worldcup2026.ts; the admin mutates only its result columns. A Pick is the join between a player and a team for a given round.

1None-to-many1None-to-many1None-to-manyPoolid (PK)joinCode (unique)lockedPlayerid (PK)poolId (FK)displayNamePickid (PK)playerId (FK)teamCode (FK)roundTeamcode (PK)reachedRoundwonGroup
Pool
A shared bracket competition
idcuid, PK
namestring
joinCodestring, unique (6-char)
lockedboolean β€” freezes picks
createdAtdatetime
↔ 1 β†’ N Player
Player
A person in one pool (no auth)
idcuid, PK β€” stored in cookie
displayNamestring
poolIdFK β†’ Pool (cascade)
joinedAtdatetime
@@unique(poolId, displayName)
↔ N β†’ 1 Pool↔ 1 β†’ N Pick
Pick
Player predicts Team reaches Round
idcuid, PK
playerIdFK β†’ Player (cascade)
teamCodeFK β†’ Team
roundGROUP|FINAL4|SEMIFINAL|WINNER
groupIdstring, nullable β€” only for GROUP
@@unique(playerId, round, groupId, teamCode)
↔ N β†’ 1 Player↔ N β†’ 1 Team
Team
Seeded reference data (48 rows)
codestring, PK (e.g. BRA)
namestring
group"A".."L"
reachedRoundstring, nullable β€” set by admin
wonGroupboolean β€” set by admin
isChampionboolean β€” set by admin
↔ 1 β†’ N Pick

4 Β· Key flows

Create / join a pool

  1. HomeClient POSTs to /api/pools or /api/pools/[code]/join.
  2. Route handler creates the Pool/Player and calls setPlayerIdCookie().
  3. wcpool_pid (httpOnly, 90-day) is written; client routes to /pools/[code].

Make / edit picks

  1. PicksClient builds the full pick set client-side with progressive reveal.
  2. Save POSTs the entire set to /api/pools/[code]/picks.
  3. Handler validates round counts, then $transaction([deleteMany, createMany]) β€” an atomic replace.
  4. Reset = POST an empty array β†’ deletes all picks.

Admin enters results

  1. AdminClient submits to /api/admin/results with the ADMIN_TOKEN.
  2. Handler verifies the token server-side, then updates Team result columns.
  3. Locking a pool flips Pool.locked; the picks API then rejects writes.

Auto-fetch results (optional)

  1. Admin clicks Fetch latest results; AdminClient POSTs to /api/admin/fetch-results (token-gated, needs FOOTBALL_API_KEY).
  2. Server pulls standings + matches from football-data.org; lib/results.ts maps teams and derives flags β€” group winners only once a group is finished, plus Final-4, finalists, champion.
  3. The endpoint returns PROPOSED changes and writes nothing.
  4. Admin reviews the diff and confirms; changes save via /api/admin/results.

Leaderboard

  1. Server component loads players + picks + teams in one pass.
  2. scoreAllPicks() computes per-round and total points purely in memory.
  3. Rows sort by total desc, then alphabetically by name as a tie-break.

5 Β· Scoring engine

lib/scoring.ts is pure and deterministic β€” no I/O β€” which makes it trivial to unit test. Knockout rounds are cumulative: a team that advances further also satisfies the earlier rounds (a finalist still earns its Final-4 points; the champion earns all three). Correctness is round-specific:

RoundCorrect when…PtsΓ—Max
GROUPteam.wonGroup && team.group === pick.groupId11212
FINAL4team reached β‰₯ Final 4 (cumulative)4416
SEMIFINALteam reached β‰₯ Semi-Final (cumulative)8216
WINNERteam.isChampion16116
Maximum achievable60

6 Β· Win probability (leaderboard)

The leaderboard's Win %column is each player's estimated chance of finishing 1st, from a Monte-Carlo simulation of the rest of the tournament. It appears only once a live knockout bracket is available.

  1. Bracket β€” lib/tournament-bracket.ts pulls the knockout matches from football-data.org (FOOTBALL_API_KEY), resolves each team to our codes, and builds the current bracket: the earliest undecided round is the frontier; winners of adjacent matches meet in the next round up to the final. Finished semifinals / final lock in milestones for already-eliminated teams.
  2. Match model β€” data/ratings.ts holds a FIFA/Elo rating per team; eloWinProb converts a rating gap into a single-match win probability (logistic on the difference β€” a ~100-point edge β‰ˆ 64%).
  3. Simulate β€” lib/win-probability.ts plays the undecided matches ~20,000 times. Each trial yields a full set of team results, fed through the same scoreAllPicks the leaderboard uses, then the players are ranked (a tie for 1st splits the credit).
  4. Win % β€” the share of trials each player finished 1st. Computed server-side and cached (~10 min result, ~15 min bracket fetch) so a page view never re-runs the simulation or re-hits the API. An already-decided bracket skips the randomness β€” one deterministic pass.

Pure and unit-tested: the engine and bracket parser are covered by fixtures, including a frozen real-API snapshot. With no FOOTBALL_API_KEYor no knockout data, the column simply hides.

7 Β· Security & sessions

  • No passwords. Identity = the wcpool_pid cookie (httpOnly, SameSite=Lax).
  • The cookie alone grants nothing: every API route verifies the player belongs to the pool named in the URL.
  • Admin writes are gated by a server-checked ADMIN_TOKEN env var; the page UI never trusts the client.
  • Viewing another player's picks reuses PicksClient in a locked (read-only) mode.
  • Picks close when the admin locks the pool or the global deadline in lib/lock.ts passes (end of Jun 10, 2026) β€” enforced in both the picks API and UI.

8 Β· Connection & build notes

  • DATABASE_URL is the pooled (PgBouncer) Neon URL for app queries.
  • DATABASE_URL_UNPOOLED (directUrl) is used for migrations.
  • Prisma is a global singleton to survive serverless function reuse and avoid connection storms.
  • Pages use export const dynamic = "force-dynamic" so picks/leaderboards are never statically cached.
  • Build: prisma generate β†’ vitest run β†’ next build β€” a failing test blocks the deploy.
  • Vercel Web Analytics via <Analytics /> (@vercel/analytics) mounted in the root layout β€” privacy-friendly page-view metrics.

9 Β· Backups & durability

Pools, players, and picks exist only in Neon β€” the seed Team rows regenerate from data/worldcup2026.ts, but user data does not. After picks lock on Jun 10 that data is effectively frozen, so a snapshot at lock protects nearly everything.

On-demand (local)

  • npm run db:backup β†’ timestamped, gzipped pg_dump in backups/ (git-ignored).
  • npm run db:restore -- <file> restores (prompts first; dumps use --clean --if-exists).
  • scripts/db-url.sh resolves the direct/unpooled URL β€” best for pg_dump.

Automated (GitHub Actions)

  • .github/workflows/backup.yml runs pg_dump daily + on demand.
  • Each dump is uploaded as a 90-day workflow artifact.
  • Needs a DATABASE_URL_UNPOOLED repo secret to run.
  • Neon point-in-time restore is a short-window safety net; these dumps are the durable copy.

10 Β· Repository map

app/
  layout.tsx                       root layout Β· mounts <Analytics/>
  page.tsx Β· HomeClient.tsx        create / join a pool
  pools/[code]/
    page.tsx                       pool dashboard
    picks/  page.tsx Β· PicksClient  progressive bracket picker
    leaderboard/page.tsx           scored standings + win % (server-computed)
  how-it-works/ Β· architecture/    static reference pages
  admin/  page.tsx Β· AdminClient    results entry (token-gated)
  api/
    pools/route.ts                 create pool
    pools/[code]/join/route.ts     join pool
    pools/[code]/picks/route.ts    atomic replace of a player's picks
    admin/results/route.ts         set team results / lock pool
    admin/fetch-results/route.ts   pull live results from football-data.org
components/   Navigation (client Β· active-link) Β· HeroBanner Β· ThemeToggle
lib/          db.ts (Prisma singleton) Β· session.ts (cookie)
              scoring.ts (pure, cumulative) Β· lock.ts (pick deadline)
              results.ts (map providers + derive fetched results)
              win-probability.ts Β· tournament-bracket.ts (Elo Monte-Carlo)
data/         worldcup2026.ts (48 teams, rounds, points) Β· ratings.ts (Elo)
prisma/       schema.prisma Β· seed.ts
scripts/      backup-db.sh Β· restore-db.sh Β· db-url.sh
.github/      workflows/backup.yml (scheduled pg_dump)
__tests__/    vitest unit + component tests
e2e/          playwright smoke tests