I wanted to build something more useful than another fixture list.
worldcupp26.com combines all 48 teams and 104 matches with live scores, detailed match pages, interactive squads, a full-tournament simulator, title and Golden Boot probabilities, market comparisons, installable PWA behavior, and opt-in match alerts.
I spent most of the build on the systems underneath those screens: turning several inconsistent sports-data sources into one versioned data model, making an engineered football model fast enough to run 100,000 tournaments, resolving the new 48-team bracket correctly, and keeping the public app mostly static without making it feel stale.
The architecture in one view
The core application is a React 19 and TypeScript single-page app built with
Vite. Cloudflare Pages serves the compiled assets, while a small hash router
handles routes such as #/team/ESP, #/match/104, and #/sim.
The public app gets most of its data from static files:
ESPN + EA ratings + squad sources + Kalshi
│
▼
repeatable Node.js pipelines
│
┌─────────┴─────────┐
▼ ▼
versioned JSON model snapshots
matches, squads, Elo, match odds,
events, leaders title + Golden Boot
└─────────┬─────────┘
▼
Vite content chunks
│
▼
React SPA on Cloudflare Pages
▲
│
live ESPN overlay between builds
Optional user features: Firebase Auth + Firestore + FCM
I serve historical match detail, squads, model outputs, and market snapshots from my own origin, where CDN caching works well. A lightweight browser-side ESPN overlay fills the gap while a match is live. I use Firebase only for features that need identity or server execution, such as saved notification preferences and push delivery.
Treating committed JSON as a content database
I keep one canonical schedule, one group definition, and one file per team in the repository. Separate generated artifacts hold ESPN match context, player totals, leaderboards, Elo ratings, per-match probabilities, odds history, and market prices.
Committing those files makes every production data change reviewable. Score corrections and rebuilt model artifacts show up as diffs. I can trace a bad refresh to the exact commit that produced it, and the app does not need a general-purpose API server to deliver public tournament data.
The ESPN ingestion pipeline
The durable refresh job walks the entire schedule rather than only fetching “today.” For each fixture it:
- Resolves real teams from group positions and knockout placeholders.
- Queries ESPN scoreboards around the scheduled date, including the following UTC day for late North American kickoffs.
- Matches events by the unordered team pair and time window, then corrects the orientation if ESPN considers the opposite side “home.”
- Pulls the event summary and keeps the useful fields: status, score, shootout, venue, officials, attendance, formations, starting XIs, benches, team statistics, important events, recent form, head-to-head history, and commentary.
- Converts American moneylines into implied probabilities and divides by the total to remove the bookmaker margin.
- Rebuilds per-player tournament totals and derives the scorer and assist leaderboards.
- Copies final scores into the canonical schedule, then rebuilds the model artifacts that depend on those results.
I accumulate finished-match player totals only from completed summaries. Persisting a partial live total and then adding the final total later would double count the same performance.
The browser handles the interval between durable refreshes. It considers fixtures that have kicked off or are within five minutes of kickoff, but only if they still lack a curated result. It polls every 60 seconds while something is live and every 180 seconds otherwise, refreshes immediately when the tab returns to the foreground, and stops polling when nothing is subscribed.
Matching external events is tedious and easy to get wrong. I normalize and alias team names, require the team pair to match, and keep kickoff times within a bounded window. Persisted scores always take precedence; the live overlay can fill a missing score or shootout, but it cannot overwrite the reviewed record.

