I built a 3D Chinchirorin (Japanese dice game) that runs in the browser, using Three.js for rendering and Ammo.js for physics. You can play it here: Chinchiro Game.
The hardest part of this stack is reading the dice. The physics engine will happily tumble a cube for you, but it will never tell you which face ended up pointing at the sky. That calculation is yours to write.
This article walks through the code that actually ships:
- Building the bowl and the dice with Three.js
- Setting up the physics world with Ammo.js
- Reading the top face from face normals and a quaternion (the main event)
- Chinchirorin hand evaluation, and handling a die that leaves the bowl
- The adjustments needed to keep it playable on a phone
This article was published in 2025 and completely rewritten in September 2026. The original printed a hand-evaluation function built around five-dice poker hands (full house, two pair, and so on). Chinchirorin uses three dice, so that code cannot work. It has been replaced with the shipping implementation, and the missing explanation of face detection has been added.
Sponsored
How the pieces fit together
Rendering and physics live in separate libraries, and their coordinates are synced every frame.
| Responsibility | Library | What it does |
|---|---|---|
| Appearance | Three.js | Bowl, dice, pips, lighting |
| Motion | Ammo.js (Bullet compiled to WebAssembly) | Gravity, collisions, inertia |
| Values and hands | Plain JavaScript | Detecting the upward face, evaluating hands |
| Presentation | CSS animation | Hand banner, screen shake, confetti |
Three.js and Ammo.js know nothing about each other. Every frame you read the position and rotation out of Ammo and write them into the Three.js mesh. That is the entire integration.
One warning: the file named three.min.js was removed from the Three.js distribution in r161. Copying a CDN URL from an old article will give you a 404, so pin a version or host the file yourself.
Wait for Ammo.js to finish loading
Ammo.js is WebAssembly, so a script tag alone is not enough. Calling new Ammo.btVector3() before it finishes loading throws.
if (typeof Ammo !== 'undefined') {
const collisionConfiguration = new Ammo.btDefaultCollisionConfiguration();
const dispatcher = new Ammo.btCollisionDispatcher(collisionConfiguration);
const broadphase = new Ammo.btDbvtBroadphase();
const solver = new Ammo.btSequentialImpulseConstraintSolver();
physicsWorld = new Ammo.btDiscreteDynamicsWorld(
dispatcher, broadphase, solver, collisionConfiguration
);
physicsWorld.setGravity(new Ammo.btVector3(0, -9.82, 0));
ammoTmpTransform = new Ammo.btTransform();
setupPhysics(visualGeometry);
requestAnimationFrame(animate);
} else {
console.error('Ammo.js is not loaded');
}
Note that ammoTmpTransform is created once and reused. Ammo.js objects are allocated on the WebAssembly heap, so calling new every frame piles up memory that is never released.
Sponsored
Registering the bowl as a static triangle mesh
The bowl that catches the dice is the Three.js hemisphere geometry handed straight to Ammo.js.
function setupPhysics(geometry) {
const vertices = geometry.attributes.position.array;
const indices = geometry.index.array;
const ammoMesh = new Ammo.btTriangleMesh();
for (let i = 0; i < indices.length; i += 3) {
const ai = indices[i] * 3;
const bi = indices[i + 1] * 3;
const ci = indices[i + 2] * 3;
ammoMesh.addTriangle(
new Ammo.btVector3(vertices[ai], vertices[ai+1], vertices[ai+2]),
new Ammo.btVector3(vertices[bi], vertices[bi+1], vertices[bi+2]),
new Ammo.btVector3(vertices[ci], vertices[ci+1], vertices[ci+2]),
true
);
}
const bowlShape = new Ammo.btBvhTriangleMeshShape(ammoMesh, true, true);
const bowlBodyInfo = new Ammo.btRigidBodyConstructionInfo(
0, bowlMotionState, bowlShape, new Ammo.btVector3(0, 0, 0)
);
const bowlBody = new Ammo.btRigidBody(bowlBodyInfo);
bowlBody.setRestitution(1.5);
physicsWorld.addRigidBody(bowlBody);
}
| Detail | Why |
|---|---|
| Mass of 0 | Zero mass means a static body. Collisions never move it |
btBvhTriangleMeshShape |
Lets a curved surface be the collision shape. Static bodies only |
setRestitution(1.5) |
Physically impossible. A deliberate choice to exaggerate the bounce |
A restitution of 1.5 means every collision adds energy. That cannot happen in reality, but it makes the dice rattle around inside the bowl, which felt far better to play. Feel won over realism here.
A thin box floor sits under the hemisphere so nothing falls through the bottom — triangle meshes are infinitely thin, and fast bodies can tunnel through them.
Building the dice
Each die is a cube with circular pips positioned per face.
let size = 0.25;
if (isMobileNow()) size = 0.52;
const geometry = new THREE.BoxGeometry(size, size, size);
const material = new THREE.MeshStandardMaterial({
color: 0xFFFFFF, roughness: 0.3, metalness: 0.1
});
const dotRadius = size * 0.08;
const dotOffset = size * 0.22;
const faceNormals = [
new THREE.Vector3(0, 0, 1),
new THREE.Vector3(0, 0, -1),
new THREE.Vector3(0, 1, 0),
new THREE.Vector3(0, -1, 0),
new THREE.Vector3(1, 0, 0),
new THREE.Vector3(-1, 0, 0)
];
The order of faceNormals feeds directly into face detection later. Choose it carelessly and the detection code is guaranteed to break.
Pips are offset slightly along the face normal.
const dotGeometry = new THREE.CircleGeometry(dotRadius, 32);
const dotMesh = new THREE.Mesh(dotGeometry, mat);
dotMesh.position.copy(normal.clone().multiplyScalar(size/2 + 0.001));
mesh.add(dotMesh);
That + 0.001 prevents Z-fighting. Placed exactly on the surface, the pips flicker or vanish depending on draw order.
Only the single pip is red (0xff0000). That one detail did more for the “this is a real die” feeling than anything else.
The rigid body
const diceShape = new Ammo.btBoxShape(new Ammo.btVector3(size/2, size/2, size/2));
const diceMass = 0.5;
const diceLocalInertia = new Ammo.btVector3(0, 0, 0);
diceShape.calculateLocalInertia(diceMass, diceLocalInertia);
const diceBody = new Ammo.btRigidBody(
new Ammo.btRigidBodyConstructionInfo(
diceMass, diceMotionState, diceShape, diceLocalInertia
)
);
diceBody.setFriction(0.15);
diceBody.setRestitution(0.3);
physicsWorld.addRigidBody(diceBody);
btBoxShape takes half-extents. That is why BoxGeometry(size, size, size) pairs with btVector3(size/2, size/2, size/2). Pass size and you get a collision box twice the visible width — an invisible wall.
In this app the visible mesh is also scaled up with mesh.scale.set(1.1, 1.1, 1.1). The collision shape stays at 1.0, so strictly speaking the visuals and the collision box disagree. It was a choice about how full the bowl looks, but it is a place where they should really match.
Sponsored
Throwing
A throw clears the previous dice, rebuilds them, and applies an initial velocity and spin.
function throwDice() {
playRandomDiceSound();
for (let i = 0; i < diceList.length; i++) {
scene.remove(diceList[i].mesh);
physicsWorld.removeRigidBody(diceAmmoList[i]);
}
diceList = [];
diceAmmoList = [];
createDice();
diceCount++;
for (let i = 0; i < diceList.length; i++) {
let throwSpeed = 2.5 + Math.random() * 1.5;
let yVel = -4.0;
let angVel = 10;
if (isMobileNow()) {
throwSpeed = 8.0 + Math.random() * 3.0;
angVel = 34;
}
const body = diceAmmoList[i];
body.setLinearVelocity(new Ammo.btVector3(0, 0, 0));
body.setAngularVelocity(new Ammo.btVector3(0, 0, 0));
body.activate();
const angle = Math.random() * Math.PI * 2;
body.setLinearVelocity(new Ammo.btVector3(
Math.cos(angle) * throwSpeed, yVel, Math.sin(angle) * throwSpeed
));
body.setAngularVelocity(new Ammo.btVector3(
(Math.random()-0.5)*angVel,
(Math.random()-0.5)*angVel,
(Math.random()-0.5)*angVel
));
}
}
body.activate() is mandatory. Bullet puts idle bodies to sleep, and a sleeping body ignores a velocity you set. “The dice stop responding after the first throw” is almost always this.
The direction comes from Math.random() * Math.PI * 2. Feeding independent random values into X and Z biases the direction toward the diagonals; picking an angle and splitting it with cos and sin stays uniform.
The main event: reading the dice
The physics engine does not know which face is up. All you get from a rigid body is a position and a quaternion.
So: rotate all six face normals by the current orientation, and pick the one whose dot product with world up (0, 1, 0) is largest. The largest dot product is the face pointing most directly at the sky.
function getDiceTopValue(mesh) {
const up = new THREE.Vector3(0, 1, 0);
const localUps = [
new THREE.Vector3(0, 0, 1),
new THREE.Vector3(0, 0, -1),
new THREE.Vector3(0, 1, 0),
new THREE.Vector3(0, -1, 0),
new THREE.Vector3(1, 0, 0),
new THREE.Vector3(-1, 0, 0)
];
let maxDot = -Infinity;
let topIdx = 0;
for (let i = 0; i < 6; i++) {
const v = localUps[i].clone().applyQuaternion(mesh.quaternion);
const dot = v.dot(up);
if (dot > maxDot) {
maxDot = dot;
topIdx = i;
}
}
const values = [1, 6, 2, 5, 3, 4];
return values[topIdx];
}
| Line | What it does |
|---|---|
applyQuaternion(mesh.quaternion) |
Rotates the local normal into the current orientation |
v.dot(up) |
Agreement with straight up. Closer to 1 means more upward |
values[topIdx] |
Maps the face order onto pip counts |
values = [1, 6, 2, 5, 3, 4] corresponds to the order of faceNormals, arranged so opposite faces sum to seven as on a real die. Change the order you paint the pips and this table has to change too. Get it wrong and you get “it visibly shows 6 but reports 5” — a bug that is genuinely hard to trace.
Deciding when the dice have stopped
Values are only read once all three dice have settled.
const lv = diceAmmoObj.getLinearVelocity();
const av = diceAmmoObj.getAngularVelocity();
if (lv.length() > 0.02 || av.length() > 0.02) {
allStopped = false;
}
Velocities never land on exactly zero, so a threshold is required. Too small and it never settles; too large and you read the values mid-roll. 0.02 came from trying it.
Evaluating Chinchirorin hands
Chinchirorin uses three dice. Five-dice poker hands like full house and two pair simply do not exist here. These are the hands actually implemented:
| Hand | Dice | Meaning |
|---|---|---|
| Pinzoro | 1-1-1 | The best hand. Gets its own “legendary” effect |
| Arashi (triple) | Three of a kind | Displayed as “4 Arashi” and so on |
| Shigoro | 4-5-6 | A winning hand |
| Me (pair plus one) | e.g. 3-3-5 becomes 5 | The odd die out is your score |
| Hifumi | 1-2-3 | The worst hand |
| No hand | Anything else | No score |
function showYaku(values) {
values.sort();
if (values[0] === 1 && values[1] === 1 && values[2] === 1) {
playYakuBanner('Pinzoro');
return;
}
let yaku = '';
if (values[0] === values[1] && values[1] === values[2]) {
yaku = `${values[0]} Arashi`;
} else if (values[0] === values[1] || values[1] === values[2] || values[0] === values[2]) {
// a pair: the remaining die is the score
let diff;
if (values[0] === values[1]) diff = values[2];
else if (values[1] === values[2]) diff = values[0];
else diff = values[1];
yaku = `${diff}`;
} else if (values[0] === 4 && values[1] === 5 && values[2] === 6) {
yaku = 'Shigoro';
} else if (values[0] === 1 && values[1] === 2 && values[2] === 3) {
yaku = 'Hifumi';
} else {
yaku = 'No hand';
}
playYakuBanner(yaku);
}
The order of the checks matters. Pinzoro, then triples, then pairs, then Shigoro, then Hifumi, resolving from the top. Reorder them and 1-1-1 gets reported as “1 Arashi”.
One caveat about values.sort() with no comparator. JavaScript’s default sort compares strings, so this should really be sort((a, b) => a - b). It happens to work here only because the values are single digits 1 through 6. Extend the game to two-digit values and this is the first line to fix.
Handling a die that leaves the bowl
A die that jumps out of the bowl is a foul in Chinchirorin (called shonben), so that case needs its own path.
function isInsideBowl(pos) {
const r = Math.sqrt(pos.x * pos.x + pos.z * pos.z);
return (r <= (bowlRadius * 0.98)) && (pos.y <= 0.1);
}
Horizontal distance within 98% of the radius, and below a height threshold, counts as inside. All three dice are checked every frame.
const insideCount = insideStates.filter(Boolean).length;
if (insideCount >= 1 && insideCount <= 2) {
// only 1 or 2 inside means one escaped
playYakuBanner('Shonben');
} else if (allStopped && insideCount === 3) {
showYaku(diceValues);
}
A foul is announced immediately, without waiting for the dice to stop. The outcome is already decided the moment a die leaves, so there is nothing to wait for. A hand, by contrast, cannot be read until all three have settled. That asymmetry was the fiddliest part of the implementation.
Syncing every frame
animate() copies Ammo’s transforms into Three.js.
function animate() {
requestAnimationFrame(animate);
const now = performance.now();
const deltaTime = Math.min((now - lastTime) / 1000, 0.1);
lastTime = now;
physicsWorld.stepSimulation(deltaTime, 10);
for (let i = 0; i < diceList.length; i++) {
const ms = diceAmmoList[i].getMotionState();
ms.getWorldTransform(ammoTmpTransform);
const p = ammoTmpTransform.getOrigin();
const q = ammoTmpTransform.getRotation();
diceList[i].mesh.position.set(p.x(), p.y(), p.z());
diceList[i].mesh.quaternion.set(q.x(), q.y(), q.z(), q.w());
}
renderer.render(scene, camera);
}
The Math.min(deltaTime, 0.1) clamp earns its place. Switch tabs and come back, and deltaTime is several seconds; simulating all of it at once sends the dice straight through the bowl. Clamping simply discards the backlog.
The second argument to stepSimulation caps the internal substeps at 10 per frame, so the physics timestep survives a frame rate drop.
Making it work on a phone
The desktop values are unplayable on a phone. The screen is tall and narrow, so identically sized dice become specks.
| Setting | Desktop | Mobile |
|---|---|---|
| Die size | 0.25 | 0.52 |
| Bowl radius | 2 | 2.6 |
| Initial speed | 2.5-4.0 | 8.0-11.0 |
| Angular velocity | 10 | 34 |
| Sound | On by default | Off by default |
Bigger dice carry more inertia and stop rolling, so speed and spin have to rise with the size. Changing any one of them alone does not work.
Do not trust the user agent alone
iPadOS reports itself as a Mac by default. Matching on the UA string alone misses it.
function isMobileNow() {
const ua = navigator.userAgent || '';
const uaMobile = navigator.userAgentData?.mobile === true;
const touchCapable =
('ontouchstart' in window) || ((navigator.maxTouchPoints || 0) > 0);
const iOSLike =
/iPhone|iPad|iPod/i.test(ua) ||
(navigator.platform === 'MacIntel' && (navigator.maxTouchPoints || 0) > 1);
return (
uaMobile ||
iOSLike ||
/Android/i.test(ua) ||
(window.matchMedia?.('(pointer: coarse)')?.matches === true) ||
(touchCapable && window.innerWidth < 900)
);
}
navigator.platform === 'MacIntel' together with maxTouchPoints > 1 identifies iPadOS. No touch-capable Mac exists, so the combination is unambiguous.
Reuse a single Audio element
Calling new Audio() per throw had to go. Instances accumulate and playback stalls on mobile.
const diceAudio = new Audio();
diceAudio.preload = 'auto';
diceAudio.playsInline = true;
function playRandomDiceSound() {
if (!isSoundOn) return;
const src = diceSounds[Math.floor(Math.random() * diceSounds.length)];
try {
diceAudio.pause();
diceAudio.currentTime = 0;
diceAudio.src = src;
const p = diceAudio.play();
if (p && typeof p.catch === 'function') p.catch(() => {});
} catch (e) {}
}
play() returns a Promise, so always catch it. Playback before a user gesture is rejected by the browser, and an uncaught rejection floods the console.
playsInline = true is for iOS Safari; without it, audio playback can open a fullscreen video player.
Four sounds — two glass, two pottery — are chosen at random. Simply avoiding a repeated sample does a surprising amount for the sense of a die tumbling.
The effects are pure CSS
The hand banner is a single div with classes swapped on it.
.role-banner.show {
animation: yakuPop 1500ms cubic-bezier(0.2,0.7,0.2,1.2) forwards,
glowPulse 3000ms ease-in-out infinite,
floatEffect 4000ms ease-in-out infinite;
}
.role-banner.legendary {
--glow-color: #ffd54f;
background: linear-gradient(180deg, #fff7e6 0%, #ff9800 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
Hands fall into four tiers, switched by class:
legendary: Pinzoro. Screen shake, light rays, confettirare: Arashi and Shigoronormal: a scoring diebad: Hifumi and no hand
Swapping the CSS variable --glow-color lets one animation serve every tier. Writing separate keyframes per hand becomes unmaintainable fast.
Where people get stuck
| Symptom | Cause |
|---|---|
| Dice do not move after the first throw | Missing body.activate() — the body is asleep |
| Visuals and collisions disagree | btBoxShape takes half-extents |
| Dice tunnel through after a tab switch | deltaTime is not clamped |
| Reported value is off by one face | Face normal order and the value table disagree |
| Pips flicker | Z-fighting. Offset them along the normal |
| Gradual slowdown | Allocating Ammo objects every frame |
That last row still applies to this app. Each throw calls scene.remove(), but geometries and materials are never disposed. scene.remove() only detaches from the scene graph; the GPU resources stay allocated. For long sessions this should be explicit:
mesh.traverse(obj => {
if (obj.geometry) obj.geometry.dispose();
if (obj.material) obj.material.dispose();
});
scene.remove(mesh);
Summary
- Three.js renders, Ammo.js simulates. Copying position and rotation each frame is the whole integration
- Reading the dice is your job. Rotate the face normals and take the largest dot product with world up
- A mismatch between face order and the value table produces a very hard bug to find
- Chinchirorin uses three dice: Pinzoro, Arashi, Shigoro, a pair’s odd die, Hifumi, or nothing
- Resolve hands from the top down. Reordering turns 1-1-1 into “1 Arashi”
- A die leaving the bowl can be called immediately. A hand waits for all three to settle
btBoxShapetakes half-extents- Forget
body.activate()and the second throw never moves - Without a
deltaTimeclamp, returning to the tab tunnels the dice - On mobile, raise size, speed and spin together. One alone is not enough
- iPadOS claims to be a Mac; use
maxTouchPointsto tell them apart
A physics engine will only get you as far as “that looks like a die rolling”. Reading the result and deciding the hand — that part is the actual game, and building it made that obvious. You can see it running at Chinchiro Game.