open source

The model is open source. The clever bits, and the rough edges.

The engine behind every Touchline number is on GitHub now, MIT-licensed, about 550 lines you can run in two commands. An honest tour: what a sharp reader will like about it, and what they will rightly push on.

20 July 2026 · 7 min read · James Frewin

Cover illustration for The model is open source. The clever bits, and the rough edges.

The engine behind every number on this site is now on GitHub, MIT-licensed, at github.com/heyimjames/touchline-model. It is about 550 lines of TypeScript with no dependencies, and a small script that plays the World Cup final 100,000 times and prints the result. You can read the whole thing in an afternoon and run it in two commands.

The point of publishing it is not to show off. It is the strongest version of the one thing this site keeps saying: not a supercomputer, not a tipster, not a black box. That is easy to assert and cheap to doubt. A repo you can run makes it a fact. So here is an honest tour: the parts a careful reader will like, and the parts they will rightly push on. Both are in the code.

Correlation, done the right way

The core is a bivariate Poisson model. Each side’s goals come from a Poisson draw at its expected-goals rate, Spain 1.3, Argentina 1.1. The word that matters is bivariate: a small shared component (cov, default 0.10) is drawn once per match and added to both teams at the same time. Goals in real football arrive together, a stretched game opens up at both ends, and the shared piece is what puts that correlation in without touching either team’s average.

This is the textbook-correct move, the Karlis–Ntzoufras estimate from the football-modelling literature, not a fudge. And there is a nice detail in the code: the shared draw is clamped so the independent parts can never go negative. Drag it below and watch what moves. Spain’s win probability barely twitches; both-teams-score and extra time climb. Correlation changes the shape of the game, not who lifts the trophy.

try it: the shared componentCorrelation changes the shape, not the winner
Spain to lift55.6%
barely moves
Both teams score49.4%
moves with correlation
Extra time or penalties29.2%
moves with correlation

Drag it. Spain’s win probability barely moves, but both-teams-score and extra time climb: the shared component adds the same bounce to both teams at once, so it makes goals arrive together without changing who is likelier to win. This runs the actual engine (lib/final/sim.ts, the code in the repo) in your browser, 10,000 times per drag, seeded so a given cov always gives the same answer.

Shrinkage on the scorers

Each goal is handed to a scorer by weights, and the weights are a blend: half the verified tournament goal share, half a pre-tournament prior. Seven games is a tiny sample. Take the raw shares at face value and you would have the model believing a squad player who nabbed two tap-ins is a bigger threat than Messi. The blend pulls those small-sample shares back toward what you knew before a ball was kicked. Statisticians call it shrinkage; it is the honest way to treat seven matches.

Who scores first, without simulating the clock

The model never simulates minute-by-minute. So to answer “who scores the next goal from here” it uses a small piece of probability instead of a stopwatch: once you know how many goals each team ends up with, the order they arrived in is exchangeable, so within a period the first goal belongs to a team in proportion to its goals that period. One line, no timestamps, and it falls out of the maths rather than being bolted on.

Two random streams, so the record never drifts

The headline numbers come from one seeded generator. When I added new outputs later, who scores first, the opening scorer, I gave them a second, independent random stream (the seed XORed with a constant) rather than pulling from the main one. If they shared a stream, adding a feature would have shifted every existing number by a hair. This way the win probability, the scoreline grid, everything published before, stays bit-for-bit identical. Add to the model, never quietly disturb the record.

Same rates in, same result out, on my machine and yours. A model you cannot reproduce is a rumour with a decimal point.
Why the seed matters

A live mode that respects football, not the clock

Once a match kicks off, the engine simulates only the remainder, from the current minute and score. The fiddly bits are where the care shows. Minute 90 in regulation is about five more minutes of normal football, not thirty of extra time. Extra time reached from regulation starts level, so the “chase” boost that opens up a trailing team must not carry into it. A leader gets counters; a team two down opens up more than a team one down. None of that is clever maths. It is just refusing to let the clock lie to the model, which is what most toy versions get wrong.

The rough edges, because they are in there too

It is a designed model, not a trained one. The two rates are not fit to a database of past matches by maximum likelihood. They are decomposed from the market’s own prices and then set by judgement. For a one-off World Cup final that is defensible, there is no clean training set of Spain-Argentina finals, but you should read it as a considered opinion with a probability attached, not a fit to history.

It does not use the Dixon–Coles correction, the standard tweak that pulls the very low scores (0–0, 1–0, 0–1, 1–1) toward their real-world frequencies. The shared component handles correlation a different way, and a purist could reasonably want both. And the live chase effect uses round multipliers, 1.15 and 1.25, that are chosen, not estimated. They are honest guesses at a real thing, and I would rather say that plainly than dress them up.

None of this is hidden, which is the whole idea. You can find every one of these decisions in src/engine.ts, with the reasoning in the comments, and disagree with any of them in public.

Run it yourself

Two commands. It plays the final 100,000 times and prints the distribution, and because the generator is seeded it prints the same thing every time, on any machine:

git clone github.com/heyimjames/touchline-model
cd touchline-model && npm install
npm start

  Spain to lift      55.5%
  Argentina to lift  44.5%
  Extra time         29.1%
  Likely scores      1–0 14.3%, 0–1 11.6%, 2–1 11.3%

Then change a number in DEFAULT_PARAMSand run it again. Push Spain’s rate up, set the shared component to zero, hand the shootout to whoever you fancy. The machinery does not move; the numbers do. That is the difference between an instrument and a headline.

Read the engine

The whole model, MIT-licensed, about 550 lines. A star helps others find it.

View on GitHub

More from the journal