Building for the World Cup: real-time fan narration and automated editorial
A goal is scored at a World Cup match. Sixty seconds later, a push notification lands on a fan’s phone — with the player’s name, the minute, and a line of narration. No journalist wrote it. No one pressed send. The system just knew.
That’s the product I inherited. What I built came after.
The context
During the years I worked at a journalism company, I had the chance to work with a sports platform that had been producing live match coverage for over eight years. The infrastructure was real and battle-tested: a pipeline that ingests football events, routes them through multiple services, and delivers narrations to mobile and web users in near real time. It predates my time there, it’s in production, and it works.
The World Cup only happens every four years. For a news organization, that means a concentrated window of intense reader demand, tight publication deadlines, and every game demanding coverage simultaneously. When Copa 2026 came around, the question wasn’t whether we had data — we did. The question was whether we could do more with it.
My contribution was the second product: automated editorial content, built on top of the data infrastructure that already existed. Two products, but very different origins — one I inherited, one I built.
Product 1: the real-time narration system
This system is over eight years old. When a match event happens — a goal, a card, a substitution — it delivers a narration to users in under 60 seconds.
That 60-second ceiling is structural, not a design decision we made. The data provider doesn’t offer webhooks or push events. It’s a polling API. Every minute, an ingestion service calls its endpoints, compares what came back against what it already knows, and fires events for anything new. The latency is baked in.
From there, the data travels through five Java services, each publishing to Google Cloud Pub/Sub. The narrations land in Firebase Firestore, and from there the Firebase SDK delivers them to clients in real time — no WebSocket, no SSE, no custom long-polling on our side.
The real-time delivery layer is entirely Firebase’s problem. The backend is a sequence of Pub/Sub consumers that write to a database; the clients watch that database directly. It’s a clean separation, and it means the system doesn’t have to manage connections for thousands of concurrent users during peak match moments. The tradeoff is a hard dependency on Firebase infrastructure — something I’ll come back to in the third post in this series.
The events it tracks, and what each one triggers:
| Event | What it triggers |
|---|---|
| Goal / own goal | Narration + push notification |
| Yellow / red card | Narration |
| Substitution | Narration |
| Period change | Match status update |
| Match status | SCHEDULED → LIVE → FINISHED |
Each event carries type, timestamp, player, team, and match period. That granularity is what makes the narrations feel like narrations rather than score updates — “34’, left foot from the edge of the box” rather than just “1–0.” For a goal notification, 60 seconds is a product constraint you learn to live with; in practice, it still beats most TV broadcasts finishing the replay.
It works. It has worked for years. It also has the unmistakable shape of a system that grew incrementally: five services for what is essentially one linear pipeline, each hop a Pub/Sub publish and a single transformation. We’re looking at refactoring it, and I’ll talk about what that process surfaced in the next post.
The question the Copa forced
With a system already producing structured, timestamped, per-player event data for every match, the Copa raised an obvious question: could we use that data to produce editorial content automatically — without waiting for a journalist to write it?
The data was there. The deadline was real. The answer turned out to be yes.
Product 2: automated editorial
This is what I built. The premise: when a World Cup match ends, produce a full editorial chronicle and publish it — without a journalist writing it, and without an editor reviewing it before it goes live.
The framing we started with was “pre-filled editorial materials,” implying an editor would receive structured content, adjust the tone, and hit publish. What we actually built is more aggressive: content arrives at the CMS already formatted, tagged, and ready. The editorial team receives an email notification after publication.
The pipeline runs on a Python service using APScheduler with cron and interval jobs. For most content categories — exchange rates, lottery results, today’s match schedule — it pulls from various sources and renders a Jinja2 template directly into a Pub/Sub message that reaches the CMS. These are templated, predictable, and the editorial risk is low.
World Cup chronicles are different. When a match finishes, the job:
- Reads the narration events already stored in Firestore — goals, cards, substitutions — produced by Product 1
- Fetches match statistics (possession, shots, fouls) from the data API
- Sends everything to an internal LLM service
- Receives a headline, support line, deck, and full body text
- Publishes directly to Pub/Sub → CMS
That dependency on Firestore is what connects the two products — and it’s not accidental. The same event structure that makes real-time narration work (what happened, when, who) is exactly what you need to write a post-game chronicle. A goal with player, minute, and context is both a push notification and a paragraph. The narration pipeline was designed for fan consumption in the moment; what I realized is that it was also producing a complete, structured record of the match. I didn’t build a data layer. I found one.
What we learned
A few things that didn’t show up in the original design:
Fully automated publication is a bigger decision than it looks. No human review before publish means the LLM’s output goes live regardless of what it says. For World Cup chronicles this worked well — structured events, clean stats, a model with strong priors about football. But “this worked” and “this is safe” aren’t the same thing. We got lucky in the matches we covered.
Building on top of an old system means accepting its constraints. I didn’t choose Firebase. I didn’t choose five Java services in a chain. Those were already there, and they worked well enough that building around them was faster than rebuilding. The World Cup had a deadline; I took what I had.
The LLM was introduced for the World Cup specifically, not as a general capability. Domestic league games still get template-generated articles. The decision was deliberate — the World Cup was a bounded scope where the quality bar was worth the investment. That containment was smart, even if it creates a visible inconsistency in the product.
An 8-year-old system shows its age in unexpected places. When the data provider introduced rate limiting, the structure of those five services — each making its own calls, no shared cache, no deduplication — became a real problem. I’ll get into that in the next posts in this series.