Work/Sport & games
FikaScores
After every match, a plain verdict on whether the result was deserved.
LiveLive and still being built out.
Web
The problem
“We were robbed, but all anyone has to prove it is a feeling.”
After almost every football match someone says the result was unfair, and nobody has anything better than a feeling to back it up. The scoreline hides a lot: a team can lose a game it dominated, or win one it was outplayed in. Stats sites publish chance and shot numbers, but they hand the fan a table and leave the argument unsettled. Fans do not want to learn a new metric. They want a word and a number they can quote, in the minutes after full time, when the argument is happening.
Who had it
- Football fansA plain word and number that settles whether a result was fair.
- Group-chat arguersA card they can share that makes the case in one glance.
- Creators and podcastersEvidence-backed takes with terms that need no explanation.
- AnalystsVerdicts that show their evidence and their sample sizes.
What the answer had to survive
- The verdict must arrive within minutes of the final whistle.
- Each word must mean exactly one thing, with a fixed sentence.
- Where chance data is missing, the page must say so.
- History across many seasons needed the same treatment as current matches.
- The data supplier had to be replaceable without losing anything.
What we built
One verdict per team per match, with the evidence beside it.
For every finished match, FikaScores reads the chances each side created and works out how often a team with those chances would have won, drawn or lost. From that comes a verdict from a fixed ladder of eleven plain football words, a percentage that means one thing, a one-sentence explanation and a Fair Score. Where there is no chance data, the page says 'No verdict' rather than guessing. Deeper layers such as team strength and a Luck Ledger sit below for anyone who keeps reading.
What it does
The verdict
- Eleven-word verdict ladderWon, lost and drawn matches each get words from Deserved to Robbed, each with its own colour and sentence.
- Fair ScoreThe most likely scoreline given the chances, shown beside the real score.
- Evidence lineEvery verdict states its basis: chance quality, shot profile, or shots and shots on target.
- Honest gapsIf a competition publishes no chance data, the page says 'No verdict' and never guesses.
After full time
- Provisional, then finalA first verdict appears within about a minute of the whistle and is firmed up by ten minutes after.
- Live match pageScore, clock and the pre-match expectation refresh automatically until the verdict arrives.
- Fan voteFans agree or disagree and see the split. Votes never change a verdict.
Teams and competitions
- Team pages by seasonVerdict strip, team strength score, Luck Ledger, form and signals for any season on record.
- Evidence TableThe league table re-ranked by what performance earned, beside the real table.
- Luck LedgerActual points minus the points performance earned, over a season and the last ten games.
- Compare two teamsA side-by-side view of two teams across the same measures.
Sharing
- Share cardsEach verdict gets an image card, also used as the link preview in group chats.
- Follow a teamOn your next visit, a message tells you the verdict for your team is in.
- Pages search engines can readSearch and link-preview bots receive full server-rendered pages at the same address people use.
A day with it
- 1
Find the match
After full time, a fan searches for the match or opens the homepage and sees the newest verdicts.
- 2
Read the verdict
Their team comes first, with a word such as Robbed, a percentage and one plain sentence.
- 3
Check the evidence
They compare the Fair Score with the real score and read the evidence line behind it.
- 4
Look deeper
They expand the chances side by side and see how typical this verdict is for the team this season.
- 5
Share and vote
They send the card to the group chat, then vote on whether they agree.
- 6
Follow the team
They follow their team so the next verdict greets them, and may go on to the team page.
How it works inside
Three layers with a strict rule: the engine computes, the API only reads and the app only renders. A Python engine builds verdicts from archives and live data. A read-only API serves them, and every cached answer is keyed to the data version.
Cut the network to see what this system does without it.This one lives on the internet, so it waits, like any website. Its staying power is on our side: backups, health checks and tested recovery.
On the device
- Web appRenders what the API returns. It contains no formulas.
Our cloud
- Read-only APIServes verdicts, votes and follows. Never computes a statistic.
- Intelligence engineIngests data, computes verdicts and signals, and writes versioned records.
- Verdict databaseAppend-only, versioned records. Past verdicts can be audited.
- Versioned cacheCached answers are keyed to the data version.
- Raw file storeSource files kept byte-for-byte, never edited.
- Share-card rendererDraws the verdict image for each match.
- Data supplier portOne adapter per supplier, so a supplier can be swapped.
Outside services
- Live data supplierCurrent fixtures, live status, statistics and line-ups.
- Public match archiveHistorical results and statistics, fetched twice weekly.
Every part and every link, as text
- Web app — Renders what the API returns. It contains no formulas. (on the device)
- Read-only API — Serves verdicts, votes and follows. Never computes a statistic. (our cloud)
- Intelligence engine — Ingests data, computes verdicts and signals, and writes versioned records. (our cloud)
- Verdict database — Append-only, versioned records. Past verdicts can be audited. (our cloud)
- Versioned cache — Cached answers are keyed to the data version. (our cloud)
- Raw file store — Source files kept byte-for-byte, never edited. (our cloud)
- Share-card renderer — Draws the verdict image for each match. (our cloud)
- Data supplier port — One adapter per supplier, so a supplier can be swapped. (our cloud)
- Live data supplier — Current fixtures, live status, statistics and line-ups. (outside services)
- Public match archive — Historical results and statistics, fetched twice weekly. (outside services)
- Web app → Read-only API: JSON with change tags (needs internet)
- Read-only API → Versioned cache: Versioned cache reads (works offline)
- Read-only API → Verdict database: Read-only queries (works offline)
- Live data supplier → Data supplier port: Fixtures and statistics (needs internet)
- Data supplier port → Intelligence engine: Neutral records (works offline)
- Public match archive → Raw file store: Twice-weekly download (needs internet)
- Raw file store → Intelligence engine: Parsed by column name (works offline)
- Intelligence engine → Verdict database: Versioned writes only (works offline)
- Intelligence engine → Versioned cache: Version bump (works offline)
- Intelligence engine → Share-card renderer: Verdict to image (works offline)
- Share-card renderer → Read-only API: Cached per verdict (works offline)
The hard parts
A verdict that means one thing
- The situation
- Fans will not accept a verdict word that does not match what they saw. A first prototype called nearly a quarter of all losses 'robbed', including matches with a goalless Fair Score.
- What we did
- We made the percentage a single probability with a fixed sentence and split the ladder into eleven words. Robbed became a two-part rule: a real chance of a draw or better and a clear lead. Thresholds were tuned over the whole archive.
- The lesson
- A fuzzy idea becomes a precise, testable rule, measured against reality before shipping.
Swapping the data supplier in a night
- The situation
- The day before launch, the first live-data supplier became unavailable. The product depended on it for current-season fixtures and statistics.
- What we did
- We put a provider port in front of the engine, with one adapter per supplier, and moved supplier ids into generic columns. A second supplier's adapter was then written, with recorded responses for tests.
- The lesson
- A supplier failing should be an inconvenience, not an outage.
Versioned records and caches keyed to version
- The situation
- A stats product recomputes constantly. An overwritten record or stale cache makes numbers impossible to audit. A launch-day change kept serving the old page shape from cache.
- What we did
- Every record is append-only and stores its settings version and data coverage. Every cache is keyed to the data version, and the deployed code version is added to the change tag so a deploy clears everything.
- The lesson
- Any past verdict can be traced to the thresholds that produced it.
Deploys that do not break open tabs
- The situation
- After each deploy, an already-open browser tab asked for page files the new build had deleted, so some links silently stopped working.
- What we did
- We kept the previous builds' files for seven days and made the app reload once automatically if a page file is missing.
- The lesson
- Shipping several times a day only works if shipping does not disturb people already on the site.
The stack
- Apps
- Vue 3ViteHand-written CSS
- Backend
- Python engineLaravel 12PHP 8.3
- Data
- MySQL 8RedisAppend-only versioned records
- Infrastructure
- NginxCloudflareGitHub Actions deploys
- Services
- A live-data supplierA public historical archive
- Testing
- pytestPHPUnit
How it came together
- 1Engine, site and APIThe engine, the web app, the API and the deployment scripts were built together.
- 2Verdict firstThe product was repositioned around the verdict, with 98 engine tests passing.
- 3A replaceable supplierA provider port was introduced, a second data supplier adopted, and the site went live behind Cloudflare.
- 4History loadedThe full historical archive was loaded and thresholds tuned. Whistle-to-verdict was measured at 22 seconds.
- 5Found by searchChance-quality data was mapped for current seasons, and server-rendered pages and sitemaps added.
