44
diceroller, a sample Rust project
For me, the best learning process is regularly switching between learning and doing, theory and practice. The last post was research; hence, this one will be coding.
I've been a player of Role-Playing Games since I'm 11. Of course, I've played Dungeons & Dragons (mainly the so-called Advanced Edition), but after a few years, I've taken upon Champions and its HERO system. The system is based on points allotment and allows virtually everything regarding a character's abilities. For a brief description of the system, please check this brilliant albeit brilliant Stack Exchange answer. I've developed a sample application to handle (a part of) the damage generation subsystem for this post.
In RPG, some actions may either succeed or fail, e.g. climbing a cliff or hitting an enemy: the success depends on rolling dice. The HERO system is no different.
For that reason, our first task should be the modeling of rolling a dice. In RPGs, dice are not limited to being 6-sided.

struct Die {
faces: u8,
}
Now that we have defined a dice, we need to be able to roll it: it entails randomness. Let's add the relevant crate to our build:
[dependencies]
rand = "0.8.4"
The crate offers several PNRGs. We are not developing a lottery application; the default is good enough.
impl Die {
pub fn roll(self) -> u8 {
let mut rng = rand::thread_rng(); // 1
rng.gen_range(1..=self.faces) // 2
}
}
At this point, it's possible to create a dice:
let d6 = Die { faces: 6 };
For better developer experience, we should create utility functions to create dice:
impl Die {
pub fn new(faces: u8) -> Die {
Die { faces }
}
pub fn d2() -> Die {
Self::new(2)
}
pub fn d4() -> Die {
Self::new(4)
}
pub fn d6() -> Die {
Self::new(6)
}
// Many more functions for other dice
}
The above code is clearly not DRY. All
dN
functions look the same. It would be helpful to create a macro that parameterizes N
so we could write a single function, and the compiler would generate the different implementations for us:macro_rules! gen_dice_fn_for {
( $( $x:expr ),* ) => {
$(
#[allow(dead_code)] // 1
pub fn d$x() -> Die { // 2
Self::new($x) // 3
}
)*
};
}
impl Die {
pub fn new(faces: u8) -> Die {
Die { faces }
}
gen_dice_fn_for![2, 4, 6, 8, 10, 12, 20, 30, 100]; // 4
}
But the code doesn't compile:
error: expected one of `(` or `<`, found `2`
--> src/droller/dice.rs:9:21
|
9 | pub fn d$x() -> Die {
| ^^ expected one of `(` or `<`
...
21 | gen_dice_fn_for![2, 4, 6, 8, 10, 12, 20, 30, 100];
| -------------------------------------------------- in this macro invocation
|
= note: this error originates in a macro (in Nightly builds, run with -Z macro-backtrace for more info)
Rust macros don't allow to parameterize the function's name, only its body.
After some research, I found the
After some research, I found the
paste
crate:This crate provides a flexible way to paste together identifiers in a macro, including using pasted identifiers to define new items.
-- crates.io
Let's add the crate to our project:
[dependencies]
paste = "1.0.5"
Then use it:
macro_rules! gen_dice_fn_for {
( $( $x:expr ),* ) => {
paste! { // 1
$(
#[allow(dead_code)]
pub fn [<d$x>]() -> Die { // 2
Self::new($x)
}
)*
}
};
}
paste
directivex
We now have plenty of different dice to use. Yet, in the HERO System, the only die is the standard d6. In some cases, you'd roll half a d6, i.e., a d3, but this is rare.
It is a good case for the
Default
trait. Rust defines it as:pub trait Default: Sized {
/// Returns the "default value" for a type.
///
/// Default values are often some kind of initial value, identity value, or anything else that
/// may make sense as a default.
#[stable(feature = "rust1", since = "1.0.0")]
fn default() -> Self;
}
It makes sense to implement
Default
for Die
and return a 6-sided die.impl Default for Die {
fn default() -> Self {
Die::d6()
}
}
We can now call
Die::default()
to get a d6.Using a
u8
prevents having invalid a negative number of faces. But a dice should have at least one side. Hence, we could benefit from adding a non-zero check when creating a new Die
.The most straightforward way is to add an
if
check at the start of the new()
and dN()
functions. But I did a bit of research and stumbled upon the non-zero integer types. We can rewrite our Die
implementation accordingly:impl Die {
pub fn new(faces: u8) -> Die {
let faces = NonZeroU8::new(faces) // 1
.unwrap() // 2
.get(); // 3
Die { faces }
}
}
u8
into a non-zero typeOption
u8
value if it's strictly positive or panic
otherwiseWhen I wrote the code, I thought it was a good idea. As I'm writing the blog post, I think this is a good sample of overengineering.
The idea is to fail fast. Otherwise, we would need to cope with the
Option
type throughout the application. if faces == 0 { panic!("Value must be strictly positive {}", faces); }
would be much simpler and achieve the same. KISS.RPGs imply fights, and fights mean dealing damage to your opponents. The HERO system is no different. It models two properties of a character: its ability to stay conscious and stay alive, respectively the
STUN
and BODY
characteristics.The damage itself can be of two different types: blunt trauma, i.e.
NormalDamage
, and KillingDamage
. Let's focus on the former type first.For each normal damage die, the rules are simple:
STUN
damage is the rollBODY
depends on the roll: 0
for 1
, 2
for 6
, and 1
in all other cases.We can implement it as the following:
pub struct Damage {
pub stun: u8,
pub body: u8,
}
pub struct NormalDamageDice {
number: u8,
}
impl NormalDamageDice {
pub fn new(number: u8) -> NormalDamageDice {
let number = NonZeroU8::new(number).unwrap().get();
NormalDamageDice { number }
}
pub fn roll(self) -> Damage {
let mut stun = 0;
let mut body = 0;
for _ in 0..self.number {
let die = Die::default();
let roll = die.roll();
stun += roll;
if roll == 1 {
} else if roll == 6 {
body += 2
} else {
body += 1
}
}
Damage { stun, body }
}
}
While it works, it involves mutability. Let's rewrite a functional version:
impl NormalDamageDice {
pub fn roll(self) -> Damage {
(0..self.number) // 1
.map(|_| Die::default()) // 2
.map(|die| die.roll()) // 3
.map(|stun| {
let body = match stun { // 4
1 => 0,
6 => 2,
_ => 1,
};
Damage { stun, body } // 5
})
.sum() // 6
}
}
Damage
with the STUN
and BODY
The above code doesn't compile:
error[E0277]: the trait bound `NormalDamage: Sum` is not satisfied
--> src/droller/damage.rs:89:14
|
89 | .sum::<NormalDamage>();
| ^^^ the trait `Sum` is not implemented for `NormalDamage`
Rust doesn't know how to add two
Damage
together! It's as simple as adding their STUN
and BODY
. To fix the compilation error, we need to implement the Sum
trait for NormalDamage
.impl Sum for NormalDamage {
fn sum<I: Iterator<Item = Self>>(iter: I) - Self {
iter.fold(NormalDamage::zero(), |dmg1, dmg2| NormalDamage {
stun: dmg1.stun + dmg2.stun,
body: dmg1.body + dmg2.body,
})
}
}
So far, to print a
Damage
, we need to its stun
and body
properties:let one_die = NormalDamageDice::new(1);
let damage = one_die.roll();
println!("stun: {}, body: {}", damage.stun, damage.body);
Printing
Damage
is a pretty standard use case. We want to write the following:let one_die = NormalDamageDice::new(1);
let damage = one_die.roll();
println!("damage: {}", damage);
For that, we need to implement
Display
for Damage
:impl Display for Damage {
fn fmt(&self, f: &mut Formatter<'_>) - std::fmt::Result {
write!(f, "stun: {}, body: {}", self.stun, self.body)
}
}
I believe doing that for most of your
struct
is a good practice.The next step is to implement
KillingDamageDice
. The computation is different than for normal damage. For each die, we roll the BODY
. Then we roll for a multiplier. The STUN
is the BODY
times mult
. Our current code rolls mult
, but we don't store it in the Damage
structure. To do that, we need to introduce a KillingDamage
structure:pub struct KillingDamage {
pub body: u8,
pub mult: u8,
}
But with this approach, we cannot get the
STUN
amount. Hence, the next step is to make Damage
a trait.pub trait Damage {
fn stun(self) -> u8;
fn body(self) -> u8;
}
impl Damage for NormalDamage {
fn stun(self) -> u8 {
self.stun
}
fn body(self) -> u8 {
self.body
}
}
impl Damage for KillingDamage {
fn stun(self) -> u8 {
self.body * self.mult
}
fn body(self) -> u8 {
self.body
}
}
At this point, the code doesn't compile anymore as Rust functions cannot return a trait.
error[E0277]: the size for values of type `(dyn Damage + 'static)` cannot be known at compilation time
--> src/droller/damage.rs:86:26
|
86 | pub fn roll(self) -> Damage {
| ^^^^^^ doesn't have a size known at compile-time
|
= help: the trait `Sized` is not implemented for `(dyn Damage + 'static)`
= note: the return type of a function must have a statically known size
The fix is straightforward with the
Box
type.Boxes don’t have performance overhead, other than storing their data on the heap instead of on the stack. But they don’t have many extra capabilities either. You’ll use them most often in these situations:
- When you have a type whose size can’t be known at compile time and you want to use a value of that type in a context that requires an exact size
Let's wrap the return value in a
Box
to correct the compilation error.pub fn roll(self) -> Box<dyn Damage> {
// let damage = ...
Box::new(damage)
}
It now compiles successfully.
With
Damage
being a trait, we need to change the println!()
part of the application:let normal_die = NormalDamageDice::new(1);
let normal_dmg = normal_die.roll();
println!("normal damage: {}", normal_dmg);
let killing_die = KillingDamageDice::new(1);
let killing_dmg = killing_die.roll();
println!("killing damage: {}", killing_dmg);
But this snippet doesn't compile:
error[E0277]: `dyn Damage` doesn't implement `std::fmt::Display`
--> src/main.rs:8:35
|
8 | println!("normal damage: {}", normal_dmg);
| ^^^^^^^^^^ `dyn Damage` cannot be formatted with the default formatter
|
= help: the trait `std::fmt::Display` is not implemented for `dyn Damage`
= note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead
= note: required because of the requirements on the impl of `std::fmt::Display` for `Box<dyn Damage>`
= note: required by `std::fmt::Display::fmt`
= note: this error originates in a macro (in Nightly builds, run with -Z macro-backtrace for more info)
To fix that, we need to make
Damage
a "subtrait" of Display
.pub trait Damage: Display {
fn stun(self) -> u8;
fn body(self) -> u8;
}
Finally, we need to implement
Display
for NormalDamage
and KillingDamage
.In this post, I wrote about my steps in implementing damage rolling for the HERO System and the most exciting bits on Rust. The project doesn't stop there yet. I may continue to develop it to deepen my understanding further as it makes for an excellent use case.
As a side note, you might have noticed that I didn't write any test. It's not an oversight. The reason for that is because randomness makes most low-level tests useless. On a meta-level, and despite widespread beliefs, it means one can design in increments without TDD.
The complete source code for this post can be found on Github:
To go further:
Originally published at A Java Geek on July 25th, 2021
44