@lagless/pinball-physics
v0.0.77
Published
TypeScript binding for the [`pinball-physics-rs`](../../pinball-physics-rs/README.md) engine - the from-scratch deterministic 2D physics core (Rust -> WASM) for **fast ball(s) + rich static geometry** (dynamic circles vs static circle / segment / capsule
Readme
@lagless/pinball-physics
TypeScript binding for the pinball-physics-rs engine - the
from-scratch deterministic 2D physics core (Rust -> WASM) for fast ball(s) + rich static geometry
(dynamic circles vs static circle / segment / capsule / rectangle colliders). It wires the engine into
the Lagless ECS as a new simulationType: 'pinball' world, the same way @lagless/physics2d wires
Rapier - but with flat-memcpy snapshots and a ~98 KB binary (no JS glue).
What's in here
| Export | Role |
|---|---|
| WasmPhysicsWorldManager | Drives the engine; implements IPhysicsWorldManagerBase (takeSnapshot/restoreSnapshot), so it drops into the unchanged PhysicsSimulationBase. |
| PinballRunner | ECSRunner subclass that constructs the manager + PhysicsSimulationBase (Variant D). The codegen simulationType: 'pinball' runner extends this. |
| instantiatePinballPhysics(bytes) | Engine-agnostic loader (browser + node) -> PinballPhysicsExports. |
| layout helpers / constants | EXPECTED_WORLD_LEN, BODY_*, SENSOR_FLAG, REPORT_CONTACTS_FLAG, EVENT_BEGIN/END, column offsets. |
| ContactEvent | Solid-contact begin/end event shape (entities + impulse + normal + point), drained per tick. |
How snapshot/rollback works (Variant D)
PhysicsSimulationBase already keeps a parallel SnapshotHistory<Uint8Array> next to the ECS
buffer, writes both in saveSnapshot, restores both in rollback, and ships the combined
[ecsLen][ecs][phys] blob for late-join. WasmPhysicsWorldManager only implements the two-method
seam:
takeSnapshot()-> a detached copy of the world region (new Uint8Array(mem, ptr, len).slice()).restoreSnapshot(data)-> an in-placeset()over the same region (buffer identity preserved, so cached typed-array views stay valid; the engine never grows memory, so they never detach).
A runtime drift guard asserts phys_world_len() === EXPECTED_WORLD_LEN in the manager
constructor - a Rust/TS layout mismatch fails loudly instead of corrupting reads (the offsets in
layout.ts mirror the Rust World struct and must be kept in sync).
Usage
Direct (the demo path)
import {
WasmPhysicsWorldManager,
instantiatePinballPhysics,
BODY_DYNAMIC,
BODY_STATIC,
SENSOR_FLAG,
EVENT_BEGIN,
} from '@lagless/pinball-physics';
import wasmUrl from '@lagless/pinball-physics/wasm/pinball_physics.wasm?url'; // Vite ?url, or fetch()
const exports = await instantiatePinballPhysics(await fetch(wasmUrl).then((r) => r.arrayBuffer()));
const phys = new WasmPhysicsWorldManager(exports);
phys.init(0, -14, seed); // +y is up
phys.createSegment(0, 0, 8, 0, 0.6, 0.3, 0, 1); // a wall
phys.createCapsule(2, 3, 5, 3, 0.2, 0.6, 0.15, 0, 300); // a thick static rail (segment core + radius)
phys.createRectangle(4, 7, 1.1, 0.2, 0.35, 0.6, 0.15, 0, 400); // a tilted oriented-box obstacle
const bumper = phys.createBody(BODY_STATIC, 4, 9, 0, 0.5, 1.4, 0.1, 100); // restitution>1 = pop
const drain = phys.createBody(BODY_STATIC, 4, 1, 0, 0.8, 0, 0, 200);
phys.setBodyFlags(drain, SENSOR_FLAG); // sensor zone
const ball = phys.createBody(BODY_DYNAMIC, 4, 11, 1, 0.25, 0.35, 0.2, 1000);
// per tick:
phys.step(1 / 60);
for (const e of phys.drainSensorEvents()) {
if (e.kind === EVENT_BEGIN && e.sensorEntity === 200) onDrain(e.visitorEntity);
}
// render with interpolation: lerp(phys.prevPositionX(ball), phys.positionX(ball), alpha)Solid-contact events (drainContactEvents)
Sensors report overlaps; contact events report solid hits (the ball bouncing off a bumper,
wall, capsule, or rectangle). They are opt-in so they don't spam on every wall: flag a collider
with REPORT_CONTACTS_FLAG and an event fires whenever a pair where at least one side is flagged
begins or ends touching (like Rapier's ActiveEvents).
import { REPORT_CONTACTS_FLAG, EVENT_BEGIN } from '@lagless/pinball-physics';
phys.setBodyFlags(bumper, REPORT_CONTACTS_FLAG); // bodies via setBodyFlags
phys.createSegment(0, 0, 8, 0, 0.6, 0.3, REPORT_CONTACTS_FLAG, 1); // segments/capsules/rectangles via their flags param
// per tick, after step():
for (const e of phys.drainContactEvents()) {
if (e.kind === EVENT_BEGIN) {
playHit(e.aEntity, e.bEntity, e.normalImpulse); // impulse ~ hit strength (e.g. SFX volume)
spark(e.pointX, e.pointY, e.normalX, e.normalY); // contact point + normal
}
}Begin/end edges (one event per hit), CCD-correct (counted per substep), and rollback-safe (the
touching-pair set lives in the snapshot). END events carry zero impulse/normal/point - switch on
kind, don't read an END as a zero-impulse hit. Wrap them in Lagless Signals yourself if you want
predicted/verified/cancelled streams; the engine only exposes the drain.
As a Lagless ECS world (simulationType: 'pinball')
In your ecs.yaml:
simulationType: pinball # auto-prepends Transform2d + PinballRefs(bodyId: uint32) + PinballBodyFilter
components:
Score:
points: uint32pnpm exec nx g @lagless/codegen:ecs --configPath <game>/<game>-simulation/src/lib/schema/ecs.yaml
generates a <Game>Runner extends PinballRunner that takes the wasm exports. A physics-step system
writes kinematic poses into the manager's views, calls phys.step(dt), reads dynamic poses back
into Transform2d (setting prev* for interpolation), and drains sensor events into Signals.
Manager API (summary)
init, step, createBody, removeBody, createSegment, createCapsule, createRectangle,
setVelocity, setBodyPosition, applyImpulse,
setBodyFlags, setGridEnabled, setRestBias, setRestBiasParams, drainSensorEvents,
drainContactEvents, takeSnapshot, restoreSnapshot, hash, and read accessors positionX/Y,
prevPositionX/Y, velocityX/Y.
Build the wasm
The .wasm is committed under wasm/. To rebuild from the Rust crate:
pnpm --filter @lagless/pinball-physics build:wasm
# = cargo build --release --target wasm32-unknown-unknown (in pinball-physics-rs) + copy into wasm/Tests
npx vitest run --project=@lagless/pinball-physics # 18 testsCovers the IPhysicsWorldManagerBase contract, gravity, collisions, CCD (no-tunnel), sensors,
contact events (drainContactEvents), and the capsule + rectangle static colliders (bounce + entity
reporting) - all against the real .wasm in node (V8), with determinism + rollback==linear.
0.0.71 - Apex rest-resolution (engine now owns un-sticking) + downstream hand-off
The contact solver now breaks a symmetric metastable apex rest itself. A dynamic ball that comes
to rest exactly on the top of a circle (a static disc OR another ball) or on the top endpoint of a
near-vertical segment used to sit there forever - a noiseless deterministic engine has zero net
tangential force to break the balance. The solver now injects a tiny deterministic tangential
drift (World.rest_bias, the tunable V_BIAS) so gravity rolls the ball off within ~1s. VERSION
bumped 3 -> 4.
- On by default (
REST_BIAS_DEFAULT = 0.10u/s). Tune or disable from JS withmanager.setRestBias(magnitude)(0disables; set once beforecapturePreStartState()- it is snapshot-resident, so rollback restores it). Rust:World::set_rest_bias, FFIset_rest_bias(f32). - Deterministic & rollback-safe. The drift sign is
stable_hash(a, ob_kind, ob_idx) & 1- a pure function of persistent collider identity, never RNG/tick/contact-order - sohash()stays byte-identical run-twice and across snapshot -> restore -> continue. - Normal play is untouched. Engagement is gated to genuine apexes: ball-on-static/dynamic-circle
always; a segment/capsule only at an ENDPOINT and a rectangle only at a CORNER (a flat interior/face
rest is stable - NOT biased); never a KINEMATIC (moving) circle support. Flat floors, walls, off-center hits, and fast balls are
byte-identical to before. A tiny drift only un-sticks LOW-friction supports (real pinball
pegs/bumpers); a high-friction support legitimately holds the ball and is left alone. The support
cone and the apex gate are both SCALE-FREE (work at any gravity magnitude);
V_REST(the near-rest speed gate) is an absolute ~0.5 u/s. - Engine-test change: the ONLY existing assertion that moved is
pinball-physics-rs/tests/narrowphase.rssize_of::<World>()54_764 -> 54_768(x2; the +4-byte tailrest_biasfield) and the recapturedGOLDENhash. Both moved purely because the world grew 4 bytes andVERSIONbumped (both hashed), NOT because the bias fires (that scene is a fast ball, never an apex rest). All other engine suites use within-build hash comparisons and are unaffected.
Hand-off for pinball-rt (separate repo; do AFTER 0.0.71 lands)
- Bump all
@lagless/*deps to^0.0.71(lockstep - the repo pins them to a single version),pnpm install. - Delete the JS nudge - the engine now owns rest-resolution:
- remove
pinball-simulation/src/lib/systems/anti-stuck.system.tsand its entries insystems/index.tsandsandbox/setup-single-object.ts; - remove
BallState.restTicksfromecs.yaml, re-runpnpm codegen, and drop it fromschema-budget.test.ts.
- remove
- Re-run the determinism tests + module gates. The M3
nudgeReliancemetric should read ~0 (the engine, not a JS nudge, resolves apex rest). - Re-score the maps (classic-pinball + the v2 skeletons) - outcomes shift where a rest previously
occurred. If a peg/bumper uses high friction and a ball is meant to perch on it, lower its friction
(or raise
setRestBias) so the drift clears the friction angle.
0.0.73 - Decoupled rest-resolution + wedge coverage (the nudge can finally be deleted)
0.0.72's single rest-bias knob vb was BOTH the engagement threshold (|vt| < vb) AND the drift
magnitude, so it could not resolve a real rest quickly without disturbing normal play: raising vb
to resolve faster re-energized dense peg fields (a slow-ish ball near a peg apex was re-kicked every
settled contact -> perpetual bounce), the safe small default was too weak to free a genuine settle,
and a concave wedge (a ball jammed against the SIDE of a post, not a convex apex) was never
touched at all. So the downstream JS AntiStuckSystem nudge stayed load-bearing. 0.0.73 fixes all
three. VERSION bumped 4 -> 5.
- Engagement is DECOUPLED from drift. Two knobs now:
rest_eps_v(engagement stillness threshold, default0.12u/s - how still a ball must be to count as resting) andrest_bias(the firm DRIFT magnitude, default raised0.10 -> 0.6). Gate is|vt| < eps_v(NOTvb), so raising the drift no longer widens the engagement set: a slow-but-moving ball (eps_v <= |vt| < vb) is left alone, and a FIRM drift coexists with a dense peg field without trapping it. - Sustained-HOLD gate. A ball must satisfy the engagement gate at an eligible support for
rest_holdconsecutive ticks (default8, ~0.13s) before any kick fires, via a snapshot-resident per-bodyrest_tickscounter. A transient bounce-apex (momentarily still at the top of a bounce) is never kicked - only a genuine settle. This is the mechanism that lets a firm drift not trap peg fields. - Concave WEDGE / pocket coverage. A ball settled (under gravity, for
>= holdticks) pinned by TWO+ static contacts whose normals diverge > 60deg (a concave pocket, not a single convex apex) now resolves too: once confirmed wedged, a firm deterministic up-and-out drift is SUSTAINED over a short escape window (REST_ESCAPE_TICKS = 40) so it accumulates enough lift to climb out (a single bounded kick cannot crest a concave barrier). The convex/endpoint apex keeps its fast single-kick path. - API.
setRestBias(drift)still works (drift only;eps_v/holdkeep defaults). NEWsetRestBiasParams(drift, epsV, holdTicks)for full tuning (drift = 0disables; RustWorld::set_rest_bias_params, FFIset_rest_bias_params(f32, f32, u32)). All three values are snapshot-resident, so rollback restores them. - Deterministic & rollback-safe. All decisions use persistent collider/body ids + snapshot state
only (drift sign from
stable_hash, no RNG/tick/time/contact-order); the rest pass runs once per TICK (last CCD substep) so the HOLD counter advances exactly once per tick.hash()is byte-identical run-twice and across snapshot -> advance -> restore -> advance, INCLUDING a mid-climbrest_ticks. - Engine-test changes: the ONLY existing assertions that moved are in
pinball-physics-rs/tests/narrowphase.rs-size_of::<World>()54_768 -> 56_824(x2; the +2056-byte tail:rest_eps_v+rest_hold+rest_ticks[512]) and the recapturedGOLDENhash (386_236_133 -> 2_532_029_047). Both moved purely because the world grew andVERSIONbumped (both hashed), NOT because the rest pass fires (that scene setsrest_bias = 0.0and is a fast ball). Note:multiball_narrowphase_is_deterministic_and_rollback_saferuns at the new0.6default over a peg-at-floor config, so the new apex + pocket paths DO fire there - its trajectory legitimately changes, but it compares within-build (run-twice + rollback) so it stays green (good extra coverage, including a mid-HOLD-climb rollback). All other engine suites use within-build comparisons and are unaffected. Theapex.rsbehavioral tests were pinned to an explicitset_rest_bias(0.10)to keep testing the gentle drift they were tuned for, insulated from the default bump.
Hand-off for pinball-rt (separate repo; do AFTER 0.0.73 lands)
This is the release that lets the JS nudge be DELETED (0.0.71 owned apex rests but could not replace the nudge for wedges / firm-drift peg fields; 0.0.73 does both).
- Bump all
@lagless/*deps to^0.0.73(lockstep - the repo pins them to a single version),pnpm install. - In the executor, set
world.setRestBiasParams(drift ~0.6-1.0, eps_v ~0.12, hold ~8)before the pre-start snapshot (already wired via theREST_BIASconstant + theworld.setRestBiascall - swap it forsetRestBiasParams). TunedriftUP for stubborn wedges if a map needs faster un-sticking. - Delete the JS nudge - the engine now owns BOTH apex and wedge rest-resolution with NO peg-field
trapping:
- remove
pinball-simulation/src/lib/systems/anti-stuck.system.tsand its entries insystems/index.tsandsandbox/setup-single-object.ts; - remove
BallState.restTicksfromecs.yaml, re-runpnpm codegen, and drop it fromschema-budget.test.ts.
- remove
- Re-run the determinism tests + the module gates. The recirculator / collector-fan modules must now
ACCEPT with the nudge OFF (the M3
nudgeReliancemetric should read ~0 - the engine, not a JS nudge, resolves rests). Re-score the maps - outcomes shift where a rest previously occurred.
Generalization - fast ball(s) + rich static geometry (VERSION 5 -> 6)
The engine generalized from a single-ball pinball table to "fast ball(s) + rich static geometry":
- Flippers removed. All kinematic-flipper logic (engine columns + ABI + TS binding + demo) is gone.
Kinematic CIRCLE movers (
BODY_KINEMATICdriven viasetBodyPosition/setVelocity) are untouched. - Two new static colliders.
createCapsule(ax, ay, bx, by, radius, restitution, friction, flags, entity_id)(a segment core inflated by a radius) andcreateRectangle(cx, cy, hx, hy, angle, restitution, friction, flags, entity_id)(an oriented box; rotation stored as a precomputed axis so the narrowphase is trig-free, with a deterministic inside-box eject). Both have full segment parity: solid collision, sensor overlap (SENSOR_FLAG), contact events (REPORT_CONTACTS_FLAG), and rest-bias eligibility (capsule endpoints / rectangle corners). - Unified broadphase. The uniform grid now indexes all static colliders (static circle bodies,
segments, capsules, rectangles), not just segments, with a query-overflow brute fallback;
grid == brutebyte-identity holds across every shape kind (guarded, including a dense-overflow case). - Layout. The
Worldgrew (flipper columns removed, capsule + rectangle columns appended at the tail;size_of56_824 -> 75_868, snapshot ~74 KB).EXPECTED_WORLD_LENinlayout.tsmirrors it and the constructor drift-guard enforces the match. - Fresh
bench-vs-rapiernumbers (outer box + N oriented-box obstacles + 1 ball; 64 / 128 / 240):step~6-9 us (≈1× Rapier, competitive),takeSnapshot~3-7× faster,restoreSnapshot~57-141× faster, snapshot a constant 74.1 KB (Rapier grows to ~113 KB at 240). Full table + the wasm-size comparison are in the engine README.
