
World Cup Fantasy Dashboard
A decision tool for the official FIFA World Cup 2026 Fantasy game, built during the tournament and used to play it. There is no public API for that game, so it runs on the undocumented JSON its own web client reads, plus a cookie-gated endpoint for my own squad.
Stack: Next.js 16 (App Router), React 19, TypeScript, Tailwind v4, Neon Postgres via Drizzle ORM, Vitest, Vercel Cron plus a GitHub Actions scheduler.
Why I built it
Due to my love of football, I have participated in many football fantasy games. One thing they always lack is a centralised platform to analyse, scout, and plan future gameweeks. This World Cup, my friends and I created a league for the World Cup Fantasy game with forfeits on the line, which prompted me to create the centralised system described above.
How I built it
As with previous projects I wrote the specs first, brainstorming each feature with Claude until the behaviour was pinned, down in docs/spec.md, docs/architecture.md and docs/game-rules.md, then had Claude Code implement against them.
The rules went in before any feature: scoring, squad composition and transfer allocations became pure functions with tests around them, because they are the only part of the system knowable up front. Everything downstream depends on feeds that can change without notice. docs/game-rules.md was transcribed from the game's own help endpoint, play.fifa.com/json/fantasy/help_pages.json, rather than from a wiki.
The provider layer: public and cookie-gated feeds
Every external call sits behind FantasyProvider or StatsProvider. Nothing else in the codebase fetches, which keeps the fragile parts in one file each.
Public static JSON (players.json, rounds.json, squads.json) supplies prices, ownership, fixtures and points.
A cookie-gated Genius Sports API at play.fifa.com/api/en/fantasy supplies anything about my own team. There is no OAuth and no public team view, so linking an account means pasting a fifa_session cookie into /link. It is stored server side and never returned to the client, and personal requests use no-store so a cache entry cannot be shared between users. An expired session degrades to estimated scores with a re-link prompt.
The feed also changes shape mid-tournament. roundPoints is an empty array before any points exist and becomes an object keyed by round id once scoring starts, so the provider normalises both forms into a dense array.
Deriving what the feeds do not carry
Fixture difficulty needs a team rating and the feeds have none. lib/providers/fifaPlayStats.ts builds one from FIFA world ranking points for level and best-eleven market value for the balance between attack and defence, blended 60/40 toward the ranking. Squads list between 26 and 55 players, so the value side counts only the top six attackers and top five defenders; summing a whole squad would measure roster size rather than quality.
Difficulty is then position aware. Goalkeepers and defenders are scored against the opponent's attack, forwards against the opponent's defence, midfielders on a blend of the two. Home advantage is fixed at zero because World Cup venues are neutral.
The transfer planner
evaluatePlan() replays an entire plan forward over a working squad, so a change in an early window surfaces its consequences in a later one. The rules compound: free transfers vary by window, exactly one group-stage transfer carries over, each extra transfer costs three points, the budget rises from $100m to $105m at the round of 32, and the per-country cap loosens from three players to eight.
This mattered most in the knockouts. A World Cup is far shorter than a Premier League fantasy season, and elimination makes a player worthless rather than merely out of form, so redrafting once results were confirmed was worth more than incremental transfers.
Demo mode
The unofficial feeds were seasonal and now return []. lib/demo/ serves the real 2026 tournament frozen at a real moment: Sunday 5 July 2026, during England v Mexico in the Round of 16, with four rounds complete and the fifth in progress. All 48 nations are present. It activates on DEMO_MODE=1, or whenever DATABASE_URL is unset, so a fresh clone runs with nothing configured.
Final thoughts
This guided me to second place in my league and top 5% of all players. The process I employed worked smoothly, and I'll keep polishing features going forward to mark this project as complete.
What's in the repo
lib/scoring.ts: scoring table, differential bonus, position-aware fixture difficultylib/transferRules.ts: plan evaluator covering allowances, carry-over, hits, budget and country capslib/squadRules.ts: squad composition and validationlib/providers/fifaFantasy.ts: public feeds and cookie-gated endpointslib/providers/fifaPlayStats.ts: derived team strengthlib/demo/: the frozen tournamentlib/panels/: the read-only panels and their shared contractcomponents/PlannerBoard.tsx,components/FixtureMatrix.tsx: planner and difficulty gridapp/api/cron/refresh/route.ts: the one path that fetches externally and writesdocs/: spec, architecture, design system, game rules