The lab
Every number on this page comes from data/atlas/ in the repo, generated by npm run atlas from a fixed seed. Same inputs, same bits, on any machine.
What the model is — and what it isn't
Every shot is one ball, stepped forward at 1/120 s by a semi-implicit Euler integrator, under three forces and nothing else.
Gravity. 9.81 m/s² down, on a 0.43 kg ball of radius 0.11 m (FIFA Law 2).
Drag. ½ρ·C_d·A·|v|·v against the velocity, in air of density 1.225 kg/m³ across a cross-section of πr². C_d is not a constant: it falls from 0.47 in the subcritical regime to 0.25 past the critical one, smoothly between 8 and 14 m/s. That is the drag crisis — a hard shot holds its pace better than a gentle one. Values follow Carré, Asai, Akatsuka & Haake, Sports Engineering 5 (2002).
Magnus. ½ρ·C_l·A·|v|² along ω̂ × v̂. The lift coefficient is a saturating fit in the spin parameter S = r|ω|/|v| — C_l = S/(2.0·S + 0.4), clamped at 0.33 — inside the range reported by Goff & Carré, Am. J. Phys. 77 (2009) and Eur. J. Phys. 31 (2010). Spin decays exponentially over a 10 s time constant; a full-power strike on the edge of the ball is about 10 rev/s.
A fourth term switches on only above 20 m/s with under 2 rad/s of spin: a band-limited sideways wobble. That is the knuckleball, on purpose.
The three striker presets are gains over these constants, never edits to them. Realistic is ×1, the literature value; Exaggerated and Arcade multiply power, spin and the Magnus force by up to ×1.5, ×2 and ×3, and the game labels them unphysical, because they are.
Not modelled. No wall. No wind, no altitude — sea-level air, always. The drag curve is one smooth-panel fit: no seam orientation, no panel count. Spin is a decaying vector, not a wobbling axis. And the keeper is a model — reaction time, read probability, reach envelope — not a person.
Drag crisis
The question. How does the ball’s drag coefficient — and the deceleration it causes — change with speed?
The grid. 31 speeds, 5 to 35 m/s in 1 m/s steps. C_d comes straight from the aerodynamic model. The deceleration is measured, but from one integrator step, not a full flight: the ball is launched horizontally and the drop in its horizontal speed over that single 1/120 s step is divided by the step — so gravity, which only touches the vertical, never enters the number.
The reading. The curve has a knee, and the knee is the whole point. At 5 m/s — a rolled pass — C_d is 0.47. At 11 m/s, halfway through the transition, it is 0.36. By 20 m/s it has settled at 0.25, and it stays there for every speed a struck ball actually travels at. A hard shot lives on the low side of the crisis; a floated one does not.
What that costs the ball is easier to feel in m/s². At 30 m/s the drag deceleration is 12.18 m/s² — noticeably more than gravity is pulling the ball down at the same instant. Drag, not gravity, is what shapes the back half of a long free kick.
The overlay. Carré et al. (2002) is the source these coefficients come from, and their measured C_d curve is the obvious thing to plot underneath. It is not plotted. The points were never transcribed from the paper, and an overlay eyeballed off a printed figure would look like a validation while being an invention. So the chart says overlay not transcribed and cites the paper instead. The honest version of “we checked against the literature” is that the constants came from it; the curve shape between them is this model’s, not theirs.
Carré et al. 2002 (match ball). Overlay not transcribed — the points are left empty rather than approximated from the paper; see the source. Carré, Asai, Akatsuka & Haake, "The curve kick of a football II: flight through the air", Sports Engineering 5 (2002) 193–200
- cdAt5
- 0.470
- cdAt11
- 0.360
- cdAt20
- 0.250
- decelAt30
- 12.183
- rows
- 31
Download JSON · CSV · reproduce: npm run atlas -- drag-crisis · seed 1 · config dfa2b5d
Curl vs spin
The question. How far does a 30 m/s shot from 25 m bend, per rev/s of sidespin, at each curve gain?
The grid. 63 flights: 21 spin rates, 0 to 10 rev/s in half-turn steps, crossed with the three curve gains (×1, ×2, ×3). The launch never changes — 25 m, 30 m/s, 10° elevation, spin about the vertical axis — so only spin and gain move. Deviation is measured at the goal plane, against the line the ball was pointed down.
The reading. At the literature gain of ×1, 8 rev/s bends the ball 4.48 m and 10 rev/s bends it 5.02 m. Lift saturates in the spin parameter, so those last two revolutions buy 0.54 m where the first two bought 1.75 m. One point is missing from the ×1 line, at 5.5 rev/s: that shot bends onto the post and is deflected before it can cross.
The overlay. One point, from Dupeux et al. (2010) on Roberto Carlos’s 1997 free kick: roughly 10 rev/s, about 4 m of curl. It is drawn, and it is not a like-for-like check — their kick was 35 m at 38 m/s, this one 25 m at 30 m/s, so there is less time for the same spin to work in. The model’s 5.02 m above their 4 m says the regime is right, not that the model is validated.
Where the data stops. At curve ×3 the line stops at 6.5 rev/s: beyond it the ball bends so hard it turns away before reaching the goal plane. That is why deviationAt8revGain3 is null and reads no crossing rather than a number. The widest crossing in the grid, 18.93 m off line, is arcade too. Gain ×3 is a toy: from 7 rev/s it stops producing a shot on goal.
Roberto Carlos, France v Brazil 1997 (Dupeux et al. 2010). Dupeux et al. give that free kick as roughly 35 m at 38 m/s, about 10 rev/s of spin and about 4 m of curl. This experiment runs a shorter, slower shot — 30 m/s from 25 m — so the single point is indicative of the regime, not a like-for-like check: less distance and less time in the air mean less room for the same spin to bend the ball. Dupeux, Le Goff, Quéré & Clanet, "The spinning ball spiral", New J. Phys. 12 (2010) 093004
- deviationAt8revGain1
- 4.481
- deviationAt10revGain1
- 5.015
- deviationAt8revGain3
- no crossing
- maxDeviation
- 18.932
- goalsOutsideNet
- 0
- rows
- 63
Download JSON · CSV · reproduce: npm run atlas -- curl-vs-spin · seed 1 · config dfa2b5d
Range and loft
The question. From how far, and at what power, can a shot still reach the goal at all?
The grid. 280 flights: 8 powers (0.30–1.00), 7 distances (11–35 m in 4 m steps), 5 contact heights — how far under the ball the striker gets, from a flat 0 to a full −1. Every shot aims at the same spot, x = 0, y = 2.3 m: the middle of the goal, 14 cm under the bar. All of it runs in raw aim mode on purpose: raw points the ball where the crosshair points and leaves gravity to the player, so this is the problem the targeting solver exists to solve.
The reading. Only 72.1 % of the grid reaches the goal plane. The rest die short — not wide, not high, short, the failure mode players never expect.
Contact height is the lever, not power. At full power with a flat contact (0) the ball reaches the plane from 11 through 23 m only; at 27 m and beyond it lands first. Strike the bottom of the ball (−1) at the same power and all seven distances are reachable.
But reaching is not arriving. That same −1 contact crosses 5.05 m up from 11 m and 8.09 m from 35 m — two to three times the height of the bar. Loft buys range and spends accuracy. Nothing on this chart is aimed well; it is only aimed far.
And power barely rescues it. At that 2.3 m mark from 25 m with a flat contact, the finest sweep of the power axis puts the threshold at 0.95 — 95 % of everything you have just to make the ball arrive.
- reachedRate
- 0.721
- minPowerToReach25m
- 0.950
- goalsOutsideNet
- 0
- rows
- 280
Download JSON · CSV · reproduce: npm run atlas -- range-and-loft · seed 1 · config dfa2b5d
Crosshair accuracy
The question. In target mode, how close to the crosshair does the ball actually cross — and how much does raw mode miss by?
The grid. 14,580 flights: 6 ball positions × 9 targets on the goal mouth × 3 contact points × 3 powers, under all 3 presets, in both aim modes, with 5 seeds each. Error is the distance on the goal plane between crosshair and crossing.
The reading. Target mode lands 0.62 m from the crosshair on average; raw mode lands 5.72 m away. That gap — nearly an order of magnitude — is the value of the solver, and why target mode is the default.
Split by preset, target mode gives 0.39 m on Realistic, 0.46 m on Exaggerated and 1.00 m on Arcade. It degrades as the physics gets more violent: at Arcade’s gains a small direction change moves the crossing much further. Raw mode gets worse with more power — 3.51 m Realistic against 6.84 m Exaggerated and 6.55 m Arcade — because a faster ball pointed wrong misses by more.
Why isn’t target mode perfect? The solver iterates to 2 cm, and on Realistic it converges on 99.7 % of the shots that reach the goal (2,321 of 2,329). So the residual 0.39 m there is not the solver missing: it is the shooting noise applied deliberately after the solve — 0.8° of direction and 2 % of power. Take the noise away and this measures the solver’s tolerance, not the game’s feel. The 85.5 % convergence quoted in the summary below is across all three presets together — it is Arcade that drags it down, where only 1,624 of 2,404 crossings (67.6 %) came from a converged solve.
- meanErrM_realistic_target
- 0.385
- meanErrM_realistic_raw
- 3.505
- meanErrM_exaggerated_target
- 0.462
- meanErrM_exaggerated_raw
- 6.843
- meanErrM_arcade_target
- 1.002
- meanErrM_arcade_raw
- 6.547
- targetConvergedRate
- 0.855
- rawMeanErrM
- 5.723
- targetMeanErrM
- 0.623
- goalsOutsideNet
- 0
- rows
- 14580
Download JSON · CSV · reproduce: npm run atlas -- accuracy · seed 1 · config dfa2b5d
Keeper
The question. How much of a save is reaction time, and how much is reading the flight?
The grid. 9,720 flights — the same shot set as the accuracy experiment, all on the Realistic preset in target mode, against four keeper profiles: an elite one (150 ms reaction, perfect read), the game’s default (280 ms, 0.4), a slow-but-clever one (400 ms, 0.7) and a statue (600 ms, no read at all). Reach is held at 0.5 for all four, so the only things moving are reaction and read.
The reading. Save rates come out at 67.9 %, 50.1 %, 50.7 % and 36.9 %.
The middle two are the finding. A keeper 120 ms slower than the default but reading the flight better (0.7 against 0.4) saves at the same rate — 50.7 % against 50.1 %. Reading buys back reaction time, roughly one for one over this range. That is the whole design claim behind having a read slider at all.
Mean estimate error at the moment of commit is worth reading against that: 0 m for the perfect reader, 0.28 m for the default, 0.11 m for the clever one, 0.23 m for the statue. The statue’s number looks respectable — he commits so late that his error has had almost the whole flight to decay — and he still concedes most. Being right at the last instant is not the same as being able to get there.
Two invariants come out of the same run and hold across all 9,720: the keeper’s body is at x = 0 for every simulation step before his reaction time elapses (0 pre-reaction moves), and he never changes his dive target after committing (0 post-commit redirects). He is beatable, but he does not cheat.
- saveRate_0
- 0.679
- meanEstErrAtCommit_0
- 0
- saveRate_1
- 0.501
- meanEstErrAtCommit_1
- 0.277
- saveRate_2
- 0.507
- meanEstErrAtCommit_2
- 0.109
- saveRate_3
- 0.369
- meanEstErrAtCommit_3
- 0.229
- preReactionMoves
- 0
- postCommitRedirects
- 0
- goalsOutsideNet
- 0
- rows
- 9720
Download JSON · CSV · reproduce: npm run atlas -- keeper · seed 1 · config dfa2b5d
Sample trajectories
The question. What do the flights actually look like, step by step?
The grid. 24 flights: eight hand-picked situations — a straight penalty, curled free kicks from either side, a top corner from 25 m, a knuckleball, a low driven shot, a raw-mode aim-off and a penalty against the default keeper — one run per preset. 18 are goals. Unlike the others, these ship full state at every step: position, velocity, spin, 120 samples a second.
The reading. The first chart is the pitch from above: six of the eight Realistic flights, goal line at zero. The penalty barely moves sideways: 0.19 m over 11 m. The free kick from the left starts 8 m wide and crosses 2.55 m the other side.
The knuckleball needs care. It is flagged knuckle, so the wobble term was on, and it crosses 0.32 m off centre from a dead-centre, spinless strike. But the shooting noise alone is that size — the accuracy experiment measures a 0.385 m mean error here — and this flight’s lateral track never once reverses. One shot cannot separate the two: the flag is evidence, the 0.32 m is not.
The second chart puts all eight on the goal mouth at the real 7.32 × 2.44 m, coloured by outcome. The raw-mode aim-off is the dot above the bar at 3.11 m — pointed at the crosshair, nothing correcting for gravity or curl. The penalty the keeper saved has no dot: it never reached the plane, so there is no crossing to draw.
Presets are not difficulty. The top-corner inputs that score on Realistic (2.78 m across, 2.02 m up) miss on Arcade, crossing 5.02 m up — twice the height of the bar. Multiplying power and curve doesn’t make you better, just harder to aim.
- count
- 24
- goals
- 18
- goalsOutsideNet
- 0
- rows
- 24
Download JSON · CSV · reproduce: npm run atlas -- sample-trajectories · seed 1 · config dfa2b5d
Reproduce it
Nothing here is a screenshot of a run that happened once. It regenerates from source:
npm run atlas # all six experiments
npm run atlas -- curl-vs-spin # just one
npm run atlas -- --check # is the committed data still what the code produces?
Output is one JSON and one CSV per experiment in data/atlas/, plus the subset under public/game/atlas/ this page’s downloads point at. Every file carries its seed, its reproduce command and the git SHA it ran at, so a number and its code cannot drift apart unnoticed.
Determinism. A run reads its config, its declared grid and a seed — no clock, no Math.random. What randomness exists in play comes from a seeded generator. Same inputs, same bits, anywhere.
The gate. --check re-runs everything into a scratch directory and diffs it against what is committed, SHA aside. If the physics changed and the data didn’t, it fails with atlas is stale; npm run check:game runs it. Size is capped elsewhere: npm run atlas itself refuses over 15 MB in data/atlas/ or 3 MB in the subset, and check-assets re-checks the 3 MB.
What the subset drops. Only accuracy and keeper, and only their row tables — 14,580 and 9,720 rows this page never charts, since both charts read the summary. The other four are byte-identical; the CSVs carry every row.
One invariant. Every experiment that flies a ball — all but the drag curve, which takes one step — counts goalsOutsideNet: goals whose ball later shows up outside the net. Across all five: 0.