A match page joins the static schedule, saved ESPN context, model output, bookmaker probabilities, and the live overlay into one view.
One refresh command, with guardrails
The operational path is deliberately boring:
ESPN backfill
→ canonical scores and player totals
→ Elo rebuild
→ 100,000-run match probabilities
→ title and Golden Boot snapshots
→ Kalshi snapshot (best effort)
→ schema validation
→ independent goalkeeper-save audit
→ production build
→ data-only commit and push
→ Cloudflare Pages upload
The refresh refuses to run when unrelated files are dirty, produces no commit or deployment when the data did not change, and commits only the generated data directory. A Kalshi API failure does not stop the refresh. The last good market snapshot remains in place while results and model data continue to update.
Cloudflare Pages is not Git-integrated for this project. The deployment command
uploads the local dist directory with Wrangler, so the refresh explicitly
builds after the data changes and deploys the exact resulting artifact.
From teams to expected goals
The prediction stack combines team ratings with player strength. I wrote the adjustments directly, which makes each one visible and debuggable.
A time-aware Elo state
The Elo rebuild begins with historical international results. Its update is the familiar expected-versus-actual form:
expected = 1 / (1 + 10 ^ (-ratingDifference / 400))
delta =
K
× marginMultiplier
× (actual - expected)
The margin multiplier increases the effect of multi-goal wins while reducing the boost when a large rating gap already made the result likely. Historical matches use competition-sensitive K factors; the tournament simulation uses 40 for group matches and 50 for knockouts.
For the 48 World Cup teams, the rebuild resets the active pre-tournament ratings to a FIFA ranking-points snapshot. Historical Elo is the fallback when a team is missing from that snapshot and also supplies the recent scoring environment used by the xG layer. The FIFA baseline is then advanced with tournament results.
Every simulated tournament receives its own Elo state. After each real or simulated match, that state is updated before the next fixture. A team that survives a difficult path therefore enters the next round with a different rating than it had at kickoff on matchday one.
The model adds 100 host points only when Mexico, Canada, or the United States plays in a venue inside its own country. “Home” in the schedule alone is not enough; the venue-country mapping decides it.
Projected XIs and depth
Elo supplies the team baseline, but it cannot see that an unavailable player changes the lineup.
The squad layer projects a starting XI into the coach’s preferred formation. Each formation has positional slots, and each player receives a slot score:
slot score =
player rating
- (1 - position affinity) × 20
A global greedy assignment repeatedly selects the best remaining player-to-slot pairing, with goalkeeper treated as a hard constraint. The starting XI provides most of the team value, while the five strongest relevant bench players add a smaller depth contribution.
The resulting strength object has overall, attack, midfield, and defense values. Availability records can remove an injured or suspended player before the lineup is selected; the projected XI and strength values then change with no separate model rule. Confirmed ESPN lineups are shown on match pages, but the forecast layer currently uses the projected XI so scheduled simulations remain consistent before official lineups exist.

The team screen shows the formation and squad data that feeds the player-strength layer of the model.
Converting strength into xG
Both the quick Monte Carlo engine and the detailed match simulator call the
same xgPair function.
First, lineup strength creates attack-versus-defense and midfield adjustments. The model combines those with current Elo and any venue-specific host adjustment:
phaseHome =
home.attack - away.defense
+ 0.35 × (home.midfield - away.midfield)
playerAdjHome =
7 × (home.overall - 76)
+ 5 × phaseHome
ratingDifference =
homeElo - awayElo
+ hostAdjustment
+ playerAdjHome - playerAdjAway
homeGoalShare =
1 / (1 + 10 ^ (-ratingDifference / 650))
The total expected goals start with the recent international scoring environment. A bounded attack-quality term and a mismatch term can move that total, after which it is split by goal share. Each side is clamped between 0.15 and 3.4 xG to keep extreme inputs from producing absurd score distributions.
One shared function gives the instant preview, detailed simulation, per-match forecasts, and full-tournament odds the same definition of team strength.
Sampling football matches
The fast engine draws each team’s goals from a Poisson distribution, then
applies a Dixon–Coles-style low-score correction with rho = -0.13. The
correction increases the weight of 0–0 and 1–1 while trimming the neighboring
1–0 and 0–1 outcomes. Independent Poisson scores otherwise miss some of that
low-score dependence.
Knockout matches add extra-time goals at roughly one third of the regulation rate. If the score is still level, every penalty is simulated. Conversion probability has a mild team-strength adjustment and is clamped between 62% and 84%; sudden death continues until the teams separate.
The richer interactive engine uses the same xG pair but simulates the match minute by minute. It can generate goals, assists, chances, saves, woodwork, cards, substitutions, extra time, and individual shootout kicks. Goal intensity rises late in the match, red cards reduce the dismissed team’s attack while increasing the opponent’s, and substitutions can slightly change the remaining xG.

