Reading the Top Face of a Tumbling Die in Three.js
Notes on building dice.bee.ac: rigid-body polyhedra in cannon-es, face-normal dot products for reading d4 through d20 results, and deterministic replay.
- Published
- Updated
- —
- Reading
- 1 min
- Filed under
- #threejs #webgl #typescript #gamedev
Most browser dice rollers do one of two things: either they call Math.random() immediately and play a canned animation that lands on the predetermined number, or they run a physics simulation and struggle to figure out which face ended up pointing at the ceiling.
When I built dice.bee.ac, I wanted an honest physics tray — if a d20 clips the rim of the tray, bounces off a d8, and tips over at the last moment, the readout should reflect the face that actually settled on top.
Representing polyhedra in cannon-es
A six-sided die is a trivial CANNON.Box, but a d12 (dodecahedron) or d20 (icosahedron) needs a ConvexPolyhedron constructed from vertices and outward-winding face indices. Each face has an outward unit normal $\hat{n}_i$ in the die’s local coordinate space.
Once the rigid body comes to rest (linear and angular velocity magnitudes below $\varepsilon = 0.01$ for 12 consecutive frames), reading the result is a single loop over the face normals:
import { Quaternion, Vec3 } from 'cannon-es';
export interface DieFace {
readonly value: number;
readonly localNormal: Vec3; // unit normal in body-local space
}
const WORLD_UP = new Vec3(0, 1, 0);
const tmpWorldNormal = new Vec3();
/**
* For standard dice (d6, d8, d10, d12, d20), the winning value is the face
* whose outward normal aligns closest with world +Y (or -Y for a d4 pyramid
* where the base face sits flat on the table).
*/
export function readSettledDie(
orientation: Quaternion,
faces: readonly DieFace[],
pickBottomFace = false,
): number {
let bestValue = faces[0].value;
let bestScore = -Infinity;
for (const face of faces) {
orientation.vmult(face.localNormal, tmpWorldNormal);
const alignment = tmpWorldNormal.dot(WORLD_UP);
const score = pickBottomFace ? -alignment : alignment;
if (score > bestScore) {
bestScore = score;
bestValue = face.value;
}
}
return bestValue;
}
Why the d4 is the awkward cousin
Every standard die from d6 to d20 has parallel opposite faces (or near-opposite faces on a pentagonal trapezohedron d10), so one face sits flat on top when the body settles. A tetrahedral d4 has a vertex pointing up and a triangular face flat against the felt. Setting pickBottomFace = true flips the dot-product sign so we pick the face pressed flush against the table (alignment ≈ -1.0), matching how physical base-numbered d4s are read.
Keeping the bundle light
The whole tool at dice.bee.ac ships as static assets with zero server round-trips, procedural bevelled geometry (so there are no multi-megabyte .glb meshes to download), and a notation parser supporting expressions like 2d20kh1+5 (advantage) or 4d6dl1 (ability scores).