3DRoll is a free browser dice roller. It doesn't pick a random number and then play an animation to match. The animation is the random number. Each die is a rigid body in cannon-es that gets a random launch impulse and spin. The simulation runs until every body sleeps, and the result is whichever face points up. three.js only draws what the physics already decided.
That design has one obvious weakness. With Math.random() * 6 you know the distribution is uniform. With a physics engine you only hope it is. So I tested it, and the test found a real bias.
How a roll is read
Each d6 has six local face normals: ±X, ±Y and ±Z. Once the dice stop, I rotate each normal by the body's quaternion and take the one most aligned with world up:
const FACE_NORMALS = [
new THREE.Vector3(0, 1, 0), new THREE.Vector3(0, 0, 1),
new THREE.Vector3(1, 0, 0), new THREE.Vector3(-1, 0, 0),
new THREE.Vector3(0, 0, -1), new THREE.Vector3(0, -1, 0),
];
function topFace(body) {
const q = new THREE.Quaternion(body.quaternion.x, body.quaternion.y,
body.quaternion.z, body.quaternion.w);
let best = 0, bestDot = -2;
FACE_NORMALS.forEach((n, i) => {
const d = n.clone().applyQuaternion(q).y;
if (d > bestDot) { bestDot = d; best = i; }
});
return { index: best, dot: bestDot };
}
Note which values sit on the X axis. In this face order, faces 3 and 4 are the ±X faces. That matters later.
The tilted-die problem
Real dice sometimes come to rest leaning on a wall or on another die. A browser roller can't tell the user "cocked die, roll again", so the app needs a rule. If the top face's dot is below 0.988, the die isn't flat enough to count, and the app corrects it:
- If the die is nearly flat and nothing is nearby, rotate it the rest of the way (a short "flatten" animation).
- Otherwise, kick it. A "tip" gives it a small hop and a spin around the axis that would tip it over. After a few tries, a more random "nudge" takes over.
The kick seemed harmless at first. It turned out to be the source of the bias.
The test setup
I didn't want to click "roll" 60,000 times, so I rebuilt the app's physics in Node and ran it headless. I used the same cannon-es world and copied every parameter from the production bundle:
- gravity −30, SAP broadphase, 16 solver iterations, contact stiffness 1e7
- die/floor friction 0.42 and restitution 0.32; die/wall 0.05 / 0.40; die/die 0.20 / 0.38
- mass 1, linear damping 0.08, angular damping 0.12, sleep limits 0.07 / 0.6 s
- 1/120 s physics steps at 60 fps
- the same launch code (random target point, random upward speed, random spin), the same settle check, and the same flatten/tip/nudge corrections
- dice stay where they landed between throws, like a real session
One thing is only approximate. In the app the tray comes from the camera frustum, so it depends on screen size. The script uses a fixed desktop-sized trapezoid instead.
The statistic is Pearson's chi-square against a uniform distribution, with 5 degrees of freedom. As a reference, χ² above 11.07 means p < 0.05, and above 20.52 means p < 0.001.
What I found (v12)
One die at a time looked fine. Over 42,000 throws every face landed between 16.3 % and 17.0 % of the time (ideal: 16.67 %). A 30,000-throw run gave χ² 4.20 (p 0.52). A separate 12,000-throw run gave χ² 12.30 (p 0.031), which is borderline at most.
Five dice per throw was different. Two independent runs of 6,000 throws each (30,000 results per run) both failed:
| Run | Faces 1–6 | χ² | p |
|---|---|---|---|
| 1 | 5008 / 5045 / 4790 / 4895 / 5196 / 5066 | 20.00 | 0.0013 |
| 2 | 4915 / 5177 / 4715 / 4950 / 5187 / 5056 | 32.08 | < 0.0001 |
| pooled | 9923 / 10222 / 9505 / 9845 / 10383 / 10122 | 48.58 | ≈ 3×10⁻⁹ |
Face 3 came up 15.84 % of the time and face 4 16.41 %. Those are the ±X faces, and the same pattern showed up in both runs. One caveat: five dice in one throw collide, so their results aren't fully independent, which makes χ² somewhat overconfident. But two separate runs with p values that small and the same faces low point to something real.
In practice it's a fraction of a percentage point, and no one would notice it at a game table. But a dice roller that claims real physics shouldn't favour faces.
Finding the suspect
The correction counters gave it away. In the five-dice runs, tilted dice were very common, since dice lean on each other. Each 6,000-throw run needed roughly 1,600–1,700 tips. The tip spins the die around an axis computed from its current orientation, so the correction isn't neutral with respect to the die's own axes. Fire it that often and it starts to shape the result.
The fix (v13)
The new version changes three things.
1. A plain box collider. The d6 collision shape was a rounded box (a convex hull with a 0.045 bevel). It's now a plain CANNON.Box with half-extent size/2. The rounded look stays in the rendered mesh.
2. Slide, don't kick. A tilted die now gets a settleSmooth step before any kick. The app finds the nearest flat orientation and a free spot next to the die, then slides it there over 0.3 s:
settleSmooth(die) {
const body = die.body;
const from = body.position.clone();
const q0 = body.quaternion.clone();
// rotate the current "most-up" face to exactly +Y
const q1 = new THREE.Quaternion()
.setFromUnitVectors(die.topFace().up, UP).multiply(q0);
const restY = this.restHeight(die, q1);
// try the current spot, then rings of 0.35–1.25 × size around it,
// biased away from the nearest neighbour; must be clear of other dice
for (const [r, angle] of this.candidateSpots(die)) {
const x = from.x + Math.cos(angle) * r;
const z = from.z + Math.sin(angle) * r;
if (this.spotIsFree(die, x, z)) {
die.slide = { p0: from, p1: new THREE.Vector3(x, restY, z), q0, q1, t: 0 };
return true;
}
}
return false; // no room: fall back to tip / nudge
}
Each frame the slide eases position (lerp) and rotation (slerp) together, with velocities zeroed. This runs on the first three correction passes. Only if there's no free spot does the old tip (now up to pass five) or nudge run.
3. Ordering and waiting. Tilted dice are corrected highest first, so a die resting on top of another moves before the one under it. The result isn't read until every slide and flatten has finished.
Launch spin, materials, damping and face reading are all unchanged.
After the fix
Same script, updated to v13's settings:
| Test | v12 χ² (p) | v13 χ² (p) |
|---|---|---|
| 1 die, 30,000 throws | 4.20 (0.52) | 0.74 (0.98) |
| 5 dice, run 1 (6,000 throws) | 20.00 (0.0013) | 1.54 (0.91) |
| 5 dice, run 2 (6,000 throws) | 32.08 (< 0.0001) | 5.98 (0.31) |
| 5 dice pooled (60,000 results) | 48.58 (≈ 3×10⁻⁹) | 2.71 (0.75) |
The raw v13 counts:
- 1 die, 30,000 throws: 5040 / 4996 / 4975 / 5022 / 4998 / 4969
- 5 dice pooled, 60,000 results: 10018 / 10027 / 10049 / 9951 / 10078 / 9877, every face between 16.46 % and 16.80 %
The corrections changed just as much. Each five-dice run now needs about 885 slides and only 11–14 tips, instead of about 1,650 tips. The single-die run needed 642 slides and no tips or nudges at all.
No detectable bias in any v13 run.
What I'm not claiming
- Not "proven fair". A test that doesn't reject uniformity hasn't proven uniformity. It means no bias showed up at this sample size.
- It's a simulation. The tray is approximate, so these numbers describe the headless setup. Your screen's tray shape is slightly different.
- The cause is inferred. I changed three things at once. The kick-axis explanation fits the numbers (the bias vanished when tips nearly vanished), but I didn't test each change on its own.
- Only the d6 is covered. The d4, d8, d10, d12 and d20 are next.
Takeaways for anyone building physics dice
- Test what you ship, headless. Copy the real parameters and the real correction logic. Most of the bias came from the "fix-up" code, not the physics engine.
- Test multiple dice, not just one. The single-die runs were basically clean. The bias only appeared when dice interacted.
- Count your corrections. Logging flattens, tips and nudges was what pointed at the cause.
- Run more than once. One borderline p value means little. The same faces low in two independent runs means a lot.
- Be careful with "fair". "No detectable bias in N throws" is a claim you can back up.
You can roll the fixed dice at 3droll.com. It's free, works in the browser, and needs no sign-up. If you have ideas for testing the d10 or d20 fairly, or a better approach than a simulated tray, I'd love to hear them in the comments.
Top comments (0)