← Back to Blog

Building a 3D Dice Roller with React Three Fiber in One Night

June 22, 2026

ReactThree.jsWebGLPhysicsMobile Development

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:

  1. Use vanilla Three.js (as in the original tutorial)
  2. 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 useMemo and 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:

  1. On mount, detect if permission is needed
  2. Show banner: "Enable shake to roll dice"
  3. User clicks "Enable Shake"
  4. Call DeviceMotionEvent.requestPermission()
  5. If granted, listen for devicemotion events
  6. 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:

  1. Detects Vite project
  2. Runs pnpm build
  3. Serves optimised output globally with edge caching
  4. 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:

  1. Try the demo: https://3-d-dice-pied.vercel.app
  2. Read the Codrops tutorial: Crafting a Dice Roller with Three.js and Cannon-ES by Ksenia Kondrashova
  3. Explore React Three Fiber: docs.pmnd.rs/react-three-fiber/
  4. Learn Cannon-ES physics: cannon-es.org

The tools are there. The barrier is lower than ever. Go build something cool with WebGL.


References