This is not a pool game with physics bolted on. It is a rigid-body simulator that was written first, checked against closed-form mechanics, and then given a table and an opponent so the model can be watched while it runs. Everything below is measured by something in the repository, and the command that measures it is named.
A struck ball does not simply decelerate. While its contact point is slipping against the cloth, friction acts opposite the slip velocity rather than the ball's velocity, and the same force exerts a torque that spins the ball up:
u = v + ω × (−R ẑ), a = −μs g û, α = (−R ẑ × F)/I
Slip decays 3.5 times faster than the centre of mass slows, so the ball settles
into rolling at exactly 5/7 of its launch speed and only then
switches to the much smaller rolling resistance. Watch the
Cue ball, live panel during a shot: the yellow slip curve collapses to
zero and the green speed curve lands on the blue line. That line is a
prediction from the mechanics, not a fitted parameter.
Simulators are easy to make self-consistent and wrong. The test suite compares
the integrator against results derived independently on paper, not against its
own earlier output: the 5/7 rolling speed, the
12v₀²/(49 μs g) sliding distance, spin decay, cushion
restitution, the draw/stun/follow ordering, exact momentum conservation and
strictly decreasing energy.
An earlier version of this repository failed all of that. The contact-point
slip and the friction torque had opposite handedness, so friction drove the
ball away from rolling: measured rolling speed was zero instead of
5/7 v₀, and stopping distance was four times short.
Closed-form checks exercise one ball at a time, so a defect that only exists
between balls survives them. This one did. The solver counted two balls as
touching when the gap between their surfaces was under
1e-4 m — and the rack was built with a 1e-4 m
clearance. All thirty contacts in the triangle sat exactly on the threshold,
and which side each fell on came down to whether hypot rounded up
or down. Sixteen registered. Fourteen did not.
A break propagated through a contact graph with holes in it: balls in the middle of the rack came out of a full-power break having barely moved, and the table opened up less the harder it was struck. It was found by measuring break spread against cue speed and getting the sign wrong, not by a test failing. What fixed it was giving the tolerance a name, using the same one everywhere, and racking the balls actually touching so nothing sits on the boundary. A test now asserts the property that caught it, because it is exactly the kind nobody writes down: a harder break opens the table further.
Measuring that turned up something the fix does not solve, and which is worth stating rather than hiding. A rack is resolved as a chain of about fifteen pairwise collisions, and each one applies restitution, so the survival factor compounds: only 48% of the kinetic energy comes through a 10 m/s break, where the real event is one stress wave that dissipates once. The break you see below opens the table, but less than a real one would. It is the largest known departure from reality here, and fixing it means treating a simultaneous contact set as a single event rather than adjusting a coefficient.
web/js/physics.js is a hand port of
src/pocket/physics/, and a port nobody measures is a rumour.
scripts/export_parity_cases.py runs
35 shots through the Python simulator —
draw, follow, english off two rails, thin cuts, clusters, and full sixteen-ball
breaks — and records where every ball stopped.
web/test/parity.mjs replays them here and compares.
The largest disagreement across all of them is 0.0012 mm — a thousandth of a millimetre, accumulated over seconds of a chaotic sixteen-ball break, and far below the scale at which the two could be said to behave differently. It runs in continuous integration, so they cannot quietly drift apart.
Twenty headless bot-against-bot games walk every branch of the rules, and check on each of the roughly eight hundred thousand physics steps they take that no two balls are ever sharing space and nothing is ever inside a cushion. The worst overlap seen is 0.5 mm on a 57 mm ball, a fifth of a pixel at the size this table is drawn. They also assert that every break moves at least ten of the fifteen — it moves twelve — because a break that clips the apex leaves the rack standing and looks exactly like a limitation of the physics rather than a badly aimed cue.
Aiming a pot needs no learned model: the ghost-ball construction is exact, and a test asserts that aiming at it drops the ball while half a degree either side misses. So the bot spends nothing on aiming. It enumerates every ball-and-pocket pair in closed form, discards whatever is blocked or cut too thin, and spends its whole budget simulating the survivors to see what each one leaves behind.
That is the argument this repository makes about fast physics, running in front of you: the value of a cheap simulation is the number of futures you can afford to look at. The panel above reports how many candidates the bot enumerated, how many it actually simulated, and how much table time that was.
The Python side also trains a surrogate that predicts resting positions directly, at 0.60 ms against 4.6 s for a full rack in the reference simulator. It cuts mean endpoint error from 494 mm to 376 mm against the closed-form baseline it corrects, over 20,000 simulated shots.
The bot above does not use it, and the reason is the more useful result. Ported to the browser the same physics runs 65× faster, which is enough to simulate the shots worth considering exactly. A surrogate is worth its error when you need to screen far more candidates than you can afford to simulate; inside one turn, you do not. Reported against interest: once a ball-ball collision has to be modelled, it loses to plain gradient boosting (696 mm against 610 mm). Every number here is rewritten by the training run.
Nothing here is a screenshot of a number that was true once.
make check # ruff, mypy, the whole test suite
make parity # export shots from Python, replay them in Node
make selfplay # 20 headless games, every rule branch
make browser # load this page in Chrome and play a game
make all # dataset → training → benchmarks → figures
The figures on this page are written by
scripts/site_facts.py from those runs rather than typed in, and
continuous integration fails if they drift. The game itself is dependency-free
ES modules: no bundler, no framework, no build step.
python3 -m http.server in web/ is enough to run
everything you see.