Physics-driven dice for Bevy 0.18. Parse a dice expression, drop it into an arena, watch the dice tumble and settle, read the result.
- Math-only roll API for headless use (
DiceRoll::parse,roll,roll_detailed) with no Bevy dependency on the caller's side beyond the crate import. - Physics layer built on
avian3d: per-arena box collider, throw arc, settle detection, and a face-orientation table that turns each settled die's rotation back into the numeral it shows. - Standard polyhedral set: d4, d6, d8, d10, d12, d20, d100 (rendered as a d100 tens + d10 ones pair).
[dependencies]
bevy = "0.18"
bevy_dice = { version = "0.2", features = ["plain_white"] }The diceset features ship a .glb baked into the binary so you do not have
to manage assets yourself:
plain_white- white plastic, black labelshalloween- orange and blackmetal- brushed metalclear_orange- translucent orange
Two larger textured sets (frosty, fiery) are not feature-gated; copy
them out of this repo's assets/ directory and load them through the
asset server like any other gltf.
I also have a process to generate custom dice, feel free to contact me with a request.
Diceset- a gltf asset path plus its face-orientation table. Cached mesh + material handles populate automatically once the asset loads. Spawn one per distinct diceset; many arenas can share one.DiceArena- a playing-area box. Holds anEntityreference to itsDiceset(use.diceset(entity)).DefaultArena- marker component. Tag exactly one arena to make it the target ofroller.roll(...)/roller.roll_expr("...").SpawnedDie- marker on every die entity, plus the arena it belongs to.
- Add
DicePlugin::default()to yourApp(afterDefaultPlugins). - Enable one diceset feature so a default arena (and its
Dicesetentity) auto-spawn at startup. Or spawn them yourself (see "Custom arena"). - Trigger rolls with
roller.roll_expr("3d6+2")(aDiceRollersystem param); read results fromMessageReader<RollComplete>.
No camera setup is required out of the box - dice render on layer 0, the
same layer as everything else in your scene.
See examples/simple_d20.rs, use cargo run --example simple_d20 --features plain_white to run it.
3d6 three six-siders
d20+5 one d20 plus a flat 5
2d6+1d4-1 compound expression with adjustment
1d20-1d4 negated terms
Whitespace is ignored, d and D both work. Supported sides: 4, 6, 8, 10,
12, 20, 100. See [DiceRoll::parse] for the full grammar. A leading + or
- is rejected; write 3d6, not +3d6.
See dice_expressions, use cargo run --example dice_expressions --features plain_white to run it.
use bevy::prelude::*;
use bevy_dice::ui::RollComplete;
fn report(mut completed: MessageReader<RollComplete>) {
for event in completed.read() {
// Pretty-print "3d6(4,2,1) + 2 = 9"
info!("{}", event.outcome.display(&event.roll));
// Or read the raw fields:
info!("rolled {}", event.outcome.total);
for die in &event.outcome.dice {
info!(" d{}: {}", die.kind.sides(), die.value);
}
}
}Theres an example of this in dice_expressions,
use cargo run --example dice_expressions --features plain_white to run it.
use bevy_dice::ui::{DefaultArena, DiceArena, Diceset};
fn setup(mut commands: Commands) {
let diceset = commands.spawn(Diceset::embedded("halloween")).id();
commands.spawn((
DiceArena::default()
.name("table")
.center(0.0, 0.0, 0.0)
.size(12.0, 5.0, 6.0)
.diceset(diceset),
DefaultArena,
));
}Diceset::embedded(slug) resolves an enabled embedded feature's gltf bytes.
For a custom diceset shipped in your assets/ directory, use
Diceset::custom("my_diceset") (it appends .glb/.gltf and reads
orientations from assets/{slug}). If orientations live elsewhere, use
Diceset::custom_with(path, orientations).
Spawn multiple DiceArena entities to run separate rolling regions in the
same world. They can share a single Diceset (one mesh load, one
orientation table, one spawn). Tag at most one with DefaultArena; use
DiceRoller::roll_in(arena_entity, roll) to target a specific one.
By default, every dice entity and its overhead light spawn on render layer
0, so any camera that renders your scene sees them with no extra setup.
You can override the layer per arena (or globally on the plugin) to isolate the dice from the rest of your scene. This is useful when:
- Your game already has its own lights on layer 0 that double-light the dice.
- You want a HUD-style camera that only renders dice.
- Split-screen: each player's arena draws on a different layer.
To isolate, pick a non-zero layer, set it on the arena, and make sure your camera includes both the scene layer and the dice layer:
use bevy::camera::visibility::RenderLayers;
use bevy::prelude::*;
use bevy_dice::ui::{DefaultArena, DiceArena, DicePlugin, Diceset};
const DICE_LAYER: u8 = 1;
App::new()
.add_plugins((
DefaultPlugins,
// Don't auto-spawn the default arena; we want to pin it to DICE_LAYER.
DicePlugin { spawn_default_arena: false, ..default() },
))
.add_systems(Startup, |mut commands: Commands| {
commands.spawn((
Camera3d::default(),
Transform::from_xyz(0.0, 8.0, 8.0).looking_at(Vec3::ZERO, Vec3::Y),
RenderLayers::from_layers(&[0, DICE_LAYER as usize]),
));
let diceset = commands.spawn(Diceset::embedded("plain_white")).id();
commands.spawn((
DiceArena::default().diceset(diceset).render_layer(DICE_LAYER),
DefaultArena,
));
});DicePlugin::render_layer sets the default for arenas that don't override
it. DiceArena::render_layer(N) overrides per arena. The plugin spawns one
overhead DirectionalLight per arena, on the arena's resolved layer.
See examples/isolated_layer.rs, use cargo run --example isolated_layer --features plain_white to run it.
use bevy_dice::dice::{DiceRoll, Options};
let roll = DiceRoll::parse("4d6").unwrap()
.with_options(Options::default().with_keep_highest(3));keep_highest(n)/keep_lowest(n)- drop dice after rolling.reroll_at_or_below(threshold)- replace low rolls until above the threshold (capped byMAX_OPTION_ITERATIONSper term).explode_at_or_above(threshold)- rolls hitting the threshold spawn an extra die (same cap).
DiceRoll::with_explode() and with_reroll_ones() are shorthands.
DiceRoll::with_advantage(modifier) / with_disadvantage(modifier) build a
standard 5e 2d20kh1 / 2d20kl1 roll plus the modifier.
In the physics path, reroll and explode are re-simulated: a die that lands in the trigger range is despawned and a fresh one is thrown, until it clears or the budget runs out.
Physics systems pull from a DiceRng resource (a boxed RngCore). Default
wraps StdRng::from_entropy(). For deterministic playback in tests, insert
a seeded one:
use bevy_dice::ui::DiceRng;
app.insert_resource(DiceRng::from_seed(0xD1CE));If you only need the numbers, skip DicePlugin entirely:
use bevy_dice::dice::DiceRoll;
let total = DiceRoll::parse("3d6+2").unwrap().roll(&mut rand::thread_rng());roll_detailed additionally returns each individual die value.
All public components and messages derive Reflect and are registered with
the type registry, so they show up in bevy-inspector-egui and similar
tools.
Everything here is available under an MIT license. See LICENSE.txt.