I’ve been thinking through a game idea that I know will need a dice rolling mechanic, so I decided to build out an MVP of that. The following is the end result of 2 days of working through that.

Diceroll

Resources

I learned a lot from these resources, from the programming logic in Bevy to modeling different dice shapes in Blender.

After watching the tutorial, I made these in less than 5 minutes, so it’s pretty accessible to a first time Blender user. I will need to spend a lot more time customizing the look and feel of them, but the first tutorial gave me some tips on how to do that.

Blender Dice

Setup

I’m working on a plugin where you’ll be able to import this work into your own game, but in the meantime this is how I got an MVP out.

Project structure

assets/
    font/FiraSans-Bold.ttf
    models/dice/...
die_roller/
    src/lib.rs
    Cargo.toml
src/main.rs
Cargo.toml

Set up a new project

We’re going to have two crates: a library that can roll a die, and our main program (bin) that depends on the die roller.

cargo new dice_roller
cd dice_roller
cargo new die_roller --lib

Assets

For testing, we’re just going to use existing assets from bevy_dice. Copy the assets directory into the root of your project.

Cargo.toml (dice_roller)

Here we depend on bevy and our local die_roller library. We add a debug feature so we can control when to run bevy-inspector-egui later (only in development).

[package]
name = "dice_roller"
version = "0.1.0"
edition = "2021"

[dependencies]
bevy = "0.14.1"
die_roller = { path = "die_roller", features = ["debug"] }

Cargo.toml (die_roller)

We have more permissive dependencies, not depending on a specific patch version but a specific minor version.

[package]
name = "die_roller"
version = "0.1.0"
edition = "2021"

[dependencies]
bevy = "0.14"
bevy_rapier3d = "0.27"
bevy_render = "0.14"
meshtext = "0.3"
bevy-inspector-egui = { version = "0.25", optional = true }

[features]
default = []
debug = ["bevy-inspector-egui"]

src/main.rs

Our main program will import a DieRollerPlugin from die_roller, and this is all it will call. All of the details of the dice roll will be ran inside the die_roller library.

use bevy::prelude::*;
use die_roller::DieRollerPlugin;

fn main() {
    App::new().add_plugins((DefaultPlugins, DieRollerPlugin)).run();
}

die_roller/src/lib.rs

The majority of the code resides here. This was the final product after numerous iteration. It’s contained in a single file for easy copying and modifying, but in the library I’m working on it’s separated into cohesive modules. Use/modify as you see fit.

Note: Depending on your dice object, you may need to adjust CUBE_SIDES to map to the correct side.

use bevy::prelude::*;
use bevy::render::mesh::Mesh;
use bevy::render::mesh::PrimitiveTopology;
use bevy_rapier3d::prelude::*;
use bevy_render::render_asset::RenderAssetUsages;
use meshtext::{MeshGenerator, MeshText, TextSection};

#[cfg(debug_assertions)]
use bevy_inspector_egui::quick::WorldInspectorPlugin;

/// Plugin to manage die rolling in the Bevy application
pub struct DieRollerPlugin;
impl Plugin for DieRollerPlugin {
    fn build(&self, app: &mut App) {
        app.add_plugins(RapierPhysicsPlugin::<NoUserData>::default())
            .insert_resource(RapierConfiguration {
                gravity: Vec3::new(0.0, -9.81, 0.0),
                physics_pipeline_active: true,
                query_pipeline_active: true,
                timestep_mode: TimestepMode::Fixed {
                    dt: 1.0 / 30.0,
                    substeps: 2,
                },
                scaled_shape_subdivision: 4,
                force_update_from_transform_changes: true,
            })
            .add_systems(Startup, setup)
            .add_systems(Update, (roll_die, update_die));

        #[cfg(debug_assertions)]
        app.add_plugins(WorldInspectorPlugin::new());
    }
}

/// Enum to represent the current state of the die
#[derive(Debug, PartialEq, Eq)]
enum GameState {
    Rolling,
    Stationary,
    Cocked,
}

/// Component representing a die with its state and spin timer
#[derive(Component)]
struct Die {
    state: GameState,
    spin_timer: Timer,
}

const CUBE_SIDES: [Vec3; 6] = [
    Vec3::new(0.0, 1.0, 0.0),  // Top face (1)
    Vec3::new(1.0, 0.0, 0.0),  // Right face (2)
    Vec3::new(0.0, 0.0, -1.0), // Back face (3)
    Vec3::new(0.0, 0.0, 1.0),  // Front face (4)
    Vec3::new(-1.0, 0.0, 0.0), // Left face (5)
    Vec3::new(0.0, -1.0, 0.0), // Bottom face (6)
];

