From 422356974b3e74abb85ebdb21d90f0728e716694 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 13 Aug 2026 00:46:26 +0000 Subject: [PATCH 1/2] chore: anchor the build/ ignore rule to the repository root setuptools writes ./build, but the pattern also matched web/build, which is source. Anchoring it keeps the artefact ignored and the source trackable. Co-authored-by: BruceMoseti --- .gitignore | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 02134a7..df43fbb 100644 --- a/.gitignore +++ b/.gitignore @@ -2,8 +2,9 @@ __pycache__/ *.py[cod] .venv/ venv/ -dist/ -build/ +/dist/ +# Anchored: setuptools writes ./build, but web/build holds source. +/build/ *.egg-info/ .pytest_cache/ .mypy_cache/ From 8f9cdc0418fe1a8fe523816d4a87c2dedd48030e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 13 Aug 2026 00:46:26 +0000 Subject: [PATCH 2/2] feat(web): add play.html, the whole game in one openable file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page is deliberately unbundled, which is why it needs no toolchain and also why it cannot be opened from a disk: a module graph over file:// is refused, so double-clicking web/index.html gets you a blank page. Until a published copy exists there is no way to play without running a server. play.html is the same markup with the stylesheet, the measured figures and all nine modules inlined. It is a concatenation rather than a bundle, which is legal for reasons the build checks instead of assuming: the imports are named and static, the graph is acyclic, and no two modules declare the same top-level name. Any of those failing stops the build with the offending name rather than shipping a file that loads and then misbehaves. It is generated, so CI rebuilds it, compares it against the committed copy, and then plays a full game and the whole input suite from file:// — a copy of the game that has quietly rotted is worse than no copy. Co-authored-by: BruceMoseti --- .github/workflows/ci.yml | 12 + Makefile | 6 +- README.md | 7 + play.html | 4109 ++++++++++++++++++++++++++++++++++++++ web/build/standalone.mjs | 154 ++ web/js/facts.js | 18 +- 6 files changed, 4298 insertions(+), 8 deletions(-) create mode 100644 play.html create mode 100644 web/build/standalone.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 674130a..b0e7b5e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,6 +85,18 @@ jobs: - name: Drive the page with a real cursor and keyboard run: node web/test/input.mjs --url http://localhost:8123/index.html + # play.html is the same game inlined into one file, for opening from a + # disk with no server. It is generated, so it can go stale, and it loads + # under file:// where fetch is refused — so it is rebuilt, compared, and + # then actually played from a file rather than trusted. + - name: Check the one-file build still matches web/ + run: node web/build/standalone.mjs --check + + - name: Play the one-file build straight off the disk + run: | + node web/test/browser.mjs --url "file://$PWD/play.html" --games 1 + node web/test/input.mjs --url "file://$PWD/play.html" + pipeline: # Proves the data -> train -> export -> serve path still works end to end, # on a dataset small enough to finish in a couple of minutes. diff --git a/Makefile b/Makefile index 36e0c54..f36adab 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ .PHONY: help setup test lint typecheck check train bench figures facts api ui play \ - parity parity-check selfplay browser input capture web clean all + parity parity-check selfplay browser input capture standalone web clean all .DEFAULT_GOAL := help PY ?= python3 @@ -46,6 +46,10 @@ browser: ## Load the page in Chrome and play a game (needs puppeteer-core) capture: ## Re-record the screenshots and the clip in the README $(NODE) web/test/capture.mjs --url http://localhost:$(PORT)/index.html +standalone: ## Rebuild play.html, the whole game in one openable file + $(NODE) web/build/standalone.mjs + $(NODE) web/test/browser.mjs --url file://$(CURDIR)/play.html --games 1 + web: parity selfplay ## Every check that does not need a browser play: ## Serve the game at http://localhost:$(PORT) diff --git a/README.md b/README.md index 8904350..86c9b4c 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,13 @@ closed-form references. "Quick" playback the page offers, with the bot's search pauses capped at 0.8 s. The shots are the game's, not an animation.* +**Three ways in, none of which need a toolchain.** The link above, `make play` +to serve `web/` yourself, or `play.html` — the whole game inlined into one file +that plays when you open it from a disk, offline, with no server. Download it +from the repository root and double-click it. `web/build/standalone.mjs` builds +it, and CI plays a full game from `file://` on every push so the copy cannot +quietly rot. + No install, no build step, no framework — `web/` is plain ES modules, and the whole thing is served as static files. Aim with the mouse, drag back from the cue ball to strike, click the cue-ball diagram to move the tip off centre. Every diff --git a/play.html b/play.html new file mode 100644 index 0000000..4f32de0 --- /dev/null +++ b/play.html @@ -0,0 +1,4109 @@ + + + + + + + Pocket Physics — play the simulator + + + + + +
+
+

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.
  2. +