Scheduled fixtures use the committed 100,000-run forecast; arbitrary matchups can still receive a quick deterministic browser preview.
Simulating the 48-team tournament
A complete run treats the 104 fixtures as a connected state machine:
- Group tables award 3/1/0 points and sort by points, goal difference, and goals scored.
- Teams still level are compared in a head-to-head mini-table before a deterministic or seeded drawing-of-lots fallback.
- The best eight of the 12 third-place teams advance.
- Placeholders such as
1A, an eligible third-place slot,W74, orL101are resolved as the bracket develops. - Real results can be locked while every future fixture remains simulated.
- Elo is updated in chronological order after every match.
The production allocation data encodes the official row for the realized
BDEFIJKL third-place combination. Before that combination is known,
exploratory Monte Carlo runs use a seeded allowed-group fallback rather than a
complete FIFA allocation matrix. The UI can resolve the actual tournament
bracket precisely, but exploratory runs do not pretend that every theoretical
allocation permutation has been encoded.
For the public snapshots, the system runs 100,000 full tournaments. Work is
split across Node worker threads, defaulting to available CPU parallelism minus
one. A fixed seed (987654321) and common random numbers reduce day-to-day
sampling noise, so a movement in the trend chart is more likely to come from a
new result than from an unrelated random sample.
Per-match probabilities are a separate 100,000-run artifact. Each fixture uses the Elo state immediately before that match. Group matches publish home/draw/away; knockout matches publish each team’s probability to advance after extra time and penalties. The seed includes the Elo-artifact fingerprint, run count, match ID, and participants, so the same inputs produce the same result.
Modeling the Golden Boot
Tournament winners are counted directly. Top-scorer probabilities require attributing every simulated team goal to a player.
The scorer model starts with a player’s demonstrated international goals per appearance, then regresses small samples toward a position-and-shooting prior:
careerWeight = caps / (caps + 18)
scoringRate =
careerWeight × (international goals / caps)
+ (1 - careerWeight) × positionAndShootingPrior
Known primary penalty takers receive a modest multiplier. Projected starters receive a full minute share, while the top five outfield substitutes enter the pool at a smaller share. Assist probability has its own role and passing-based weight, and the scorer cannot assist himself.
The model adds real goals and assists through the snapshot date first and excludes own goals. Each Monte Carlo run then attributes future goals using a random stream separate from match outcomes, so adding player bookkeeping cannot alter which country wins the tournament. If players finish level on goals and assists, the simulated win is split because minutes played (the next real tiebreaker) is not modeled.
The homepage shows a committed Kalshi market snapshot beside the model probabilities. The model forecast stays independent. Outright and Golden Boot prices use the market price directly, while multi-way match and advancement markets are normalized to sum to 100%.
Making a large static app feel fast
The squads and detailed ESPN artifacts are too large to load on every landing page visit. Doing so would waste bandwidth and main-thread time.
The landing view loads eagerly. Matches, standings, teams, match detail, stats,
and the simulator use separate React.lazy routes. Vite also isolates React,
Firebase, squad data, ESPN match context, and player totals into distinct
chunks.
Preloading happens in stages:
- likely navigation routes after roughly two idle seconds;
- the remaining routes after five seconds;
- full squads after eight seconds;
- detailed match context after twelve seconds.
The prefetcher respects Save-Data and slow 2G/2G connections. Full squad files load only once and populate a shared in-memory team registry. This prevents a simulation from caching strength before the real players arrive, a bug I hit in an earlier version.
The service worker handles navigations network-first with an offline shell fallback and caches content-hashed same-origin assets first. It never intercepts cross-origin sports data, so a cached service worker cannot quietly make a live score stale. The install prompt waits for a high-intent action and meaningful engagement instead of appearing on the first page view.
What still needs a server
Most visitors can use the app without an account. Firebase loads lazily only when a gated feature needs it.
For match alerts, preferences are local-first and can synchronize after the visitor opts in. An anonymous Firebase identity preserves the device’s data and can later be linked to Google, Apple, or a passwordless email account without changing its UID.
A scheduled Firebase Function runs every minute. It finds matches whose 15-minute reminder or kickoff notification is due, filters users by followed teams, explicit match reminders, and mutes, then reserves a Firestore receipt before sending. That transaction gives each match, user, kickoff, and trigger an idempotency key. FCM tokens are sent in batches of 500, and invalid tokens are removed.
Predictions and private challenges use the same Firebase identity, but both are still gated experiments. Visitors do not need them to use the tournament app.
Observability for a single-page app
Hash routing can hide screen views from analytics. A traditional page-view snippet sees one HTML document even when the user visits ten meaningful screens.
The app disables GA4’s automatic initial page view and sends its own event on load and every hash change. Team, match, leader, and simulator routes keep low-cardinality page titles while attaching the unique entity as route data. That preserves useful detail without collapsing reports into GA4’s high-cardinality “(other)” bucket.
The analytics layer records navigation, simulations, follows, shares, authentication, alerts, and installation. It also records qualified engagement, 25/50/75/90% scroll depth, debounced search, daily returns, Core Web Vitals, JavaScript errors, and inbound share attribution.
I use those events to see whether visitors compare the model with the market, whether they inspect a team before simulating it, which screens lead to a follow or install, and where people stop reading a long match page.
What I learned
The score card was the easy part. Identity resolution took more work than I expected.
External providers disagree on team names, home/away orientation, event dates, player spellings, and when a record is “final.” Bracket placeholders carry data dependencies; treating them as display strings breaks the bracket. Historical charts are hard to trust unless the model uses reproducible seeds and time-aware state. The static architecture stayed manageable because the refresh, validation, and deployment path was explicit.
For another data-heavy product, I would keep six decisions:
- Persist a reviewed truth layer and treat live data as an overlay.
- Make one model function serve interactive and batch use cases.
- Separate outcome randomness from explanatory bookkeeping.
- Version generated artifacts and make refreshes idempotent.
- Load server infrastructure only for the features that require it.
- Instrument virtual routes and product actions alongside page loads.
I still want broader automated availability inputs, stronger calibration and backtesting, a complete theoretical third-place allocation matrix, and notification fan-out that can handle more users.