/// Component for entities that display the roll result text
#[derive(Component)]
struct RollResultText;

/// Sets up the game environment including camera, lighting, die, and walls
fn setup(
    mut commands: Commands,
    mut meshes: ResMut<Assets<Mesh>>,
    asset_server: Res<AssetServer>,
    mut materials: ResMut<Assets<StandardMaterial>>,
) {
    setup_camera(&mut commands);
    setup_lighting(&mut commands);
    spawn_die(&mut commands, &mut meshes, &asset_server, &mut materials);
    spawn_ground_plane(&mut commands);
    spawn_containment_box(&mut commands, &mut meshes, &mut materials);
}

/// Sets up the main camera
fn setup_camera(commands: &mut Commands) {
    commands.spawn((
        Camera3dBundle {
            transform: Transform::from_xyz(0.0, 10.0, 0.0).looking_at(Vec3::ZERO, Vec3::Y),
            ..Default::default()
        },
        Name::new("Main Camera"),
    ));
}

/// Sets up the lighting in the scene
fn setup_lighting(commands: &mut Commands) {
    // Brighter directional light to simulate daylight
    commands.spawn((
        DirectionalLightBundle {
            directional_light: DirectionalLight {
                color: Color::srgb(1.0, 1.0, 0.9), // Slightly warm color for daylight effect
                illuminance: 100_000.0,            // High illuminance for a bright daylight effect
                shadows_enabled: true,
                ..Default::default()
            },
            transform: Transform {
                translation: Vec3::new(0.0, 10.0, 0.0), // Position above the scene
                rotation: Quat::from_rotation_x(-std::f32::consts::FRAC_PI_4), // Angled to create natural shadows
                ..Default::default()
            },
            ..Default::default()
        },
        Name::new("Sunlight"),
    ));

    // Retaining a point light for additional effects, if needed
    commands.spawn((
        PointLightBundle {
            point_light: PointLight {
                intensity: 2000.0, // Lowered intensity for ambient fill
                range: 20.0,       // Affects a limited area
                shadows_enabled: true,
                ..Default::default()
            },
            transform: Transform::from_xyz(4.0, 8.0, 4.0),
            ..Default::default()
        },
        Name::new("Point Light"),
    ));
}

/// Spawns the die and its associated components in the game world
fn spawn_die(
    commands: &mut Commands,
    _meshes: &mut ResMut<Assets<Mesh>>,
    asset_server: &Res<AssetServer>,
    materials: &mut ResMut<Assets<StandardMaterial>>,
) {
    let dice_handle = asset_server.load("models/dice/scene.gltf#Scene0");
    let dice_material = materials.add(StandardMaterial {
        base_color: Color::WHITE,
        unlit: true,
        ..Default::default()
    });
    commands.spawn((
        SceneBundle {
            scene: dice_handle.clone(),
            transform: Transform {
                translation: Vec3::new(0.0, 1.5, 0.0),
                scale: Vec3::splat(0.8),
                ..Default::default()
            },
            ..Default::default()
        },
        dice_material,
        RigidBody::Dynamic,
        Collider::cuboid(0.5, 0.5, 0.5),
        ExternalForce {
            force: Vec3::ZERO,
            torque: Vec3::ZERO,
        },
        ExternalImpulse {
            impulse: Vec3::ZERO,
            torque_impulse: Vec3::ZERO,
        },
        GravityScale(1.0),
        AdditionalMassProperties::Mass(0.1),
        Velocity::default(),
        Die {
            state: GameState::Stationary,
            spin_timer: Timer::from_seconds(0.1, TimerMode::Once),
        },
        Name::new("Die"),
    ));
}

/// Spawns the ground plane to prevent the die from falling indefinitely
fn spawn_ground_plane(commands: &mut Commands) {
    commands.spawn((
        RigidBody::Fixed,
        Collider::cuboid(10.0, 0.1, 10.0),
        Transform::from_xyz(0.0, 0.0, 0.0),
        Name::new("Ground Plane"),
    ));
}

