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

@diceforge-sdk/presenter-physics

v0.7.0

Published

Physics motion for DiceForge dice: simulates a roll, then remaps each die so the already-resolved face lands where the simulation put it.

Readme

@diceforge-sdk/presenter-physics

Physics motion for DiceForge dice. Simulates a roll, then places the already-resolved face where the simulation put whichever face came up — so the dice tumble for real and still land on the outcome the engine decided.

npm install @diceforge-sdk/presenter-physics @diceforge-sdk/renderer-web

The problem it solves

A simulation lands a die wherever it lands. The core resolved the roll before any of this began, and architecture rule 5 says presentation may never decide an outcome. Steering the simulation is a corrected animation, which ADR-0007 rules out; rotating the die once it rests is a visible snap at exactly the moment the player is watching.

So the die's mesh is rotated inside its collider by a rotation from the solid's own symmetry group, chosen so the recorded face occupies the place the simulation's face landed in. A symmetry leaves the collider identical, so the physics never notices and nothing is corrected on screen (ADR-0018).

Use

import { simulateRoll } from "@diceforge-sdk/presenter-physics";

const record = engine.roll("4d6");           // resolved first, as always
const motion = simulateRoll(
  record.groups.flatMap((group) =>
    group.dice.map((die) => ({
      shape: die.sides,
      face: die.value,
      // Where this model's numerals are. The collider is built from it, so a
      // face of the collider *is* a number (ADR-0019).
      faceRotations: theme.models.faceRotations[die.sides],
    })),
  ),
  { dieRadius: 1.05 },
);

for (const die of motion.dice) {
  mesh.quaternion.set(...die.remap);          // once, before the first frame
}
// then play die.frames at motion.frameRate

Each PhysicsDie carries the remap to apply to its mesh, the recorded frames, and seated — how squarely it finished, where 1 is flat on a face.

| Option | Default | Meaning | | --- | --- | --- | | dieRadius | 1 | Circumradius in your units; every distance is scaled to match | | trayRadius | dieWidth × 3.5 | The tray's shorter half-extent; fixed, not scaled to the dice count | | trayAspect | 1 | Width over depth — shape it like your viewport and the dice spread into frame | | random | Math.random | Pass a seeded source and the same throw reproduces exactly | | frameRate | 60 | Recording rate | | maxDuration | 8 s | Longest roll to record |

Why the trajectory is recorded, not simulated live

The whole roll is computed before anything is drawn, and playback is ordinary animation. That means no physics runs while the roll is on screen, the result is frame-rate independent, reduced motion is a matter of jumping to the last frame, and the engine never needs to be deterministic across runs.

It is also cheap enough to do in the frame that starts the roll:

| Dice | Simulation | Roll length | Trajectory | | --- | --- | --- | --- | | 1 | 4 ms | 0.64 s | 6 kB | | 10 | 14 ms | 0.93 s | 77 kB | | 40 | 44 ms | 1.52 s | 438 kB |

The collider is not your model, but it is built from it

The die you draw and the die that collides are deliberately different objects. The collider is the idealised sharp solid; your model is cosmetic, drawn inside an invisible container only the physics sees.

It is nevertheless derived from your model, via the calibrated table that says which rotation brings each numeral to the top. Intersecting the half-spaces those directions define gives a solid whose faces are the numerals, in value order. That is why faceRotations is required: a collider knows its geometry and nothing about its numbering, and the first version of this package built the collider from generated geometry instead — so the physics landed a geometric face upward and the model showed whichever numeral happened to be printed there. 57 of 60 faces displayed the wrong number (ADR-0019).

That is not only for speed, though it is 500× faster than colliding a bevelled model. The symmetry remap needs a solid whose faces are all equivalent, and a bevelled d20 is not twenty faces but roughly 620 facets — no symmetry carries one bevel sliver onto another in a way that repositions a die face.

Because the physics decides nothing, the two need not match. A themed die may be bevelled, hollowed, or missing whole faces for effect and still roll correctly. The one constraint is scale: line the model's face planes up with the collider's, or it will hover or sink.

The presenter

createPhysicsPresenter plays that motion, so most applications never touch simulateRoll directly:

import { createPhysicsPresenter } from "@diceforge-sdk/presenter-physics";

