The Standalone Physics Worker (worker.js)
runs the billiards physics engine in a Web Worker thread. Source:
github.com/tailuge/billiards
— see
src/worker.ts.
// Preload the worker once at page load (not per simulation)
const worker = new Worker('https://tailuge.github.io/billiards/dist/worker.js');
// Listen for checkpoint messages during init
worker.onmessage = (e) => {
if (e.data.type === 'CHECKPOINT') {
console.log('Worker checkpoint:', e.data.label);
}
if (e.data.type === 'COMPLETE') {
const { frames, outcomes, computeTime } = e.data;
// process results...
}
if (e.data.type === 'ERROR') {
console.error(e.data.error, e.data.stack);
}
};
// Reuse the same worker for multiple simulations
worker.postMessage(configA);
// ...wait for COMPLETE...
worker.postMessage(configB);
| type | When | Key fields |
|---|---|---|
CHECKPOINT |
At each lifecycle stage | label, t (performance.now) |
COMPLETE |
Simulation finished |
frames[], outcomes[],
computeTime, tableX, tableY
|
ERROR |
On failure | error, stack |
worker.js loaded → Inputs received →
Constants set → Table geometry set →
Strike applied → Iteration progress (×N) →
Simulation loop complete → COMPLETE
{
ruleType: "threecushion" | "snooker" | "eightball",
balls: [{ id: number, pos: { x, y, z } }, ...],
cushionModel: "mathavan" | "stronge",
shot: {
cueBallId: number,
angle: number, // radians
power: number, // m/s impulse scale
offset: { x, y }, // spin offset from centre (max ±0.5 of R)
elevation: number // cue elevation radians
},
stepSize: number, // simulation time step (default 0.001953125)
maxIterations: number, // hard cap (default 200000)
warpClearanceR: number, // warp clearance in units of R (default 4). Set ~50 to disable warp.
params: { ... } // physics constant overrides (see below)
}
params)
Pass any of these keys in params. The key maps to
set<key>(value) in
constants.ts.
| Key | Default | Description |
|---|---|---|
mu |
0.007 | Rolling friction (Han) |
muS |
0.136 | Sliding friction (Han) |
rho |
0.035 | Spindown rate (Han) |
m |
0.23 | Ball mass (kg) |
R |
0.03275 | Ball radius (m) |
e |
0.86 | Ball-ball coefficient of restitution |
ee |
0.84 | Cushion coefficient of restitution (Mathavan) |
μs |
0.2 | Table friction coefficient (Mathavan) |
μw |
0.2 | Cushion friction coefficient (Mathavan) |
StrongeOmegaRatio |
1.847 | Stronge omega ratio |
StrongeEN |
0.979 | Stronge normal restitution |
StrongeMu |
0.161 | Stronge friction coefficient |
worker.js loaded checkpoint fires immediately — do not
wait for a simulation to confirm it loaded.
onmessage fires.
frames[] can contain 1000+ entries. Slice before display
or process fields directly.
R or m triggers a refresh() of
derived constants (Mz, Mxy, I). Set them before other params that
depend on derived values.
Instantiate multiple workers to perform concurrent simulations. Use the
id field to correlate out-of-order responses.
const workerCount = 4;
const workers = Array.from({ length: workerCount }, () => new Worker('dist/worker.js'));
async function runParallel(tasks) {
const promises = tasks.map((task, i) => {
return new Promise((resolve, reject) => {
const worker = workers[i % workerCount];
const correlationId = task.id || `task-${i}-${Date.now()}`;
const handler = (e) => {
if (e.data.id === correlationId && (e.data.type === 'COMPLETE' || e.data.type === 'ERROR')) {
worker.removeEventListener('message', handler);
e.data.type === 'COMPLETE' ? resolve(e.data) : reject(e.data);
}
};
worker.addEventListener('message', handler);
worker.postMessage({ ...task, id: correlationId });
});
});
return Promise.all(promises);
}
The core worker.js logic can be executed seamlessly in
Node.js using node:worker_threads by injecting a tiny
global polyfill wrapper. This avoids the need for environment-specific
code in the physics engine.
// node-runner.js
import { Worker } from 'node:worker_threads';
const nodeFriendlyWrapper = `
const { parentPort } = require('node:worker_threads');
// Polyfill the Web Worker global context
global.self = global;
global.postMessage = (data) => parentPort.postMessage(data);
parentPort.on('message', (data) => {
if (global.onmessage) global.onmessage({ data });
});
// Load your untouched production worker
require('./dist/worker.js');
`;
const worker = new Worker(nodeFriendlyWrapper, { eval: true });
For optimization tasks (e.g., searching for a specific shot), use the
multi-platform ww.js module. It provides a
runBatch() function that manages a worker pool and
aggregates results.
import { runBatch } from './ww.js';
// Execute 3 simulations in parallel with angle variance
const results = await runBatch(baseConfig, 3, 0.001745);
results.forEach(res => console.log(`Outcome: ${res.outcomes.length} events`));
The same logic runs in Node.js using node:worker_threads.
The SimulationRunner in ww.js automatically
detects the environment and polyfills the global
self context for the physics engine.
To run the headless batch example:
# From the project root node dist/ww-node.js
This is the preferred entry point for LLM agents performing large-scale trajectory analysis or parameter tuning without a browser environment.
When all rolling balls are well clear of cushions and other balls (by at
least warpClearanceR × R meters) and moving above a minimum
speed, the simulation advances by a larger dt instead of
one small stepSize step. This skips uneventful roll
segments and reduces iteration count, giving a
speedup factor (shown in the log as
speedup 2.50x).
Set warpClearanceR to a value larger than the table
half-width (~50) to disable warping entirely — all
segments will use the base stepSize.