The aim projection is real geometry, not a decoration: move the pointer over the table and it ray-casts against every ball and every cushion, places a ghost ball at the true contact point, and draws where the object ball will actually leave. Click to take the shot.
Every frame it does the same three things a player does, in the same order.
| 1 · Cast the ray | From the cue ball centre toward the pointer. Solve the quadratic against every other ball at a separation of two radii, and against the four cushion lines inset by one radius. Take the smallest positive root |
| 2 · Place the ghost | At the cue ball's centre at the moment of contact — not at the object ball. The gap between ghost and object is the cut, and showing it is the whole reason a beginner ever learns to cut |
| 3 · Split the paths | The object ball leaves along ghost-centre to ball-centre. The cue ball leaves along the tangent, perpendicular to that. Both drawn, both dimmer than the aim line, because they are predictions rather than instructions |
| If a cushion comes first | Reflect and draw one bounce. One, not three — a three-rail projection is a solver, and giving a player a solver stops it being a skill game |
| If the line ends in a pocket | The pocket ring lights. This is the only positive feedback on the table and it should stay that way |
| Spin widget | Sets the contact point on the cue ball. It does not alter the projection — english changes what happens after contact, and drawing it into the aim line would be a lie |
The one line that matters more than the geometry. Under the waiting overlay: your stake is locked until the 8 falls, nothing settles before then. A player who has just watched an opponent pot four in a row needs to know the money has not already gone. That sentence belongs on the screen where the doubt actually occurs, not in a help page.
Skill, on purpose. There is no assist toggle, no auto-aim, no three-rail solver. Beyond the legal argument — a game of pure skill sits outside the Criminal Code's lottery definition, and that argument is doing real work for you in Canada — an aim assist strong enough to matter would also make every match a test of who bought the better assist. One bounce, no english in the projection, and the cut left for the player to judge.
Clicking now runs a real simulation. It is deterministic on purpose, and that purpose is not only technical.
| Integration | Fixed step at 1/240 s, sub-stepped so no ball moves more than 2 units at a time. Nothing tunnels through a cushion or through another ball, however hard it is hit |
| Cloth | Rolling deceleration of 340 units/s², a rest threshold at 14. A ball does not asymptotically creep — it stops |
| Restitution | 0.86 off a cushion, 0.94 ball on ball. Equal masses, so a collision exchanges the normal component and leaves the tangential alone |
| Pockets | Cushion noses have real gaps. A ball crossing a wall inside a gap latches into the throat and cushions stop applying to it entirely. Without that latch a ball in the throat gets mirrored back onto the table — which is exactly the bug the invariant suite caught |
| English | Spent on the cue ball's first contact and nowhere else, as one impulse, applied along the incoming line rather than what is left of the cue ball's velocity. On a full hit the cue ball nearly stops — which is precisely when draw should bite hardest, and it only does if you measure from the incoming line |
| Collision order | Resolved in stable index order. Simultaneous three-ball contacts are rare but real, and an unstable order is the classic way a physics engine stops being reproducible |
| Output | Frames at 60 Hz for playback, plus an event log of every cushion, hit and pot with timestamps. The renderer never touches the simulation |
Determinism is your fairness proof, and it is a better one than commit-reveal. RunéLTC needs a sealed draw because it has randomness to hide. CueLTC has none: a shot is an angle, a power and a contact point, and the same three numbers always produce the same table. So the verifiable artifact is not a hash — it is the shot log. Anyone can re-run a whole match from it and land on the identical final position, which means a disputed frame is settled by replay rather than by trusting you. Press Verify determinism above: it runs the same descriptor twice and compares the full serialised output.
It is also the legal argument, made in code. The pure-skill position in Canada turns on there being no element of chance anywhere in the outcome. A physics engine with a random number generator in it — even one seeded for jitter or a break scatter — is a chance element a regulator can point at. There is no RNG in this file. That is worth keeping true, and worth being able to demonstrate in one click when counsel asks.
Every verdict below is read off the event log, mechanically. No branch is a judgement call, because a judgement call is a dispute and a dispute is a payout someone contests.
| Open table | Until a ball is legally pocketed, neither player has a group. The strips read "Open table" rather than guessing |
| Assignment | The group of the first ball legally pocketed after the break. The other player gets the complement |
| Legal first contact | Your group. On an open table, anything but the 8. Once your group is cleared, the 8 |
| Rail requirement | After contact, something must be pocketed or some ball must reach a cushion. Read from cushion events timestamped at or after the first contact — a rail struck before contact does not count |
| Fouls | No ball struck, cue ball potted, wrong group struck first, the 8 struck early, no rail after contact. All of them hand ball in hand to the opponent |
| The 8 settles the frame | Cleared and no foul, you win. Anything else — potted early, or potted on a foul — and you lose. The escrow settles on that single return value |
| Not modelled | Called pockets, break requirements, the 8 on the break, three-foul rules, and choosing a group when a solid and a stripe drop on the same open-table shot (this takes the first pot in time). Each is a rule to add, none needs randomness |
The loop is symmetric. There is no "you" branch and no "them" branch — one function, one state object, a shooter field.
advance(state, shot) | The whole game in one pure function. No DOM, no globals, no clock. The browser runs it to show the shot, the Worker runs it to settle, a test runs it headless. If those three ever disagree, the platform is broken |
| Turn handoff | Foul or dry shot passes the turn. A legal pot of your own group keeps you at the table. Both read straight off the verdict |
| Ball in hand is signed | The placement travels inside the shot descriptor. If it lived only on the client, two replays of the same log would put the cue ball in two different places and the chain would fork on an honest player |
| Scratch | A potted cue ball leaves the state with no cue at all. The next shot cannot be accepted without a placement — the protocol makes the illegal state unrepresentable rather than guarding against it |
validate(rack, log) | What the Worker runs at settlement. Replays the whole frame, returns the winner and the hash chain, and takes the client's word for nothing. Also reports trailing shots — a log with moves after the 8 fell is a tampered log |
| Hot seat | Turn it on in the controls and both sides are playable in one window. Not a shipping feature — it is how you confirm the loop is symmetric without two devices |
A match is an opening rack plus an ordered list of shot descriptors. Nothing else needs storing, and nothing else needs trusting.
| A shot is five numbers | dx, dy, power, spinX, spinY — quantised to six decimals so the submitted values are exact rather than approximately re-parsed |
| Direction, not angle | The client does the trig once and submits a vector. The simulation calls no trigonometric function at all |
| Only exact arithmetic inside | + - * / and Math.sqrt, all IEEE-754. Math.hypot, cos, sin and pow are implementation-approximated in the spec and may legally differ between engines — Math.hypot(3.3,4.7) and Math.sqrt(3.3²+4.7²) already disagree in the last bits inside one engine |
| Canonical state | Balls sorted by number, coordinates at six fixed decimals. Serialisation order is not allowed to be a source of divergence |
| Hash chain | Each state hashed, each shot advancing the chain. A validator re-runs the log and must land on the same final hash |
| Hash used here | FNV-1a in two lanes, which is fine for a client-side demo and wrong for settlement. Use SHA-256 on the Worker |
Why this matters more here than in an ordinary game. The client cannot be trusted with the outcome when the outcome moves money. But it does not have to be: the client submits five numbers per shot, the Worker re-runs the identical function, and any disagreement is the client lying. The bandwidth is trivial — a whole frame of 8-ball is a few hundred bytes — and the validator needs no state beyond the opening rack. The cross-engine hazard above is the one that would actually bite you: a player on an iPhone and a validator on V8 diverging on Math.sin would produce honest players being flagged as cheats, which is worse than missing a cheat.
| Table space | Same 1360 × 760 units as Tetlow No. 1, 10 units per inch. Playfield 1000 × 500 inset at 180, 130 |
| Ball radius | 11.3 units. Contact separation 22.6, cushion inset 11.3 |
| Pointer mapping | SVG getScreenCTM().inverse(), so it stays correct at any size and on any device pixel ratio |
| Recompute | Aim layer only, on pointermove. The table, rails and pockets are built once and never touched again |
| Shot clock | One setInterval at 1 Hz driving stroke-dashoffset. No animation frame loop anywhere |
| Playback | Simulate once, record, then play frames back at 60 Hz. The server can run the identical function to validate a submitted shot |
| Not yet modelled | Called pockets, the 8 on the break, jump and masse shots, throw and cut-induced spin. All of them are rules or refinements on top; none of them needs randomness |
| States | data-turn you / them, data-hand for ball in hand, data-note for the foul toast |