/// Spawns the containment box with transparent walls to keep the die within bounds
fn spawn_containment_box(
    commands: &mut Commands,
    meshes: &mut ResMut<Assets<Mesh>>,
    materials: &mut ResMut<Assets<StandardMaterial>>,
) {
    let box_size = 1.5;
    let wall_thickness = 0.01;
    let wall_height = box_size * 2.0;
    let new_wall_height = wall_height * 2.0;

    let transparent_material = materials.add(StandardMaterial {
        base_color: Color::srgba(0.0, 0.5, 0.5, 0.0),
        alpha_mode: AlphaMode::Blend,
        ..Default::default()
    });

    spawn_wall(
        commands,
        meshes,
        transparent_material.clone(),
        Vec3::new(-box_size - wall_thickness / 2.0, new_wall_height / 2.0, 0.0),
        Vec3::new(wall_thickness, new_wall_height, wall_height),
        Vec3::new(wall_thickness / 2.0, new_wall_height / 2.0, wall_height / 2.0),
        "Left Wall",
    );

    spawn_wall(
        commands,
        meshes,
        transparent_material.clone(),
        Vec3::new(box_size + wall_thickness / 2.0, new_wall_height / 2.0, 0.0),
        Vec3::new(wall_thickness, new_wall_height, wall_height),
        Vec3::new(wall_thickness / 2.0, new_wall_height / 2.0, wall_height / 2.0),
        "Right Wall",
    );

    spawn_wall(
        commands,
        meshes,
        transparent_material.clone(),
        Vec3::new(0.0, new_wall_height / 2.0, box_size + wall_thickness / 2.0),
        Vec3::new(wall_height, new_wall_height, wall_thickness),
        Vec3::new(wall_height / 2.0, new_wall_height / 2.0, wall_thickness / 2.0),
        "Front Wall",
    );

    spawn_wall(
        commands,
        meshes,
        transparent_material.clone(),
        Vec3::new(0.0, new_wall_height / 2.0, -box_size - wall_thickness / 2.0),
        Vec3::new(wall_height, new_wall_height, wall_thickness),
        Vec3::new(wall_height / 2.0, new_wall_height / 2.0, wall_thickness / 2.0),
        "Back Wall",
    );

    spawn_wall(
        commands,
        meshes,
        transparent_material.clone(),
        Vec3::new(0.0, new_wall_height + wall_thickness / 2.0, 0.0),
        Vec3::new(wall_height, wall_thickness, wall_height),
        Vec3::new(wall_height / 2.0, wall_thickness / 2.0, wall_height / 2.0),
        "Top Wall",
    );

    spawn_wall(
        commands,
        meshes,
        transparent_material.clone(),
        Vec3::new(0.0, -wall_thickness / 2.0, 0.0),
        Vec3::new(wall_height, wall_thickness, wall_height),
        Vec3::new(wall_height / 2.0, wall_thickness / 2.0, wall_height / 2.0),
        "Bottom Wall",
    );
}

/// Spawns a wall with given dimensions and properties
fn spawn_wall(
    commands: &mut Commands,
    meshes: &mut ResMut<Assets<Mesh>>,
    material: Handle<StandardMaterial>,
    position: Vec3,
    size: Vec3,
    collider_size: Vec3,
    name: &'static str,
) {
    commands.spawn((
        PbrBundle {
            mesh: meshes.add(Cuboid::new(size.x, size.y, size.z)),
            material,
            transform: Transform::from_translation(position),
            ..Default::default()
        },
        RigidBody::Fixed,
        Collider::cuboid(collider_size.x, collider_size.y, collider_size.z),
        Name::new(name),
    ));
}

/// Rolls the die when the space key is pressed
fn roll_die(input: Res<ButtonInput<KeyCode>>, mut query: Query<(&mut Die, &mut ExternalForce, &mut ExternalImpulse)>) {
    for (mut die, mut external_force, mut external_impulse) in query.iter_mut() {
        if input.just_pressed(KeyCode::Space) && (die.state == GameState::Stationary || die.state == GameState::Cocked)
        {
            println!("Rolling the die!");
            die.state = GameState::Rolling;
            die.spin_timer.reset();
            apply_initial_forces(&mut external_force, &mut external_impulse);
        }
    }
}

/// Applies initial forces to the die when rolling
fn apply_initial_forces(external_force: &mut ExternalForce, external_impulse: &mut ExternalImpulse) {
    external_force.force = Vec3::new(0.0, -19.62, 0.0);
    external_force.torque = Vec3::new(7.0, 1.0, 6.0);
    external_impulse.impulse = Vec3::new(5.0, 1.0, 5.0);
    external_impulse.torque_impulse = Vec3::new(7.0, 1.0, 6.0);
}

