Skip to content
Andy Lawton

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).