Creating a 10-Sided Die with React, Part 1


An animated GIF of a D10 spinning

This is a 10-sided die. Below is my attempt to recreate one with JavaScript—just the shape for now. Click it to see it move!

You can create 3D graphics with React! This is how I built a custom three.js geometry to use in react-three-fiber. Hopefully documenting my process can help someone get started* with 3D programming.

*I'm assuming that you have some basic knowledge of three.js and React. I'm no expert; that's all I had when I figured this stuff out.

Also, I hope you like math.

Motivation

With limited opportunities to hang out with friends, D&D has been a great outlet for me in the COVID-19 era. It's healthy escapism in the form of collaborative storytelling with a bunch of improv thrown in. So it's not hard to see why it's surged in popularity recently.

Two D&D web apps that have become indispensable to my group are Roll20 and D&D Beyond. Respectively, they make it easy to keep track of game state (enemy positions, players' turn order) and character state (hit points, inventory, spells known). Using traditional character sheets, it's frustrating to rifle through stacks of paper and erase/cross things out to update all of that information by hand.

Part of the joy of playing D&D is rolling dice that affect the outcomes of characters' actions. These talismans determine whether you can persuade the lizard people to fight the frog people to create a distraction while you reclaim an ancient castle that's been taken over by cultists. Or whether you can kill a boss while doing a cool backflip.

Rolling dice is simple enough to manage when everyone is sitting around a table in a room together. Unfortunately, in a virtual environment, some of that tactile magic is lost. Both Roll20 and D&D Beyond attempt to make up for the absence of real dice with 3D dice rollers. ✨

Is it possible to make my own 3D dice roller?

As a web developer, I use each of these 3D dice rollers and think, "This is amazing! I wonder how its code works though."

And also... "I wonder how hard it would be to recreate it."

I knew it was possible! Even if it proved to be really challenging, I also knew it'd still be a valuable experience. Reverse-engineering software is one of the best ways to learn new technologies.

Learning awesome tools

There are two tools in particular that I wanted to practice using:

  1. three.js is a JavaScript library I've been interested in since college, and the tool of choice for displaying interactive 3D objects in a web browser. Both Roll20 and D&D Beyond use it to render dice onto a canvas.

  2. react-three-fiber (r3f) is a wrapper around three.js, giving us a way to create three.js scenes declaratively with React.

Why is it a good idea to use three.js and React together? To quote Corrine Toracchio's talk from Next.js Conf 2020:

Building dynamic scene graphs declaratively with re-usable components makes dealing with three.js easier and brings order and sanity to your codebase.

This makes it much simpler to create 3D objects and deal with their relationships in code—even complex stuff like physics!

r3f's ecosystem also includes some nifty helper libraries which would come in handy for a dice roller:

How do I make a dice roller with r3f?

  1. Create React components to represent each type of die.
  2. Render those components on a canvas as necessary, depending on what dice a user wants to roll.
  3. Roll the dice in a physics simulation, and be able to obtain the result of the roll.

The very first step—making the dice—was both easier and harder than I expected.

Why did I have to make a D10 from scratch?

A standard set of D&D dice consists of seven dice: a D4, a D6, a D8, two D10s—one numbered 0-9 and another numbered 00-90, whose values are added to obtain random numbers from 1-100—a D12, and a D20. Every die's shape is a convex polyhedron with congruent faces, which means that each of its marked numbers is equally likely to be rolled.

Except for the D10, these convex polyhedra are regular, which means that all of their faces' angles and sides are equal, and the same number of faces meet at each vertex. These shapes are better known as Platonic solids:

Platonic solidDieImage
TetrahedronD4Tetrahedron.svg
CubeD6Hexahedron.svg
OctahedronD8Octahedron.svg
DodecahedronD12Dodecahedron.svg
IcosahedronD20Icosahedron.svg

Thanks to three.js providing geometric definitions for each of these shapes, I could make these dice out of the box. However, there is no Platonic solid with ten faces. So the D10 is a shape called a pentagonal trapezohedron. It has ten vertices where 3 faces meet, and two vertices where 5 faces meet.

Pentagonal trapezohedron

I could have just created the model for this thing in Blender (...if I learned Blender first). But three.js is perfectly capable of stitching together a couple of triangles in the browser. It couldn't be that hard.

Implementation

To start, I forked this CodeSandbox from the documentation of use-cannon. This scene consists of a cube plowing through a hundred little spheres in a box. Conceptually, it's not too different from rolling dice.

Setting the scene

This is what the 3D scene to showcase a die looks like—minus lights, the floor, and some walls.

// index.js

import React from 'react';
import ReactDOM from 'react-dom';
import { Canvas } from 'react-three-fiber';
import { Physics } from 'use-cannon';
import { D8 } from 'D8';

// TODO Inlining the gl and camera props breaks my Markdown parser 😢
const glProps = { alpha: false };
const cameraProps = { position: [0, -12, 16] };

ReactDOM.render(
<Canvas concurrent shadowMap sRGB gl={glProps} camera={cameraProps}>
{/* Add lights here */}
<Physics gravity={[0, 0, -30]}>
{/* Add floor and walls here */}
<D8 position={[0, 0, 4]} rotation={[0, 1, 0]} />
</Physics>
</Canvas>,
document.getElementById('root')
);

Even though I'm relatively new to JSX, what's going on here is fairly straightforward:

  1. Create a canvas.

  2. Inside that canvas, create a physics world with gravity pulling in the negative z direction.

  3. Instantiate a die in the physics world.

    • Initial position is slightly above the floor, so that the die doesn't clip through it.
    • Initial rotation is set so that when the die hits the ground, it falls to one side instead of remaining uncannily balanced. The physics are somewhat convincing, but they're definitely not real.
  4. Render the canvas at a specified root element.

...Why a D8 though? Isn't this a blog post about a D10?

Creating a D8

A D8 is an excellent base from which to create a D10. The two shapes are actually pretty similar! Each consists of a top and a bottom vertex, which both connect to a ring of vertices between them.

Octahedron and pentagonal trapezohedron

For the octahedron, the middle layer of vertices is a square. So four of its faces are formed by connecting the middle layer to the top vertex, and the other four faces are formed by connecting the middle layer to the bottom vertex.

Likewise, the pentagonal trapezohedron has five faces on top, and five faces on bottom. Its middle layer of vertices is roughly a pentagon. More on that later.

Because Platonic solids are exported by three.js and drei, this is all I needed to make a D8 component that obeys the laws of physics:

// D8.js

import React from 'react';
import * as THREE from 'three';
import { useConvexPolyhedron } from 'use-cannon';
import { Octahedron } from 'drei';

const D8 = (props) => {
const radius = 4;
const octahedronGeometry = new THREE.OctahedronGeometry(radius);
const [ref, api] = useConvexPolyhedron(() => {
return {
args: octahedronGeometry,
mass: 1,
...props,
};
});

return (
<Octahedron
args={radius}
ref={ref}
onClick={() => api.applyImpulse([0, 20, 0], [0, 0, 0])}
castShadow
receiveShadow
>

<meshNormalMaterial attach="material" />
</Octahedron>
);
};

A couple of things to note:

  1. Even though drei gives us an <Octahedron /> component, the vanilla three.js octahedronGeometry has to be created for physics to work. That geometry is passed into useConvexPolyhedron, which returns a ref that's aware of physical properties like shape and mass. Passing this ref as a prop to the <Octahedron /> component is what links the object on the canvas to the physics world. useConvexPolyhedron also returns an API object whose methods allow you to apply transformations and interactions to your shape.

  2. I added an onClick handler for two reasons:

    1. To verify that physics work. I.e., collisions, rotations.
    2. To inspect different sides of the shape. Kicking it around seemed more fun than orbiting a camera around it.
  3. In r3f, any three.js object can be expressed as a JSX component. <meshNormalMaterial /> is a material that colors each face in a mesh according to the direction of its normal vector.

Here's the D8 in action:

Manually creating a D8

To create a D10, first my goal was to create an octahedron (D8) component using PolyhedronGeometry and drei's <Polyhedron /> that behaved identically to the one above, just to make sure I have a good grasp on r3f. Because the shapes have similar structures, I could then turn the shape into a pentagonal trapezohedron (D10) by passing different parameters to the PolyhedronGeometry constructor.

What parameters are necessary?

For this part, I relied heavily on Three.js Fundamentals' articles on custom geometry and custom buffer geometry. Those article raised some important considerations:

How do I draw an octahedron?

Octahedron
Here's an octahedron with vertices numbered 0-5.

Note that I numbered the vertices of the D8 above counterclockwise, starting from the left, for no reason other than that's how I labeled the shape when I drew it.

The first face contains vertices 0, 2, and 3 in that order so that we know the face is front-facing.

Here's that same octahedron, made using the same code from the previous snippet—but with PolyhedronGeometry and explicitly defined vertices and faces following the labels in the diagram, instead of OctahedronGeometry:

// D8.js

import React from 'react';
import * as THREE from 'three';
import { useConvexPolyhedron } from 'use-cannon';
import { Polyhedron } from 'drei';

const D8 = (props) => {
const radius = 4;
const vertices = [
[0, 0, 1],
[0, 0, -1],
[-1, 0, 0],
[0, -1, 0],
[1, 0, 0],
[0, 1, 0],
].flat();
const faces = [
[0, 2, 3],
[0, 3, 4],
[0, 4, 5],
[0, 5, 2],
[1, 3, 2],
[1, 4, 3],
[1, 5, 4],
[1, 2, 5],
].flat();
const args = [vertices, faces, radius, 0];
const octahedronGeometry = new THREE.PolyhedronGeometry(...args);
const [ref, api] = useConvexPolyhedron(() => {
return {
args: octahedronGeometry,
mass: 1,
...props,
};
});

return (
<Polyhedron
args={args}
ref={ref}
onClick={() => api.applyImpulse([0, 20, 0], [0, 0, 0])}
castShadow
receiveShadow
>
<meshNormalMaterial attach="material" />
</Polyhedron>
);
};

I grouped each both the vertex and face arrays in threes and called Array.prototype.flat() afterward for readability.

As you can see, this D8 acts exactly the same as the previous one.

Converting a D8 to a D10

Remember how I mentioned that the middle layer of vertices in a pentagonal trapezohedron is roughly a pentagon?

Pentagonal bipyramid

If those vertices did form a pentagon, then the shape would be a similar but different one called a pentagonal bipyramid, which has triangular faces.

Pentagonal trapezohedron

In contrast, each face of a pentagonal trapezohedron is a quadrilateral that kind looks like a kite. 🪁

Even though each face has four sides, its faces array will still be a list of triangular faces. Instead of 10 quadrilaterals, the array will represent 20 triangles.

Another thing about buffer geometries: no matter whether a face is a triangle, quadrilateral, or some other shape, faces are always defined as triangles because three points uniquely identify a plane. Any face that consists of a shape with more than three vertices is really just multiple triangles which lie on the same plane. Also, GPUs are really efficient at rendering triangles in 3D graphics.

So what exactly is that middle vertex layer?

You can visualize a pentagonal trapezohedron's middle layer of vertices in one of two ways:

  1. Two pentagons, one above and one below, rotated 1/10th of a turn apart from each other.

    Upper and lower pentagon

  2. A zigzaggy decagon whose vertices alternate going up and down.

    Zigzaggy decagon

The latter model shows the way to create this middle vertex layer: Trace out a circle, and place ten equally spaced vertices along this circle, alternating the height of each vertex between high and low.

How do I draw a circle in JavaScript?

Well, there are a lot of ways to do that. But one of them is trigonometry! The method described above is exactly what Anton Natarov did in his three.js dice roller. He released the source code for it without a license, which Michael Wolf ported to Github here. I had snooped around in Roll20 and D&D Beyond's client-side JavaScript to see how their D10s were constructed, but I couldn't find any references to D10s besides texture images. Anton/Michael's code was the next best thing.

Here's how Anton created the D10's middle vertex layer with a for loop:

// threejs-dice/blob/master/lib/dice.js

for (let i = 0, b = 0; i < 10; ++i, b += (Math.PI * 2) / 10) {
this.vertices.push([Math.cos(b), Math.sin(b), 0.105 * (i % 2 ? 1 : -1)]);
}

One important difference between his implementation and mine is the order in which vertices are created. Anton created vertices counterclockwise, starting from the right ((1, 0)). Because I wanted to match the vertex order of the D8 above, I created the D10's middle layer of vertices counterclockwise, starting from the left ((-1, 0)).

Pentagonal trapezohedron vertices

To draw a circle counterclockwise around the origin from (-1, 0), you can use the following parametric equation:

For 0 ≤ b ≤ 2π: x starts at -1, goes to 1, then goes back to -1; y starts at 0, goes to -1, then to 1, then back to 0.

Drawing a circle counterclockwise from the left on Desmos

When we take the z component into account, we have our zigzaggy decagon:

Drawing a circle counterclockwise from the left with varying z-heights on MathBox

By modifying the vertex and face arrays again, the octahedron from the last snippet can be turned into a pentagonal trapezohedron. The code below produces a kickable D10—the very first demo above!

// D10.js

import React from 'react';
import * as THREE from 'three';
import { useConvexPolyhedron } from 'use-cannon';
import { Polyhedron } from 'drei';

const D10 = (props) => {
const sides = 10;
const radius = 4;
const vertices = [
[0, 0, 1],
[0, 0, -1],
].flat();

for (let i = 0; i < sides; ++i) {
const b = (i * Math.PI * 2) / sides;
vertices.push(-Math.cos(b), -Math.sin(b), 0.105 * (i % 2 ? 1 : -1));
}

const faces = [
[0, 2, 3],
[0, 3, 4],
[0, 4, 5],
[0, 5, 6],
[0, 6, 7],
[0, 7, 8],
[0, 8, 9],
[0, 9, 10],
[0, 10, 11],
[0, 11, 2],
[1, 3, 2],
[1, 4, 3],
[1, 5, 4],
[1, 6, 5],
[1, 7, 6],
[1, 8, 7],
[1, 9, 8],
[1, 10, 9],
[1, 11, 10],
[1, 2, 11],
].flat();
const args = [vertices, faces, radius, 0];
const pentagonalTrapezohedronGeometry = new THREE.PolyhedronGeometry(...args);
const [ref, api] = useConvexPolyhedron(() => {
return {
args: pentagonalTrapezohedronGeometry,
mass: 1,
...props,
};
});

return (
<Polyhedron
args={args}
ref={ref}
onClick={() => api.applyImpulse([0, 20, 0], [0, 0, 0])}
castShadow
receiveShadow
>
<meshNormalMaterial attach="material" />
</Polyhedron>
);
};

I'm sure there's a one-line for loop that will generate the same faces array—but that's beyond the scope of this blog post I didn't feel like it writing out the vertex order by hand is a little clearer.

To see the above code in action, scroll back to the beginning or check out the scene on CodeSandbox!

Conclusion

Thank you for sticking it out through this humongous blog post. I hope you learned something new. 😊

The next challenge is to add a number texture to the die. Stay tuned for part 2!

In the meantime...

Can this post or the dice roller be made more accessible? Are there any illustrations/demos that would make this material easier to understand? Am I causing huge performance issues? Did I mess up some geometric concepts/terminology?

If you have an answer to any of these questions, please hit me up on Twitter!

An animated GIF of a D10 spinning
Retro spinning dice GIFs courtesy of GifCities.

Further reading