/// Updates the state of the die based on its velocity and position
fn update_die(
    mut commands: Commands,
    time: Res<Time>,
    mut query: Query<(
        &mut Die,
        &Transform,
        &mut ExternalForce,
        &mut ExternalImpulse,
        &mut RigidBody,
        &Velocity,
    )>,
    text_query: Query<Entity, With<RollResultText>>,
    camera_query: Query<&Transform, With<Camera>>,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<StandardMaterial>>,
) {
    if let Ok(camera_transform) = camera_query.get_single() {
        for (mut die, transform, mut external_force, mut external_impulse, mut rigid_body, velocity) in query.iter_mut()
        {
            die.spin_timer.tick(time.delta());

            if die.spin_timer.finished() {
                handle_die_state(
                    &mut commands,
                    &mut die,
                    transform,
                    &mut external_force,
                    &mut external_impulse,
                    &mut rigid_body,
                    velocity,
                    &text_query,
                    camera_transform,
                    &mut meshes,
                    &mut materials,
                );
            }
        }
    }
}

/// Handles the state of the die after it has finished rolling
fn handle_die_state(
    commands: &mut Commands,
    die: &mut Die,
    transform: &Transform,
    external_force: &mut ExternalForce,
    external_impulse: &mut ExternalImpulse,
    rigid_body: &mut RigidBody,
    velocity: &Velocity,
    text_query: &Query<Entity, With<RollResultText>>,
    camera_transform: &Transform,
    meshes: &mut ResMut<Assets<Mesh>>,
    materials: &mut ResMut<Assets<StandardMaterial>>,
) {
    match die.state {
        GameState::Rolling => check_roll_completion(die, transform, velocity),
        GameState::Cocked => handle_cocked_die(
            commands,
            text_query,
            camera_transform,
            meshes,
            materials,
            external_impulse,
            die,
        ),
        GameState::Stationary => display_roll_result(
            commands,
            transform,
            text_query,
            camera_transform,
            meshes,
            materials,
            external_force,
            external_impulse,
            rigid_body,
        ),
    }
}

/// Checks if the die has finished rolling and updates its state accordingly
fn check_roll_completion(die: &mut Die, transform: &Transform, velocity: &Velocity) {
    let linvel = velocity.linvel;
    let angvel = velocity.angvel;
    let threshold = 0.4;
    let cocked_tolerance = 0.4;

    if linvel.length() < threshold && angvel.length() < threshold {
        let is_cocked = is_die_cocked(transform, cocked_tolerance);
        die.state = if is_cocked {
            println!("The die is cocked!");
            GameState::Cocked
        } else {
            println!("The die is stationary.");
            GameState::Stationary
        };
    }
}

/// Determines if the die is cocked based on its current position
fn is_die_cocked(transform: &Transform, tolerance: f32) -> bool {
    CUBE_SIDES.iter().any(|&side| {
        let world_normal = transform.rotation.mul_vec3(side);
        let abs_x = world_normal.x.abs();
        let abs_y = world_normal.y.abs();
        let abs_z = world_normal.z.abs();

        (abs_x > tolerance && abs_x < 1.0 - tolerance)
            || (abs_y > tolerance && abs_y < 1.0 - tolerance)
            || (abs_z > tolerance && abs_z < 1.0 - tolerance)
    })
}

/// Handles the situation when the die is cocked and displays a message
fn handle_cocked_die(
    commands: &mut Commands,
    text_query: &Query<Entity, With<RollResultText>>,
    camera_transform: &Transform,
    meshes: &mut ResMut<Assets<Mesh>>,
    materials: &mut ResMut<Assets<StandardMaterial>>,
    external_impulse: &mut ExternalImpulse,
    die: &mut Die,
) {
    if let Ok(entity) = text_query.get_single() {
        commands.entity(entity).despawn();
    }
    let font_data = include_bytes!("../../assets/font/FiraSans-Bold.ttf");
    let text_mesh_bundle = create_text_mesh(
        "HONOR THE COCK", // see Dropout.tv, Fantasy High Junior Year :)
        font_data,
        meshes,
        materials,
        Color::srgba(1.0, 0.0, 0.0, 1.0),
        camera_transform,
    );
    commands.spawn(text_mesh_bundle).insert(RollResultText);
    external_impulse.impulse = Vec3::new(0.0, 0.0, 0.0);
    external_impulse.torque_impulse = Vec3::new(-0.15, -0.05, -0.15);
    die.state = GameState::Rolling;
}

