For the past few months I've been building a fantasy football manager for the Swiss Super League. If you've played Fantasy Premier League you know the shape of it: you pick a squad under a budget, you set a lineup before a deadline, and then you watch your points tick up while the matches are played.
That last part is the entire product. Nobody opens a fantasy app to admire their squad on a Wednesday. They open it at 16:30 on a Saturday, twice a minute, because someone on their bench just scored. Deadlines, live points, bonus points that get recomputed as a match runs, prices that drift after a round settles — all of it is time-dependent, and none of it means anything outside a live match window.

Which was a problem, because when I started I had no live matches. No feed, no season, nothing to attach to. And a fantasy season lasts nine months, so "just wait and see if it works" is not a development cycle.
Here's how I ended up testing a live product with no live sport, and why the second attempt was much better than the first.
Attempt One: Seed and Reveal
The obvious move is to fake the season. So that's what I did.
At boot, a seeder generates a complete, deterministic season: every fixture,
every scoreline, and a Performance row for every player in every match with
their full final stat line — minutes, goals, assists, cards, bonus points,
everything. The whole season exists in the database from the first second the
app runs.
Then a virtual clock walks through it. A SimClock service owns "now", and
"now" is just a number I can move. Set it to an hour before the GW1 deadline,
advance it at 60× speed, and nine months of football play out in an afternoon.
Where the app would normally call Date.now(), it asks the clock instead.
The part that makes this work is a projection function. Since the database holds each player's final line, a read has to answer "how much of this has happened yet?" So there's a pure function that takes the final line plus the fixture's progress and returns the as-of line:
projectStats(finalLine, { started: true, finished: false, minutes: 45 }, 'FWD')
// → { minutes: 45, goals: 1, assists: 0, bps: 15, cleanSheet: false }
// floor(2 goals × 45/90)
This was genuinely good. It's pure, so it's trivial to unit test. It's shared by both the ticker that mutates rows and the read layer, so a clock jump — forwards or backwards — produces correct points immediately, without depending on whether a background job has run. I could replay a full 38-gameweek season with scripted personas making transfers and playing chips, and check every invariant along the way.
It also had one flaw that I spent weeks paying for.
The Future Was Sitting in the Database
The first report was simple: a yellow card appeared in the app before the match had kicked off.
The cause was obvious once I looked. projectStats gated minutes, goals,
assists, bps and cleanSheet. It did not gate cards, because cards aren't
accruing stats — there's no sensible way to show "0.4 of a yellow card". So
cards were read straight off the stored row. And the stored row already held the
card the player would be shown in the 70th minute.
So I fixed it. Then the same bug showed up for own goals. Then for penalty misses. Then in the live events feed, which is a different read path with its own logic. Every fix was correct and every fix was local, and a week later there'd be another one, because I kept fixing the symptom.
The actual cause wasn't in any read path. It was that the architecture stored the future. A row that contains the 70th minute at kickoff will eventually leak it — through a read path I forgot, or through one I hadn't written yet. Every new endpoint was a new opportunity to expose data the user wasn't supposed to have. The projection layer was a filter over privileged state, and filters over privileged state fail open.
You can't test your way out of that either. There's no test for "the feature I add next year also remembers to gate cards."
Attempt Two: Record a Real Season, Replay It Through the Real Path
The fix was to stop storing the future.
I bought access to a football data API and recorded a completed season to disk — 230 fixtures, each one a bundle of its final scoreline, per-player statistics, and the event timeline. About 22 MB, committed through Git LFS.
The naive thing to do with a recording like that is turn it into better seed data. I did the opposite: I bound the recording as the API client.
// live-feed.module.ts — same class token, different implementation
{
provide: ApiFootballClient,
useFactory: () => isLiveSim() ? corpusClient : httpClient,
}
Every @Inject(ApiFootballClient) site downstream is unchanged. The poller
still polls. The mapper still maps. Scoring, bonus assignment, reconciliation
and finalization all run exactly as they do in production, because as far as
they can tell, they're talking to the provider.
The corpus client implements the full provider surface, reads bundles off disk, and clips every fixture to the virtual match minute:
T = floor((clock.now() − kickoff) / 60_000)
T < 0→ statusNS, scoreline 0–0. The poller drops anything that isn't live-or-finished, so nothing is written at all.T ≥ 95→ the recorded final state.- otherwise → the scoreline, per-player stats and events reconstructed as of
minute T.
/fixtures/eventsreturns only events withminute ≤ T.
And that's the whole point. There is no finalGoals column any more. There is
no stored row that knows about the 70th minute at kickoff. A card cannot leak
before its minute because at that moment it does not exist anywhere in the
system — not behind a gate, not filtered on read, not at all. I didn't fix the
bug class. I made it unrepresentable.

