npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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-place set() 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: uint32

pnpm 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 tests

Covers 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.10 u/s). Tune or disable from JS with manager.setRestBias(magnitude) (0 disables; set once before capturePreStartState() - it is snapshot-resident, so rollback restores it). Rust: World::set_rest_bias, FFI set_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 - so hash() 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.rs
    • size_of::<World>() 54_764 -> 54_768 (x2; the +4-byte tail rest_bias field) and the recaptured GOLDEN hash. Both moved purely because the world grew 4 bytes and VERSION bumped (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)

  1. Bump all @lagless/* deps to ^0.0.71 (lockstep - the repo pins them to a single version), pnpm install.
  2. Delete the JS nudge - the engine now owns rest-resolution:
    • remove pinball-simulation/src/lib/systems/anti-stuck.system.ts and its entries in systems/index.ts and sandbox/setup-single-object.ts;
    • remove BallState.restTicks from ecs.yaml, re-run pnpm codegen, and drop it from schema-budget.test.ts.
  3. Re-run the determinism tests + module gates. The M3 nudgeReliance metric should read ~0 (the engine, not a JS nudge, resolves apex rest).
  4. 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, default 0.12 u/s - how still a ball must be to count as resting) and rest_bias (the firm DRIFT magnitude, default raised 0.10 -> 0.6). Gate is |vt| < eps_v (NOT vb), 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_hold consecutive ticks (default 8, ~0.13s) before any kick fires, via a snapshot-resident per-body rest_ticks counter. 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 >= hold ticks) 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/hold keep defaults). NEW setRestBiasParams(drift, epsV, holdTicks) for full tuning (drift = 0 disables; Rust World::set_rest_bias_params, FFI set_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-climb rest_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 recaptured GOLDEN hash (386_236_133 -> 2_532_029_047). Both moved purely because the world grew and VERSION bumped (both hashed), NOT because the rest pass fires (that scene sets rest_bias = 0.0 and is a fast ball). Note: multiball_narrowphase_is_deterministic_and_rollback_safe runs at the new 0.6 default 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. The apex.rs behavioral tests were pinned to an explicit set_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).

  1. Bump all @lagless/* deps to ^0.0.73 (lockstep - the repo pins them to a single version), pnpm install.
  2. 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 the REST_BIAS constant + the world.setRestBias call - swap it for setRestBiasParams). Tune drift UP for stubborn wedges if a map needs faster un-sticking.
  3. 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.ts and its entries in systems/index.ts and sandbox/setup-single-object.ts;
    • remove BallState.restTicks from ecs.yaml, re-run pnpm codegen, and drop it from schema-budget.test.ts.
  4. Re-run the determinism tests + the module gates. The recirculator / collector-fan modules must now ACCEPT with the nudge OFF (the M3 nudgeReliance metric 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_KINEMATIC driven via setBodyPosition/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) and createRectangle(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 == brute byte-identity holds across every shape kind (guarded, including a dense-overflow case).
  • Layout. The World grew (flipper columns removed, capsule + rectangle columns appended at the tail; size_of 56_824 -> 75_868, snapshot ~74 KB). EXPECTED_WORLD_LEN in layout.ts mirrors it and the constructor drift-guard enforces the match.
  • Fresh bench-vs-rapier numbers (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.