/// Displays the result of the die roll and updates the game state
fn display_roll_result(
    commands: &mut Commands,
    transform: &Transform,
    text_query: &Query<Entity, With<RollResultText>>,
    camera_transform: &Transform,
    meshes: &mut ResMut<Assets<Mesh>>,
    materials: &mut ResMut<Assets<StandardMaterial>>,
    external_force: &mut ExternalForce,
    external_impulse: &mut ExternalImpulse,
    rigid_body: &mut RigidBody,
) {
    let mut max_dot = -1.0;
    let mut face_up = 0;

    for (i, side) in CUBE_SIDES.iter().enumerate() {
        let dot_product = transform.rotation.mul_vec3(*side).dot(Vec3::Y);
        if dot_product > max_dot {
            max_dot = dot_product;
            face_up = i + 1;
        }
    }

    if let Ok(entity) = text_query.get_single() {
        commands.entity(entity).despawn();
    }

    let font_data = include_bytes!("../../assets/font/FiraSans-Bold.ttf");
    let text_mesh_bundle = create_text_mesh(
        &format!("Roll result: {}", face_up),
        font_data,
        meshes,
        materials,
        Color::srgba(1.0, 0.0, 0.0, 1.0),
        camera_transform,
    );
    commands.spawn(text_mesh_bundle).insert(RollResultText);
    external_force.force = Vec3::ZERO;
    external_force.torque = Vec3::ZERO;
    external_impulse.impulse = Vec3::ZERO;
    external_impulse.torque_impulse = Vec3::ZERO;
    *rigid_body = RigidBody::Dynamic;
}

/// Creates a text mesh to display in the game world
fn create_text_mesh(
    text: &str,
    font_data: &'static [u8],
    meshes: &mut ResMut<Assets<Mesh>>,
    materials: &mut ResMut<Assets<StandardMaterial>>,
    color: Color,
    camera_transform: &Transform,
) -> PbrBundle {
    let mut generator = MeshGenerator::new(font_data);
    let transform = Mat4::from_scale(Vec3::new(0.1, 0.1, 0.1)).to_cols_array();
    let text_mesh: MeshText = generator
        .generate_section(text, false, Some(&transform))
        .expect("Failed to generate text mesh");

    let positions: Vec<[f32; 3]> = text_mesh.vertices.chunks(3).map(|c| [c[0], c[1], 0.5]).collect();
    let uvs = vec![[0.0, 0.0]; positions.len()];

    let mut mesh = Mesh::new(PrimitiveTopology::TriangleList, RenderAssetUsages::default());
    mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions);
    mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, uvs);
    mesh.compute_flat_normals();

    let text_position = Vec3::new(-0.2, 9.0, 0.1);
    let text_rotation = Quat::from_rotation_x(std::f32::consts::PI) * Quat::from_rotation_x(std::f32::consts::PI);

    PbrBundle {
        mesh: meshes.add(mesh),
        material: materials.add(color),
        transform: Transform {
            translation: text_position,
            rotation: camera_transform.rotation * text_rotation,
            scale: Vec3::splat(0.5),
            ..Default::default()
        },
        ..Default::default()
    }
}

Run the dice_roller

cargo run

# or without egui inspector
cargo run --release

You can roll the die by pressing the space key.

Creating your own dice in Blender

  1. Follow the steps in Make a custom Die in Blender in 60s.
  2. Export your project as .glb/.gltf (e.g. your_dice.glb).
  3. Add your .glb file to your assets directory (e.g. assets/your_dice.glb).
  4. Update references from models/dice/scene.gltf#Scene0 to your_dice.glb#Scene0.
  5. You may need to reorder CUBE_SIDES in your lib.rs code if the sides map to different locations. You’ll know if you need to because the roll result will say the wrong value.

With more time

  • The detection for a cocked die could use some work. It’s not 100% accurate and I’m still working to solve for unique situations
  • The die roller could support unique die types, like d4, d12, d20, as well as accept a configuration to let the caller modify parameters
  • I’d use my own custom die
  • I’d figure out how best to do lighting so my custom die looks better when run in bevy
  • This would be published to crates.io