TinyGames · how it works
Pool. Eight-ball style: one cue ball, ten numbered object balls racked in a triangle, six pockets. Point at what you want to hit, pull away from the cue ball to load the shot, let go. Pot all ten before the thirty shots run out.
It is pool specifically, not "cue sports". Carom billiards has no pockets at all and scores by cannons; snooker has fifteen reds and six colours on a table twice this size. Those are different games and they are not in here.
Draft. It is built, compiled and tested like every other game, and it is not
on the hub: "draft": true in build/games.json keeps it off the cards, out of
the sitemap and without a readme page. Take the flag off when it is worth
playing.
Open index.html. No build step, no dependencies, no images — the table is drawn.
cushion/
index.html markup
style.css the frame around the canvas
js/table.js the physics, and the pool rules on top of it
- no pixels, no engine, no DOM
js/cushion.js the playable layer
test-table.js node test-table.js (add -v to see every measurement)
table.ts is split down the middle, with a banner comment at the join.
The physics — a ball, a step with substepping, cushion reflection, equal-mass elastic collision, separation on contact, friction, "has everything stopped". Free functions over plain data. It does not know that pockets exist, and it takes the cloth as an argument rather than reading it from a constant, which is why a test can hand it a table a mile wide and roll a ball out on it.
The pool rules — the rack, the six pockets, potting, the cue ball going down, the shot allowance, the score. All of it built on top; none of it reachable from below.
That line is a rule rather than a tidiness preference. Two more cue games are to
be built on the same physics, and a single Table.POCKET inside a collision
routine would turn lifting it out into a rewrite instead of a move. The tests are
split the same way and for the same reason: the physics is checked against
conservation laws and bounds that hold for any cue game, the pool rules
separately.
A ball travels up to 520 units a second and is 2.25 wide. Advance it a whole frame at a time and it moves nearly nine units — four ball widths — so a ball standing in its path is behind it by the time anything looks, and a cushion twenty units away has been crossed without being touched. Both faults look identical on screen.
step() therefore cuts each frame into as many equal slices as it takes for no
ball to move more than a quarter of a radius in any one of them, and does the
cushion and contact work in every slice.
The obvious way to size that is from the fastest ball at the start of the frame. That is not enough, and the reason is a small piece of physics: a collision can leave one ball faster than either ball went in. It keeps its own across-the-centres component and takes the other's along-the-centres one, so up to a factor of √2 for a pair, and more in a crowd. Size the slice from the fastest ball and the slice itself can beat the number.
What cannot be beaten is the root of the sum of the squares of the speeds. That is the kinetic energy; an elastic collision leaves it exactly alone, a perfectly elastic cushion leaves it alone, and friction only ever takes from it. So a budget worked out once, before the first slice, holds for every slice in it.
The test asserts exactly that, substep by substep, across 21,000 slices of a crowded table: no ball ever exceeded the figure its slice started with, and none ever moved a whole radius in one substep (worst observed: 0.64 of 1.125).
| rebuilt mistake | what the test sees |
|---|---|
| one fixed step per frame, ball at a ball | goes clean through in 157 of 720 shots |
| one fixed step per frame, ball at a cushion | reports 1 bounce where the path demands at least 3 |
| pockets checked once a frame, not per substep | 19 of 60 balls roll straight over the pocket |
The gap between the two balls is swept rather than picked, because whether the naive version misses depends on where in its stride the target happens to fall — one lucky example would prove nothing either way. The cushion figure is not an eyeball: a ball in a strip of usable width W travelling a path of length P must touch a cushion at least ⌊P/W⌋ − 1 times, and its path is at least (v − a·dt)·dt.
Everything below is a property with an independent calculation on the other side of it, not a screenshot somebody approved.
An equal-mass elastic collision exchanges the velocity components along the line of centres and leaves the ones across it alone. Twenty thousand random pairs, sums computed in the test rather than asked of the module:
| quantity | worst drift over 20,000 pairs |
|---|---|
| momentum (vector) | 5.7 × 10⁻¹⁴ |
| kinetic energy | 1.9 × 10⁻¹⁵ relative |
| across-the-centres component | 1.1 × 10⁻¹³ |
Three negative controls, because those are three different assertions and each needs its own proof that it is live. An inelastic hit still conserves momentum. Reversing both balls still conserves energy. Swapping the whole velocity vectors passes both laws and is still wrong — which is what the third row is for.
The perpendicular component flips, the parallel one survives, so the speed comes out identical to the last bit — not close, identical, because the only arithmetic is a change of sign. Angle in equals angle out to 0.0, measured from dot products against the wall's normal rather than by restating that sign.
Two negative controls: a cushion that absorbs a fifth fails the speed test, and one that flips both components passes the speed test and the angle-to-normal test and is still wrong.
Friction takes a fixed amount off the speed per unit of time, which is what a rolling ball does and — unlike a per-frame multiplier — what actually arrives at zero. So a ball at v travels v²/2a and stops after v/a seconds, and both are checked against a ball rolled out on a cloth a mile wide.
Stepped integration does not land exactly on the paper answer, and the discrepancy has a paper answer too: each step overshoots by a·h²/2 and never undershoots, which over the whole roll is at most a relative a·dt/v. Both halves of that are asserted — never short, never past the ceiling — rather than a tolerance somebody liked the look of. On the fast rolls the substepping holds the error to 53% of what a fixed step would have cost, and the same shot played at 1/30 and at 1/240 lands 0.26 units apart where a fixed step would have differed by 4.4.
Write E for the sum of the squares of the speeds. Friction drops √E by at least DECEL times the elapsed time; a collision leaves E alone and so does a cushion. √E starts at no more than MAX_SPEED because only the cue ball is moving. So everything is at rest within MAX_SPEED/DECEL seconds — 225 frames.
Two thousand random breaks. All 2,000 settled inside 225 frames and the slowest took 220 of them, which is the part that matters: the bound is a bound, not a big number that seemed to work. A build that quietly stopped applying friction would sail past a number chosen the other way.
Two claims, kept separate on purpose. The arithmetic guarantees no two balls overlap by more than a quarter of a radius — a ball may travel a quarter of a radius per substep, so two can close half a radius before anything looks, and putting a ball back inside a cushion after a separation gives a little of that back. What the two rounds of separation achieve is 1.3 × 10⁻¹⁴, eleven orders of magnitude better.
Both are asserted, so a change that stayed inside the guarantee but stopped converging still gets noticed instead of hiding in a margin nobody was using. Across 39,000 steps of a ten-ball table at three step sizes, no ball ever ended a step outside the cushions either — and balls really did touch during 2,389 of those steps, so the check is live.
Ten mutants: the shipped, minified file with one thing changed, loaded fresh into a scope of its own and put through the same 114 assertions.
| mutation | result |
|---|---|
| no substepping — a whole frame in one go | caught (7 assertions) |
| the cloth stops slowing anything | caught (7) |
| cushions absorb a fifth of the speed | caught (3) |
| collisions are inelastic | caught (1) |
| overlaps are left where they are | caught (3) |
| balls give up while still rolling | caught (3) |
| the speed limit is lifted | caught (4) |
| pockets shrink to nothing | caught (6) |
exported collide() made inelastic |
caught (1) |
step() wrapped, behaviour unchanged |
passes, as it should |
Eight of them are constants patched in the minified text. Terser mangles
local names but leaves object keys and number literals alone, so DECEL:140
survives the build and is a stable thing to reach for; each patch is checked for
having applied exactly once, so a mutation that quietly failed to take can never
be reported as one nothing caught. The ninth replaces an exported function, which
reaches the tests that call it directly but not step()'s own use of it — said
plainly rather than left to be assumed.
Two of the tests were wrong before the code was. The roll-out test compared against v²/2a with a flat 2% tolerance and failed at 2.495%; the number was right and the tolerance was a guess, so it was replaced with the derived a·dt/v. The scoring monotonicity test swept from zero, across the floor where the score cannot go lower — the same shape of mistake as Rockfall's vertical-push test, and a scoring function that had lost an argument entirely would have passed it just as quietly.
$ node test-table.js
114 passed, 0 failed
Point at the table where you want the cue ball to go. The aim runs from the cue ball towards your finger — you point at what you want to hit — and how far away your finger is sets the power, so the whole shot is one gesture and there is no second control to find. Let go to play it. Sliding off the canvas, or the browser taking the pointer for a gesture, abandons the aim rather than firing it.
On a keyboard: left and right turn the aim, up and down set the power, space or
enter plays the shot. Escape or P pauses, and the R button starts over.
Potting the cue ball is a scratch: it costs a foul and the cue ball is spotted back in baulk, on the head spot if it is free and walked back towards the baulk cushion until something is. The run ends when the last object ball goes down, or when the thirty shots are gone.
Advertisement