DEV Community

Cover image for diceroller, a sample Rust project
Nicolas Frankel
Nicolas Frankel

Posted on • Originally published at blog.frankel.ch

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.

Rolling dice

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,
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode
  1. Retrieve the lazily-initialized thread-local PRNG
  2. 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 };
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

DRY with macros

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
}
Enter fullscreen mode Exit fullscreen mode
  1. Don't warn if it's not used, it's expected
  2. Parameterize function name
  3. Parameterize function body
  4. 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)
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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)
            }
            )*
        }
    };
}
Enter fullscreen mode Exit fullscreen mode
  1. Open the paste directive
  2. Generate a function name using x

Default die

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;
}
Enter fullscreen mode Exit fullscreen mode

It makes sense to implement Default for Die and return a 6-sided die.

impl Default for Die {
    fn default() -> Self {
        Die::d6()
    }
}
Enter fullscreen mode Exit fullscreen mode

We can now call Die::default() to get a d6.

Non-zero checks

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 }
    }
}
Enter fullscreen mode Exit fullscreen mode
  1. Wrap the u8 into a non-zero type
  2. Unwrap it into an Option
  3. Get the underlying u8 value if it's strictly positive or panic 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.

Rolling for damage

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 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 }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode
  1. For each damage die
  2. Create a d6
  3. Roll it
  4. Implement the business rule
  5. Create the Damage with the STUN and BODY
  6. 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`
Enter fullscreen mode Exit fullscreen mode

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,
        })
    }
}
Enter fullscreen mode Exit fullscreen mode

Printing Damage

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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

I believe doing that for most of your struct is a good practice.

Making Damage a trait

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,
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

-- Using Box to Point to Data on the Heap

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)
}
Enter fullscreen mode Exit fullscreen mode

It now compiles successfully.

Display for traits

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);
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

To fix that, we need to make Damage a "subtrait" of Display.

pub trait Damage: Display {
    fn stun(self) -> u8;
    fn body(self) -> u8;
}
Enter fullscreen mode Exit fullscreen mode

Finally, we need to implement Display for NormalDamage and KillingDamage.

Conclusion

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

Latest comments (3)

Collapse
 
chenge profile image
chenge

Good post!
Rust without TDD.

Collapse
 
yjdoc2 profile image
YJDoc2

Hey, really nice post 👍
I really liked the step by step explanation, and liked the writing style as well, intermixing of code and explanation was well spaced, so it didn't feel too much code or too much text at any point :)
Thanks for the post 😁
PS : I think some of your code example has syntax issue? Eg in return Box or sum impl, a '<' seems to have converted to '//' .

Collapse
 
nfrankel profile image
Nicolas Frankel

Thanks for your feedback. I've fixed the syntax.