3D Dice Roller

  • Skia
  • Reanimated
  • Haptics

Dice that know their result before they are thrown, then tumble to it. The hard part is not the physics but the landing: a die that comes to rest two degrees off flat looks broken, however good the spin was.

View plans

Purchases are not open yet.

Why one eased spin will not do

Slerping the whole rotation under a single ease leaves the die creeping into place with almost no speed, and the last few degrees read as a tilt that never resolves — measured at 15° still remaining at 76% of an easeOutCubic over 1080°. The roll is split instead: the first 78% eases toward a deliberately overshot pose, so the flat tail lands somewhere you are not looking.

const PHASE_SPLIT = 0.78; // t₁ = 0.78·duration; the last 22% is the click-flat wobble// …const x = t / t1;const e = 1 - (1 - x) * (1 - x); // easeOutQuadqSlerp(q0, qOver, e, sSlerp);qFromAxisAngle(ax, ay, az, theta * (1 - e), sTumble);qNormalize(qMul(sTumble, sSlerp, out), out);

The last fifth is a wobble that crosses flat

Phase two rocks about the true target on a decaying cosine that starts exactly at the overshoot, so the two phases join without a seam. Because the wobble crosses zero rather than approaching it, the die visibly passes through level and settles. On the final frame the exact target quaternion is written and latched, so no accumulated float error survives.

const phi = delta * Math.exp((-WOBBLE_DECAY * s) / T2) * Math.cos((2 * Math.PI * WOBBLE_CYCLES * s) / T2);qMul(qFromAxisAngle(1, 0, 0, phi, sTmp), qTarget, out);// …settled = true; // latched — exact target forever afterqSet(out, qTarget.x, qTarget.y, qTarget.z, qTarget.w);

The numbering is an invariant, not a guess

Real dice obey rules: opposite faces sum to 7 on a d6, 9 on a d8, 13 on a d12 and 21 on a d20, whose 20 sits beside 2, 8 and 14. Tests enforce each of those. It matters because the layout most often copied from the web numbers the faces in sequence and quietly breaks the d20 rule, which anyone who owns dice will notice.

export interface SolidSpec {  kind: DieKind;  vertices: readonly (readonly [number, number, number])[];  faces: readonly (readonly number[])[];  numbering: readonly number[];  apex: readonly number[];  d4VertexValues?: readonly number[];}
<DiceRoller params={{ count: 1, edgeCurve: 15, idle: true, sound: true }} fireSignal={0} />