Building a 3D Dice Roller with React Three Fiber in One Night
June 22, 2026
From concept to production in 8 hours—here's how I built a realistic physics-based 3D dice roller using React Three Fiber, starting with a Codrops tutorial and adapting it for a modern React ecosystem.
What I Built
I created a PWA-enabled 3D dice roller that lets users throw virtual dice in a beautifully rendered WebGL scene. The experience includes:
- Realistic physics simulation using Cannon-ES
- Custom geometry generation for rounded dice with visible pips
- Mobile accelerometer support to shake your device and roll dice
- Glass morphism UI with smooth animations
- Production-ready deployment to Vercel
Try it live: https://3-d-dice-pied.vercel.app
This project was heavily inspired by the brilliant Codrops tutorial "Crafting a Dice Roller with Three.js and Cannon-ES" by Ksenia Kondrashova. I've adapted the original vanilla Three.js approach into a React Three Fiber implementation with additional mobile features.
Why React Three Fiber?
When I started this project, I had two choices:
- Use vanilla Three.js (as in the original tutorial)
- Use React Three Fiber for a more declarative, component-based approach
I chose React Three Fiber because:
Declarative Rendering: Instead of imperative Three.js boilerplate, I can write components that feel like regular React:
<Canvas>
<Dice position={[0, 5, 0]} onThrow={reset} />
<Physics gravity={[0, -9.81, 0]}>
{/* Physics bodies defined as JSX */}
</Physics>
</Canvas>
Better State Management: React handles component state and side effects elegantly, which matters when coordinating physics bodies, geometry updates, and UI state.
Rich Ecosystem: Libraries like @react-three/cannon (physics), @react-three/drei (utilities), and TypeScript support make development faster.
Performance: React Three Fiber uses refs and subscriptions efficiently, avoiding unnecessary re-renders while still being reactive.
The Main Challenge: Custom Dice Geometry
The biggest learning from this project wasn't physics—it was constructing complex shapes from basic Three.js geometries.
The Geometry Problem
Standard Three.js provides basic shapes: BoxGeometry, SphereGeometry, etc. But a convincing dice needs:
- Rounded edges (not sharp corners)
- Pip indentations (the dots on each face)
- Material depth (pips should look carved, not painted)
My Solution: Vertex Manipulation + Dual Meshes
I approached this in three steps:
Step 1: Rounded Edges
Starting with a subdivided BoxGeometry(1, 1, 1, 50, 50, 50), I manipulate vertex positions to create smooth edges:
// Pseudo-code for the rounding algorithm
const edgeRadius = 0.07
geometry.vertices.forEach(vertex => {
// Calculate distance from nearest edge
const distFromEdge = calculateDistanceFromEdge(vertex)
if (distFromEdge < edgeRadius) {
// Push vertex outward using cosine curve for smooth falloff
const falloff = Math.cos((distFromEdge / edgeRadius) * Math.PI / 2)
vertex.normalize()
vertex.multiplyScalar(0.5 + falloff * 0.086)
}
})
The result: sharp BoxGeometry corners smoothly transition into rounded surfaces.
Step 2: Pip Indentations
Each die face should have specific pip patterns:
- Face 1: 1 pip (centre)
- Face 2: 2 pips (diagonal)
- Face 3: 3 pips (diagonal)
- Face 4: 4 pips (corners)
- Face 5: 5 pips (corners + centre)
- Face 6: 6 pips (two rows)
For each pip location, I apply a cosine wave to create circular indentations:
const notchDepth = 0.02
geometry.vertices.forEach(vertex => {
// For each pip position on the current face
const distFromPipCenter = calculateDistanceToPipCenter(vertex)
if (distFromPipCenter < pipRadius) {
// Cosine curve creates smooth circular indent
const normalized = distFromPipCenter / pipRadius
const indent = notchDepth * (Math.cos(Math.PI * normalized) + 1) / 2
vertex.z -= indent // Push inward along face normal
}
})
This creates the visual effect of carved indentations without complex boolean operations.
Step 3: Dual-Mesh Approach
A single white mesh with indentations isn't enough—it doesn't look carved. I add a second black metallic mesh positioned slightly inside the first, creating depth:
<group>
{/* Outer white mesh with geometry modifications */}
<mesh geometry={outerGeometry} material={whiteMaterial} />
{/* Inner black mesh for pip depth */}
<mesh geometry={innerGeometry} material={blackMaterial} position={[0, 0, -0.001]} />
</group>
The subtle offset between meshes creates convincing shadows and depth without heavy computation.
Why This Approach Works
- One-time generation: Geometry is created once via
useMemoand reused - No runtime performance cost: Vertices are baked; no per-frame calculation
- Scalable: Easy to add more complex features (different pip styles, etc.)
- Portable: The geometry generation is pure maths, not Three.js-specific
This was the core learning from this project: when dealing with complex geometry, think about vertex manipulation, layering, and mathematical curves rather than trying to build everything from primitives.
Physics: Making Dice Feel Real
With geometry solved, physics is about making dice behave realistically.
Setting Up Cannon-ES
I use @react-three/cannon (React bindings for Cannon-ES) to add a physics world:
<Physics gravity={[0, -9.81, 0]} defaultContactMaterial={{ friction: 0.7 }}>
<PhysicsDice index={0} />
<PhysicsDice index={1} />
<Ground />
</Physics>
Key parameters:
- Gravity: Standard 9.81 m/s² downward
- Friction: 0.7 (prevents dice from sliding indefinitely)
- Linear damping: 0.25 (air resistance)
- Angular damping: 0.2 (rotational slowdown)
Initialising Each Throw
When the user clicks "Roll," I randomise dice position, rotation, and velocity:
const handleThrow = () => {
// Increment seed to trigger reset in all dice
setThrowSeed(prev => prev + 1)
}
// Inside PhysicsDice component
useEffect(() => {
if (api) {
// Random starting position above ground
api.position.set(
Math.random() * 0.4 - 0.2,
Math.random() * 0.3 + 3,
Math.random() * 0.4 - 0.2
)
// Random rotation
api.rotation.set(
Math.random() * Math.PI * 2,
Math.random() * Math.PI * 2,
Math.random() * Math.PI * 2
)
// Random velocity for variety
api.velocity.set(
(Math.random() - 0.5) * 10,
Math.random() * 2,
(Math.random() - 0.5) * 10
)
}
}, [throwSeed])
Detecting When Dice Settle
The tricky part: knowing when dice actually stop rolling. Naive approaches fail:
- Checking position alone misses rotating dice
- Checking velocity for 1 frame catches micro-movements
My solution: dual-threshold with stability frames
const linearThreshold = 0.08 // m/s
const angularThreshold = 0.12 // rad/s
const settleFramesRequired = 24 // ~400ms at 60fps
useFrame(() => {
const [linearVel] = api.velocity.getState()
const [angularVel] = api.angularVelocity.getState()
const linearMagnitude = Math.hypot(...linearVel)
const angularMagnitude = Math.hypot(...angularVel)
const isSlowing =
linearMagnitude < linearThreshold &&
angularMagnitude < angularThreshold
if (isSlowing) {
settleFrameCount++
if (settleFrameCount >= settleFramesRequired) {
// Dice settled!
calculateFaceValue()
reportSettled()
}
} else {
settleFrameCount = 0 // Reset counter if speed increases
}
})
This prevents false positives from dice gently rocking while still detecting settlement quickly.
Calculating Which Face Is Up
Here's the elegant part: quaternion dot products.
Each die face has a normal vector pointing outward (0,1,0) for the top face, (0,-1,0) for bottom, etc. I rotate these normals by the dice's current quaternion and find which points most upward:
const faceNormals = [
{ normal: [0, 1, 0], value: 1 }, // Top
{ normal: [0, -1, 0], value: 6 }, // Bottom
{ normal: [1, 0, 0], value: 3 }, // Right
{ normal: [-1, 0, 0], value: 4 }, // Left
{ normal: [0, 0, 1], value: 5 }, // Front
{ normal: [0, 0, -1], value: 2 } // Back
]
const [quat] = api.quaternion.getState()
const quaternion = new THREE.Quaternion(...quat)
let topFace = 1
let maxDot = -1
faceNormals.forEach(({ normal, value }) => {
const rotatedNormal = new THREE.Vector3(...normal)
.applyQuaternion(quaternion)
// Dot product with world up (0,1,0)
const dot = rotatedNormal.y
if (dot > maxDot) {
maxDot = dot
topFace = value
}
})
return topFace
This is more reliable than checking position because it accounts for dice orientation, not just location.
Porting from Vanilla Three.js to React: The Challenges
The Codrops tutorial uses vanilla Three.js. Adapting it to React Three Fiber required solving three main issues:
Challenge 1: Physics Body State Synchronisation
Problem: In vanilla Three.js, you update mesh position directly. In React Three Fiber, the mesh position must stay in sync with the physics body.
Solution: Use useFrame subscriptions:
useEffect(() => {
if (!api) return
// Subscribe to position changes
const unsubscribePos = api.position.subscribe(pos => {
meshRef.current?.position.set(...pos)
})
return unsubscribePos
}, [api])
This ensures the visual mesh always matches the physics body without re-rendering.
Challenge 2: Geometry Reconstruction on Every Frame
Problem: Creating geometry is expensive. If done in useFrame, performance tanks.
Solution: Memoise the geometry generation:
const geometry = useMemo(() => {
const baseGeometry = new THREE.BoxGeometry(1, 1, 1, 50, 50, 50)
// Apply vertex modifications
applySmoothRounding(baseGeometry)
applyPipIndentations(baseGeometry)
// Optimisations
baseGeometry.computeVertexNormals()
mergeVertices(baseGeometry)
return baseGeometry
}, []) // Dependencies empty = generate once
The geometry is created once at component mount and reused for every frame and every dice instance.
Challenge 3: Shader State Management
Problem: Custom shaders need access to dice rotation/position for per-frame calculations. Getting this state from physics bodies isn't straightforward.
Solution: Use refs to read physics state directly without triggering React re-renders:
const posRef = useRef([0, 0, 0])
const quatRef = useRef([0, 0, 0, 1])
// Subscribe to updates
api.position.subscribe(pos => {
posRef.current = pos
})
api.quaternion.subscribe(quat => {
quatRef.current = quat
})
// In shader or useFrame
useFrame(() => {
material.uniforms.diceRotation.value = new THREE.Quaternion(...quatRef.current)
})
This decouples physics state updates from React's render cycle.
Mobile Magic: Shake to Roll
Adding accelerometer support was surprisingly simple thanks to the DeviceMotion API and React hooks.
The Hook: useShakeDetection
export const useShakeDetection = (threshold = 15, cooldown = 1000) => {
const [canShake, setCanShake] = useState(false)
const [isPermitted, setIsPermitted] = useState(false)
const lastShakeTime = useRef(0)
const requestPermission = async () => {
if (typeof DeviceMotionEvent !== 'undefined' && DeviceMotionEvent.requestPermission) {
// iOS 13+ requires explicit permission
const permission = await DeviceMotionEvent.requestPermission()
if (permission === 'granted') {
setIsPermitted(true)
setupShakeDetection()
}
} else {
// Android: automatic access
setIsPermitted(true)
setupShakeDetection()
}
}
const setupShakeDetection = () => {
window.addEventListener('devicemotion', handleMotion)
}
const handleMotion = (event) => {
const { x, y, z } = event.acceleration
const magnitude = Math.sqrt(x*x + y*y + z*z)
// Check threshold and cooldown
const now = Date.now()
if (magnitude > threshold && now - lastShakeTime.current > cooldown) {
setCanShake(true)
lastShakeTime.current = now
// Reset after brief moment
setTimeout(() => setCanShake(false), 100)
}
}
return { canShake, isPermitted, requestPermission }
}
iOS Permission Handling
iOS 13+ requires user permission for motion sensor access. The UX flow:
- On mount, detect if permission is needed
- Show banner: "Enable shake to roll dice"
- User clicks "Enable Shake"
- Call
DeviceMotionEvent.requestPermission() - If granted, listen for devicemotion events
- When acceleration exceeds threshold, trigger roll
{!isPermitted && (
<banner>
<p>Enable shake to roll dice</p>
<button onClick={requestPermission}>Enable Shake</button>
</banner>
)}
{isPermitted && (
<indicator>Shake device to roll</indicator>
)}
{canShake && (
<effect>Shake detected!</effect>
)}
Why It Works
- Simple: Just listen for acceleration events
- Responsive: Magnitude calculation is instant
- Mobile-first: Works on iOS and Android with minimal branching
- User control: Permission banner respects privacy
The cooldown prevents accidental double-rolls from continued vibration.
Performance Optimisations
Building a 3D web app that runs smoothly on mobile requires careful optimisation:
1. Geometry Caching
Generate geometry once, use for every dice:
const geometry = useMemo(() => expensiveGeometryGeneration(), [])
// Geometry is created once, reused infinitely
2. Subscription-Based Physics
Don't poll physics state. Subscribe to changes:
// Bad: polling every frame
useFrame(() => {
const pos = body.getPosition() // Expensive!
})
// Good: subscribe once
useEffect(() => {
api.position.subscribe(pos => {
// Called only when position changes
})
}, [])
3. Mobile Device Pixel Ratio
High-DPI screens drain mobile performance. Cap the resolution:
<Canvas dpr={[1, 2]}>
{/* Render at 1x on low-end, 2x on high-end */}
</Canvas>
4. Efficient Physics Queries
Only check settlement when dice are moving:
useFrame(() => {
// Early exit if already settled
if (isSettled) return
// Only then check velocity
const linearMagnitude = calculateVelocity()
// ...
})
5. Layout Stability
Use env(safe-area-inset-*) for notched devices:
.controls {
padding-bottom: env(safe-area-inset-bottom);
}
The result: 60fps on iPhone 12, 12 Pro, and modern Android devices.
Visual Design & UX
The dice roller uses a minimalist aesthetic:
The Scene
- Background: Muted purple-grey (
#7a7a8e) - Ground: Metallic plane with subtle shadows
- Lighting: Ambient + directional + point light for realism
- Dice: Clean white with visible pips, subtle shadows
The UI
Glass morphism design with:
.container {
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 16px;
}
Buttons with smooth hover states:
button {
transition: transform 0.12s ease;
}
button:active {
transform: scale(0.95);
}
Results display shows total and rolls together for clarity.
Progressive Web App
The entire app is PWA-enabled:
{
"name": "3D Dice Roller",
"short_name": "Dice",
"display": "standalone",
"start_url": "/",
"icons": [
{ "src": "icon-192.png", "sizes": "192x192" },
{ "src": "icon-512.png", "sizes": "512x512" }
],
"screenshots": [...]
}
Users can install on home screen and use offline (with service worker).
Debug Mode Easter Egg
Append #debug to the URL to reveal:
- Real-time camera position (useful for fine-tuning views)
- FPS counter and memory monitor
- Copy-to-clipboard camera config
This was invaluable during development.
Building & Deploying
Development
pnpm dev # Start Vite dev server at localhost:5173
pnpm build # TypeScript check + optimised build
pnpm preview # Test production build locally
Production Deployment to Vercel
The project deploys to Vercel with zero configuration. Vercel automatically:
- Detects Vite project
- Runs
pnpm build - Serves optimised output globally with edge caching
- Handles HTTPS and PWA manifest serving
Live at: https://3-d-dice-pied.vercel.app
The build is tiny—entire app (Three.js, React, physics engine) gzipped is under 500KB.
Key Learnings
After building this in one night, here's what stuck with me:
1. Vertex Manipulation Is Powerful
Instead of complex boolean operations or multiple geometries, manipulating vertex positions is simple, efficient, and portable. The rounded edges and pip indentations are just mathematics applied to vertex coordinates.
2. React Three Fiber Bridges Worlds
React Three Fiber makes 3D development feel like regular component development. State management, hooks, and composition patterns carry over perfectly. It's not just a wrapper—it's a genuine improvement over vanilla Three.js for React apps.
3. Mobile APIs Are Accessible
The DeviceMotion API is simple to use. With just 50 lines of code, I added a delightful mobile feature that would be much more complex in native development.
4. Physics Settlement Detection Is Nuanced
Simply checking velocity isn't enough. The dual-threshold + stability-frames approach handles edge cases (micro-movements, gentle rocks) that naive implementations miss.
5. Subscription-Based Sync Matters
React re-renders are expensive in 3D. Using subscriptions to keep physics and visuals in sync without triggering renders is a pattern worth learning.
What's Next?
Potential enhancements:
- Multiple dice types: Polyhedral dice (d20, d12, etc.) for TTRPG fans
- Custom themes: Dark mode, neon aesthetics, etc.
- Dice stacking: Physics interactions when rolling multiple dice at once
- Sound effects: Dice hitting surfaces, settling sounds
- Multiplayer: Real-time dice rolls in a shared WebSocket room
- Haptics feedback: Vibration on iOS when dice settle
Final Thoughts
This project proves that modern web APIs and libraries make 3D development approachable. A decade ago, this would require C++ and months of learning. Today, it's achievable in React with minimal Three.js knowledge.
If you're interested in 3D web development:
- Try the demo: https://3-d-dice-pied.vercel.app
- Read the Codrops tutorial: Crafting a Dice Roller with Three.js and Cannon-ES by Ksenia Kondrashova
- Explore React Three Fiber: docs.pmnd.rs/react-three-fiber/
- Learn Cannon-ES physics: cannon-es.org
The tools are there. The barrier is lower than ever. Go build something cool with WebGL.
References
- Live Demo: https://3-d-dice-pied.vercel.app
- GitHub: tomkiernan120/3D-Dice
- Original Tutorial: Crafting a Dice Roller with Three.js and Cannon-ES by Ksenia Kondrashova
- Libraries Used: