Building a Bevy Plugin for Rolling Dice
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.
Resources
I learned a lot from these resources, from the programming logic in Bevy to modeling different dice shapes in Blender.
- Inspiration - bevy_dice
- Make a custom Die in Blender in 60s
- Make D4, D6, D8, D10, D12, D20 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.
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
- Follow the steps in Make a custom Die in Blender in 60s.
- Export your project as .glb/.gltf (e.g. your_dice.glb).
- Add your .glb file to your assets directory (e.g. assets/your_dice.glb).
- Update references from
models/dice/scene.gltf#Scene0
toyour_dice.glb#Scene0
. - 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