+
+
+ + +
+ +
+

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. +

+
+
+ +
+ Physics, rules, rendering and the opponent are in + web/js/; the reference simulator, the validation suite and the + learned surrogate are in src/pocket/. Fouls follow standard eight-ball: + hit your own group first, drive a ball to a rail, and the eight goes last. + Back to the table ↑ +
+
+
+ + + + diff --git a/web/build/standalone.mjs b/web/build/standalone.mjs new file mode 100644 index 0000000..88fff8d --- /dev/null +++ b/web/build/standalone.mjs @@ -0,0 +1,154 @@ +#!/usr/bin/env node +/** + * Build one HTML file that plays the game with no server and no network. + * + * The page itself is deliberately unbundled: `web/` is plain ES modules served + * as static files, which is the whole reason it needs no toolchain. That is also + * why it cannot be opened from a disk — a module graph over `file://` is refused + * by the browser, so double-clicking `web/index.html` gets you a blank page. + * This produces the one artefact that does work that way: the same markup with + * the stylesheet, the measured figures and every module inlined. + * + * It is a concatenation rather than a bundle, which is legal here for reasons + * that are checked rather than assumed: the imports are all named and static, + * the graph is acyclic, and no two modules declare the same name at the top + * level. Any of those stopping being true fails the build with the offending + * name, instead of producing a file that loads and then misbehaves. + * + * node web/build/standalone.mjs write play.html + * node web/build/standalone.mjs --check fail if play.html is out of date + */ + +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const WEB = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const ROOT = resolve(WEB, ".."); +const OUTPUT = join(ROOT, "play.html"); +const ENTRY = "main.js"; + +const IMPORT = /^import\s*\{([\s\S]*?)\}\s*from\s*"\.\/([\w.-]+)";?[ \t]*\n/gm; +const DECLARATION = /^(?:export\s+)?(?:async\s+)?(?:function|class|const|let|var)\s+([A-Za-z_$][\w$]*)/gm; + +const read = (...parts) => readFileSync(join(WEB, ...parts), "utf8"); + +/** Modules in the order they must run: every dependency before its importer. */ +function moduleOrder(entry) { + const done = []; + const visit = (name, stack) => { + if (done.includes(name)) return; + if (stack.includes(name)) { + throw new Error(`import cycle, which a concatenation cannot express: ${[...stack, name].join(" -> ")}`); + } + for (const [, , dependency] of read("js", name).matchAll(IMPORT)) { + visit(dependency, [...stack, name]); + } + done.push(name); + }; + visit(entry, []); + return done; +} + +/** + * Turn a module into a fragment of the shared scope. + * + * Imports become nothing, because the names they ask for are already declared + * further up the file — except renaming ones (`x as y`), which become the + * binding they promised. + */ +function flatten(name, source) { + for (const forbidden of ["import.meta", "import("]) { + if (source.includes(forbidden)) { + throw new Error(`${name} uses ${forbidden}, which does not survive inlining`); + } + } + let body = source.replace(IMPORT, (_, names) => { + const aliases = names + .split(",") + .map((entry) => entry.trim().match(/^([A-Za-z_$][\w$]*)\s+as\s+([A-Za-z_$][\w$]*)$/)) + .filter(Boolean) + .map(([, from, to]) => `const ${to} = ${from};`); + return aliases.length ? `${aliases.join("\n")}\n` : ""; + }); + body = body.replace(/^export\s+(?=(?:async\s+)?(?:function|class|const|let|var)\s)/gm, ""); + return `// ---- web/js/${name} ----\n${body.trim()}\n`; +} + +function build() { + const names = moduleOrder(ENTRY); + + const owners = new Map(); + const fragments = []; + for (const name of names) { + const source = read("js", name); + for (const [, declared] of source.matchAll(DECLARATION)) { + const existing = owners.get(declared); + if (existing) { + throw new Error( + `${name} and ${existing} both declare "${declared}" at the top level, ` + + `so they cannot share one scope. Rename one of them.` + ); + } + owners.set(declared, name); + } + fragments.push(flatten(name, source)); + } + + const script = fragments.join("\n"); + const css = read("css", "style.css"); + // The figures in the prose are fetched from a file the browser will not read + // over file://, so they travel with the page instead. + const facts = read("data", "facts.json").trim(); + + for (const [what, text, closing] of [ + ["the modules", script, ", which would end the tag early`); + } + + const html = read("index.html") + .replace( + /^ *\n/m, + ` \n` + ) + .replace( + /^ *\n` + ) + .replace( + /^\n/, + "\n\n" + ); + + for (const [what, marker] of [ + ["the stylesheet", "