The nice property of an unrepresentable bug is that it stays fixed while you're not looking. New endpoints inherit the guarantee for free.
Time as a Dependency
The thing that makes this composable is that both the clock and the network are injected, and neither knows about the other.
The corpus client injects the same SimClockService that the poller's window
query uses. So the client's idea of "which minute is it" and the poller's idea
of "which fixtures are in the live window" agree automatically, with zero
coupling between them. Neither had to be taught about the other.
Out of that, three runtime profiles fall out of one image:
sim — everything seeded and revealed through the projection layer, on the virtual clock. Still the fallback, and still what the previous season and the hockey variant run on.
live-sim — the recorded season clipped to minute T, on the virtual clock. The dress rehearsal.
prod-live — the real network, on the wall clock. Production.
There's no LIVE_SIM=true flag. The selector is the season:
export function isLiveSim(env = process.env): boolean {
return env.SIM_SEASON === '2025-26' && env.GAFFER_DATA_SOURCE !== 'live';
}
Two substitutions — the network client and the clock — and everything downstream
is byte-for-byte the production path. In the whole codebase there are about six
branches on isLiveSim(), and two of those are boot-time guards. That ratio is
the number I'd point at if someone asked whether this was worth it.
The payoff is that "run the season" became a command. A replay harness boots the real application against an in-memory Mongo, registers four scripted managers through the real endpoints — a set-and-forget player, an active trader who uses chips, someone who joins in GW10, a Free Hit edge case — and drives the clock through all 38 gameweeks in about 35 minutes. It re-derives scoring, bonus and prices independently from the spec rather than asking the app, so a mismatch is a real engine bug and not a tautology.
Then it dumps every settled fact — final points, bonus, prices, scorelines, every persona's frozen gameweek — into a JSON file and commits it. That golden file is what let me refactor the engine to support a second sport across eleven phases and prove, at every step, that soccer's output hadn't moved by a byte.
What a Recording Can't Teach You
I want to be honest about the limits, because this is the part I got wrong.
Having built a dress rehearsal that runs the production code path, I assumed pointing it at the real network would be uneventful. It wasn't. A recording is well-behaved in ways a live provider is not, and there's a whole bug class that only exists against a real feed.
Two feeds that disagree. Goals come from the events endpoint; the goal tally comes from the statistics endpoint. They are not the same feed and they do not always agree. My recorded season has players with a goal event the final tally never confirms, and vice versa. If you drive the UI from events and the points from statistics, you get a "goal" tag on a player with zero goals. The fix is to cap the revealed count at the stored tally everywhere, so the wire can never show a tag the points don't reflect.
Minute drift. Early on I interpolated event minutes from match progress. The result was that a goal moved: it showed at 31', then 33', then 44' as you reloaded, and a lone goal with a lone assist collapsed onto the same minute. Interpolation is fine for a quantity and nonsense for a timestamp. Event minutes now come from the stored timeline, clamped and sorted — which also makes the reveal order-independent, so an unsorted timeline can't leak a future minute.
Matches that don't happen. Fixtures get postponed. Since the poller only
looks at live-or-finished matches, it never observes a PST status at all — the
daily schedule sync is the only thing that can notice. Worse, a postponed match
doesn't just vanish: it gets replayed weeks later, which means the round it left
becomes a blank gameweek and the round it lands in becomes a double, where a
club plays twice and both legs have to merge into one player's score without
re-announcing the leg that already happened. A recording of a finished season
hands you the final calendar. It never hands you the calendar changing under
you.
The provider having a bad day. The statistics endpoint occasionally returns
an empty 200 for a fixture that definitely has statistics. Not an error — an
empty success. My recorder needed a repair mode specifically to re-fetch
bundles that came back empty, which tells you how routine it is. Every consumer
downstream has to treat "empty" and "not yet" as different things.
None of these were reachable from the recording. The dress rehearsal proved the engine was right; it could say nothing about whether the world was well-behaved.
Things I Learned
Injecting the clock is not optional for a time-dependent product. It's the
single highest-leverage decision in this codebase. Everything else — the
replay, the goldens, the fast-forward — is downstream of now being a value I
can set rather than a call to the system.
Fake at the boundary, not in the middle. Seeding fake data into the database gave me a fake system. Faking the network client gave me the real system with a fake input. The first tests your seeder; the second tests your application.
Filtering privileged state on read fails open. If sensitive-in-context data sits in the row, some read path will eventually return it. Prefer an architecture where the data isn't there yet over one where every reader remembers to hide it.
A pure projection function is worth writing anyway. projectStats was the
right answer to the wrong architecture, and it's still the best-tested code I
have. Purity meant I could reason about it completely — which is also how I
could tell the problem was somewhere else.
Determinism is a feature you have to defend. A golden file that drifts between runs is worse than no golden file. Key everything by stable identifiers, never by generated ids, and pin only settled facts so the artifact is invariant across replay speed.
Distinguish "broken" from "incomplete". The golden verifier exits 1 for
drift and 2 for a run that finalized fewer rounds than it should have. A
wedged run is not a regression and CI must not report it as one. I added that
after chasing a phantom regression that was really a hung process.
Final Thoughts
The framing I keep coming back to is that there are bugs you fix and bugs you delete. Fixing the card leak was six correct patches across six read paths over several weeks. Deleting it was one architectural change, after which the question stopped being askable.
I don't think the lesson is "always record real data" — that only works because football happened before I needed it to, and most domains aren't so obliging. The lesson is narrower: when the same bug keeps coming back in new places, the problem usually isn't the places. It's that your architecture allows the bug to be expressed, and you're playing whack-a-mole against a state space that keeps growing.
And then, having built the thing that proves your engine is correct, stay suspicious. A recording is a well-behaved world. The real one has two feeds that disagree with each other, matches that move, and endpoints that answer "200, nothing here" when you know perfectly well there's something there.
If you'd like to see what all of this machinery is actually holding up, the app is called Matchday Manager.