Nodal90
A football match centre with its own forecasts, and a public record of every forecast it has published, right or wrong. This is how it is put together and why.
What it is for
Nodal90 started from a bigger idea than a scores site: a model of how players, roles, formations, fatigue, cards and substitutions shape a match. What is live today is the first layer of that. It is a match centre for readers who want fixtures, results, lineups, timelines and match statistics in one clean place, and a forecast they can check.
Two rules came from that. The reader's screen stays much simpler than the engine behind it; Nodal90 is not meant to be a statistics dump. And a forecast is only worth showing if its track record is public, including the misses.
As of 30 September 2026 it covers fixtures and results for 58 competitions and publishes forecasts for 15 of them. There are no accounts, no advertising and no cookies.
My part
I own the product: what it covers, what ships and when, and what it is allowed to cost. The engineering is done by AI coding agents working under my direction, several in parallel, with only one of them allowed to release. Every decision is written down with its reasons, and changes are verified by tests before they go out.
How it fits together
- Football data providersThird-party feeds. For each competition one provider is the backbone that decides which teams and fixtures exist; others may only add detail to what the backbone created.
- CollectorA .NET job on Azure Container Apps that runs every two minutes, fetches only what is due, stores every raw response unchanged, and maps it onto Nodal90's own model of competitions, teams and fixtures.
- PostgreSQLThe single store: observations are appended rather than overwritten, forecasts are kept as snapshots, and the published record is frozen.
- Model trainingOffline, in Python. It produces versioned model checkpoints that the .NET side loads and moves forward with each new result, without retraining.
- Public APIASP.NET Core on Azure Container Apps, the read side of a CQRS design. It also serves the sitemaps and share cards, and turns away scrapers and AI-training crawlers.
- Web appA React and TypeScript single-page app on Azure Static Web Apps.
The forecast
The model is a Poisson team-strength model with a correction for recent form. Its inputs are recent goals and form, rest between matches, and expected goals where that data exists. It gives the probability of a home win, a draw and an away win, and a most likely score. National teams are modelled together, as one pool.
A forecast appears seven days before a match and updates until kick-off. A competition's forecasts normally go live only once the model beats a simpler baseline there. I made an exception for a few leagues that did not clearly beat it, because coverage is what brings readers. Every forecast competition is labelled provisional for that reason.
Decision: a record nobody can edit
The forecast that counts is the one on the match page at kick-off. At that moment the system copies it into a separate record table, and the database itself rejects any update, delete or truncate on that table. The record page reads only from it, never from the live view, which is allowed to withdraw a bad forecast. A regression test withdraws a forecast after kick-off and checks that it is gone from the page but still in the record.
The cost: when the freeze went in, the forecasts published since 13 September were copied from the live view as it stood that day, so a forecast withdrawn before then is not in the record. Only the 90-minute result is scored. Matches decided by extra time or penalties, and abandoned matches, are listed and counted but not scored.
The record also compares Nodal90 with the betting market: the last full set of home, draw and away prices from each bookmaker before kick-off, with the bookmaker's margin taken out and averaged with equal weight. On a sample this small, the page itself says a gap of a few matches can be luck.
Decision: one source decides what exists
Early on, a second data feed was allowed to create its own copies of competitions and fixtures. For two weeks, none of the 75 fixtures it brought in resolved to a real fixture. The fix was a rule: for each competition only the backbone provider may create a competition, season, team or fixture, and every other feed can only attach to those. The cost is flexibility: when a secondary feed has something the backbone lacks, it waits until the backbone has it too.
Decision: a small database, and a schedule to match
The database runs on a burstable tier that works on CPU credit. Once the credit ran out and every forecast timed out for four hours. A larger tier was the obvious fix; I chose to keep the cost down instead, and bulk work now runs only between midnight and 8 a.m. UTC. The tradeoff is that some rebuilds wait for the night.
What comes next
- Being found in search: a title, description and preview for every page that crawlers can read without running JavaScript.
- Posting forecasts and results on X, an installable web app, and more leagues.
- Later: accounts, notifications and a prediction game.
- A model that uses confirmed lineups, and the momentum view and player expected goals brought back.
Written 30 September 2026. Figures are as of that date.