Never score on unreconciled data
I’m building Tipperoo, an ad-free football prediction game, and I want to write about the part that surprised me.
I assumed the interesting work would be the scoring. It isn’t. Scoring is a pure function: you have a tip, you have a result, you produce points. You can write it in an afternoon and unit test it into the ground.
The hard part is the sentence just before that: you have a result.
A result is a claim, not a fact
When a match ends 2:1, that number arrives from a data provider over HTTP. It feels like a fact. It is not. It’s a claim, and claims have a lifecycle:
- The match is still live, so the score is true right now but not final.
- The match ended 90 minutes ago and the provider still says 2:1, which probably means it’s final — or it means nobody’s updated it.
- The match went to extra time, and “full time” means something different.
- The result gets corrected an hour later, because an own goal was reattributed.
- The fixture was postponed, after people had already tipped it.
If you treat the first number you see as truth, every one of those turns into a user-visible lie. And in a prediction game, a wrong table isn’t a cosmetic bug. The entire product is a promise that the standings are right. Get that wrong once in front of someone’s colleagues and there’s no reason to come back.
So the rule I’ve been building around: never score on unreconciled data.
What that looks like in practice
Three things, none of them clever, all of them annoying to skip.
Results have explicit states. A result is provisional until two independent sources agree, and only then confirmed. Points computed from provisional data are shown as provisional — visibly, with a label. Not greyed out and hoped about. The UI is allowed to say “we don’t know yet”. It is not allowed to render a confirmed-looking number over data that might move.
That distinction sounds obvious written down. It’s very easy to lose, because the cheapest thing to build is a single score column that just gets UPDATEd, and the difference between “provisional 2:1” and “confirmed 2:1” is invisible in that model right up until it matters.
Results are an append-only log. A correction is a new event, not an update. The standings are derived from the log, so fixing a result recomputes the table instead of silently rewriting it — and I can answer “why did my points change?” with something better than a shrug. This costs almost nothing to do on day one and is genuinely painful to retrofit.
Live push carries no data. This is the one I changed my mind about. The obvious design is to push the new score to the browser over a socket. It works, and it’s fast, and it means the score on screen is now a claim your transport layer is responsible for keeping true forever. Every dropped, duplicated or out-of-order message becomes a wrong number in front of a user.
So instead the database emits an opaque signal — transactionally, as part of the same write — and the client refetches the durable row. The signal carries no score. It says only: something you care about changed, go look. A lost message costs a refetch. It can’t cost correctness, because the message never contained the truth in the first place.
That’s a worse design on a latency benchmark and a much better one at 5pm on a Saturday.
The actual lesson
None of this is novel. It’s the same lesson every system with an external source of truth teaches eventually: the boundary is where correctness goes to die. The provider is not lying to you, it’s just answering a different question than the one you’re asking. It’s answering “what do I currently believe?” and you’re asking “what happened?”
The engineering is mostly in refusing to conflate those two, and in being willing to show a user “not yet” instead of a confident wrong answer.
Ask me how well it held up after the first real matchday — the beta rides on 2. Bundesliga in August, and I fully expect reality to find something I haven’t thought of.