WC26 is my World Cup 2026 tracker: a live match centre that follows a game in real time, plus schedule, groups, bracket, team and player pages. All of it runs on ESPN's free fifa.world JSON feed. No API key, no rate card, no documentation, no contract.
The "no documentation" part matters more than the price. When a documented API does something strange, you can read the spec and argue about who is wrong. With a free undocumented feed there is nobody to argue with. Whatever the feed does is, by definition, correct, and your code adapts to it, never the other way round. So from the first prototype session I kept a ledger in the project plan, section 2.2, under a blunt heading: these quirks are law. Here is how the entries earned their place.
The pass gauge stuck at 90%
I prototyped the match centre against a live Brazil-Morocco game and watched it through to full time. Most panels behaved. The passing accuracy gauges did not: both teams showed 90%, and the number never moved for the whole match. The feed's passPct-style percentage fields arrive pre-rounded upstream, coarsely enough that two sides passing at genuinely different rates rendered as one static figure.
The raw material was sitting right next to it in the same payload: accurate and total pass counts. First law: never display an upstream-derived percentage when the raw counts exist. The normalizer computes the rate itself, and the fixture that had shown a flat 90/90 now read 87% against 86%. Close, but two different numbers, and they moved.

The rule needed defending afterwards, too. I later ran an adversarial review of the production build (fourteen agents picking it apart across three dimensions, every finding independently verified before I acted on it), and one confirmed issue was a code path where passAcc quietly fell back to ESPN's rounded passPct when it felt like it. The fallback went. If the counts are missing, the panel shows an honest placeholder instead of a rounded lie.
Seconds, jerseys, sparse xG
Three smaller entries from the same prototype day.
status.clock is seconds, not minutes. The scorebug formats it as MM:SS, ticks it locally between polls, and caps it sensibly in stoppage time. Trivial once you know; garbage on screen if you assume minutes like every football graphic has trained you to.
Team colour comes from uniform.color: the jersey actually worn in that match, not the team's brand colour. Morocco made this concrete. Their brand identity is green, but against Brazil the feed said red (#c01436), and red is what they wore. Paint the scorebug and the pitch board from brand colour and the entire match centre is wrong for ninety minutes. In production I verified the normalizer picks up that exact red before trusting it anywhere else.
xG is sparse. It shows up per-match and inconsistently: for Brazil-Morocco it was absent even after full time. So the law is render-only-when-present, honest placeholder otherwise, and when only one team has a value, show that single side rather than fabricate a pair. The same family of rule covers ratings: the feed carries no player ratings at all, so the rating badges on the pitch are derived and carry a visible "est." label.
The 32 ties that weren't there
Then the quirk that got past me. The production backend fetched the whole tournament schedule with one ranged scoreboard request, and the day I built it the response held 100 fixtures of real data. I verified them; everything rendered. What I could not see was what the response had left out.
A day later I ran a skeptical audit of the build, one agent per phase, checking claims against the live API. Finding number one: the knockout rounds were silently missing. A ranged scoreboard request caps at 100 events, and it never says so. No error, no warning, no hint that there is more. You get 100 events and the rest simply do not exist, which in this tournament meant the response covered the group stage and dropped the knockouts on the floor.
The fix is the only fix a capped, undocumented endpoint allows: stop asking for the whole range. getAllRawEvents chunks the tournament into 12-day windows, fetches each window separately, and merges the results by event id. The schedule endpoint went from a silently capped 100 to 104 fixtures with all 32 knockout ties present. That one function feeds the bracket, the per-group fixture lists, and every team's results page, so the cap had been quietly hollowing out a third of the site.
Quirks you keep, quirks you don't invent
The same audit surfaced more feed behaviour worth writing into law. Penalty goals were being dropped: the goal filter matched /goal/i against the event type text, and a converted penalty arrives as "Penalty - Scored", which does not contain the word goal. The fix was to trust the feed's own flag, scoringPlay === true, instead of pattern-matching its prose. After that, a Germany 7-1 correctly showed all eight goals, with Havertz's stoppage-time strike flagged at 45'+5' and the leaders table crediting him with two.
I also deleted the conditional-request handling I had politely built into the fetch layer. ESPN sends no ETags. The code was dead weight pretending the feed was a well-behaved API, so it went, and the docstring was rewritten to say what actually happens.
And the counterweight, which I think about as much as any quirk: the audit produced one false positive. An agent claimed the team names were mojibake and proposed an encoding repair. I checked the bytes before touching anything: the feed returns correct UTF-8, Curaçao's ç is c3 a7, Türkiye's ü is c3 bc. No repair was added, because a repair would have corrupted good data. Quirks are law, but so is the absence of a quirk. You verify in both directions before you adapt.
What the law looks like in code
Every rule above lives in one place. All ESPN traffic goes through a single firewall module (fetch, stale-while-revalidate cache, rate discipline, logging), and behind it a normalizer bakes in the full section-2.2 ledger: percentages from raw counts, seconds to MM:SS, uniform.color, xG only when present, derived values labelled "est.". Nothing downstream ever touches the raw feed, so no component can rediscover a quirk the hard way.
The rendering side carries the same posture. Stat panels are gated on whether stats actually exist, because a match that has just kicked off would otherwise show a fabricated 50/50 possession split. A team's "next match" only shows a genuine upcoming fixture, because the feed's nextEvent goes stale and was happily presenting an already-played match as an upcoming 0-0.
None of this is clever. It is the tax you pay for building on someone else's free, unofficial data: the feed is the spec, the spec is discovered one surprise at a time, and each discovery gets written down as a rule so it only costs you once.