const presenter = createPhysicsPresenter({
  container: document.querySelector("#stage")!,
  theme: forgeTheme(forgeAssets({ color: "red" })),
});

await presenter.present(engine.roll("4d6"));

It draws only what it can honestly simulate. A custom die, an unusual face count, a missing theme or a browser without WebGL all go to @diceforge-sdk/renderer-web, which already does them well — delegating rather than reimplementing is why this package is small. The result is announced exactly once, whichever of the two drew it.

A coin flip is simulated for real when the theme ships a coin: a cylinder collider whose two faces are the outcomes, thrown into the same tray as the dice, with the same remap trick landing it on the recorded face — a half turn about a diameter is a symmetry of a cylinder, so the physics never notices. The collider's radius and thickness are measured from the loaded model rather than assumed, so a themed coin of any proportions collides as the coin it is. A flip that finishes on its rim is thrown again, exactly like a die that finishes leaning — as is one that fails to turn over at least twice on the way in (a flip that does not tumble reads as a drop), or one that spins on its rim past three seconds (a wait, not a flip). Measured over 60 seeded flips: every one settled flat on the recorded outcome, at about 5 ms per flip including retries.

Reduced motion jumps to the final pose instead of playing the roll, and present(event, { signal }) cancels like any other presenter.

Rerolls and explosions play as stories

The record keeps every value in rolled order (ADR-0016), and the presenter simulates each chapter as its own real throw. A die whose value was rerolled away lands its throw on the doomed value, rests long enough to read, is picked up, and its next recording plays as the re-toss — the lost value never lingers on the table looking dropped. A die that rolled its highest face on an exploding roll celebrates over its rest pose — a hop with a full vertical turn, which cannot change the face that is up — while the bonus die it earned plays its own throw into the tray, born mid-celebration; chains repeat, each earned die celebrating in turn. The settled stage holds every die that exists when the story ends, so the faces always sum to the record's total.

Every chapter is honest physics: the follow-up throws run in the same fixed tray with the same seeded random source, so a seeded roll — stories included — replays exactly. Their collisions join the knock schedule at their place on the story's clock, so sound stays derived rather than timed. Reduced motion jumps straight to the settled stage.

One honest limit: each follow-up throw runs in its own world, so a re-tossed or explosion-born die does not collide with dice already resting — it can land beside one closer than a real die could, or pass through one in flight. The original throw is still one shared world, and every landing is still a flat, verified face.

Pass sound: true and every collision in the recording plays a knock — felt is a dull thump, a wall is sharper, die-on-die is the bright clack — with loudness from how hard the recording says the contact was (ADR-0020). Nothing fires on a timer: the same data drives the animation and the audio, so a bounce sounds when it lands.

Every knock is synthesized from filtered noise at play time, so there are no audio files to ship or load. Default off — sound is your application's choice. The AudioContext is created inside the first presented roll, which is inside your click handler, so autoplay policy is satisfied; a browser without Web Audio simply stays silent. Reduced motion skips sound along with the animation it would have accompanied.

simulateRoll and simulateCoinFlip expose the raw material as impacts — time, body, surface, and closing speed — and impactSchedule(impacts) turns them into knocks, so an application with its own audio pipeline can consume either.

Framing

The dice area is a fixed rectangle, shaped to the stage, and the camera frames it and nothing else. It does not grow with the number of dice, so the same die is the same size on every throw and a roll that scatters wide does not zoom out — a tray is a thing on a table.

Its size is measured: dieWidth × 3.5 on the shorter side is the tightest that still lands 30 out of 30 dice flat at one, five and ten dice. Larger is calmer but shrinks the dice; at seven die-widths a d20 spans a fourteenth of the frame and cannot be read. Override it with trayRadius.

A throw that lands badly is thrown again

A die propped against a wall or a neighbour shows its recorded face at an angle, which reads as though the roll has not finished. Simulating costs about 4 ms per die, so a throw that leaves any die tilted more than about 1.8° is discarded and thrown again, up to six times. Nothing is corrected on screen and no outcome changes — the faces were decided before any of this ran. Only the motion differs.

The canvas follows its container: a window resize re-fits the camera and re-frames the dice where they lie, so a responsive layout keeps the roll it was showing. The listener is released by dispose().

Licence

MIT.