30
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
}
}
- Retrieve the lazily-initialized thread-local PRNG
- Return a random number between 1 and the number of faces, both ends inclusive
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
}
- Don't warn if it's not used, it's expected
- Parameterize function name
- Parameterize function body
- Enjoy!
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 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)
}
)*
}
};
}
- Open the
paste
directive - Generate a function name using
x
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 }
}
}
- Wrap the
u8
into a non-zero type - Unwrap it into an
Option
- Get the underlying
u8
value if it's strictly positive orpanic
otherwise
When 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:
- The number of
STUN
damage is the roll - The number of
BODY
depends on the roll:0
for1
,2
for6
, and1
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
}
}
- For each damage die
- Create a d6
- Roll it
- Implement the business rule
- Create the
Damage
with theSTUN
andBODY
- Aggregate it
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
30