Pocket Physics

Eight-ball against a search-based opponent, on the same rigid-body physics the Python reference is validated against.
Tip offset
3.1 m/s
fine aim Shift slow aim Space shoot
Shot log what the rules engine ruled, and why
  1. Nothing yet. Place the cue ball behind the head string and break.

What you are actually looking at

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.

The cloth model has four regimes

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.

The physics is pinned to closed-form answers

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.

32
physics tests
3 mm
timestep convergence
±0.5°
aim tolerance, measured

The bug the tests could not see

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.

The browser runs the reference, not a lookalike

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.

65×
faster than the reference
156 s
of table time, both ways

The bot is a search, and that is the point

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.

Where the learned model fits, and where it does not

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.

Reproducing all of it

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.