Web worker integration example

LLM Agent Integration Guide

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.

1. Integration Pattern

// 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);

2. Message Types (Worker → UI)

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

3. Checkpoint Labels (in order)

worker.js loaded → Inputs received → Constants set → Table geometry set → Strike applied → Iteration progress (×N) → Simulation loop complete → COMPLETE

4. SimulationConfig Schema

{
  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)
}

5. Settable Physics Constants (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

6. Best Practices

7. Parallel Search Pattern

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);
}

8. Universal Runtime Architecture (Web & Node)

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 });

9. High-Scale Parallelism & Search

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`));

10. Headless Node.js Execution

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.

11. Time-Warp Optimization

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.