Codementor Events

Rust RPG: Introductory Tutorial of Rust and Unit Testing with a Roguelike

Published Sep 20, 2018Last updated Mar 18, 2019
Rust RPG: Introductory Tutorial of Rust and Unit Testing with a Roguelike

Let's talk about Rust. It is a statically-typed and strongly-typed programming language that can be used to build low-level programs, 3d video games, 2d video games, system utilities, and even web application servers.

Rust is an extremely useful and productive language. Since it is a "C-Like" language (or rather belongs to the C-family in programming history), it will be familiar to those who know C++, C, C#, JavaScript or Java.

Many programmers prefer Rust for its greasy-fast speed, flexible type strictness, optimized reference management, and memory safety-net. It also makes all variables immutable (read-only) by default, subtly forcing you to design with functional programming and composing many sub-functions into larger programs.

Rust Roguelike

We're going to walk through a Rust application that I've built, which is essentially a basic Roguelike in most regards.

A Roguelike is:

... a subgenre of role-playing video game characterized by a dungeon crawl through procedurally generated levels, turn-based gameplay, tile-based graphics, and permanent death of the player character
- via Wikipedia

In our Rust application, I've made the beginnings of a roguelike: you can choose from five classes - each with their own types of attributes. Next, you can choose to attack the enemy or dodge the enemy's attack. As we break down the program, we will unravel the basic rules I've created for this game.

Tutorial

castle.jpg

Build and Meta Files

Let's look at the code - here is the link to the source code repo.

In the source code, we have some interesting items of note:

# rust_roguelike/
šŸ—€ .circle/
šŸ—€ src/
šŸ—Ž .editorconfig
šŸ—Ž Cargo.toml

Cargo.toml

The Cargo.toml holds the Cargo config that tells Rust and Cargo all about our application.

[package]
name = "rust_roguelike"
description = "Rust RPG Roguelike"
version = "0.1.0"
authors = ["Cameron Manavian <contact@etalx.com>"]

[dependencies]
rand = "0.3.14"
text_io = "0.1.7"

.editorconfig

Next, I'd love to point out a file that everyone should be using - an EditorConfig, which is a universal file to tell just about any editor how we want to format and write our Rust code, allowing for consistent coding styles between different editors and IDEs. You'll also see the configurations for YAML and TOML files:

root = true

[*]
indent_style = space
indent_size = 4
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true

[*.toml]
indent_style = space
indent_size = 4

[{*.json, *.svg}]
indent_style = space
indent_size = 4

[{*.yml,*.scss,*.css,*rc}]
indent_style = space
indent_size = 2

[*.rs]
indent_style = space
indent_size = 4

Of course, Rust already has a linter and formatter built in and easily accessible, so .editorconfig is just there as a backup.

.circleci/config.yml

Finally, we have a config folder for CircleCI. CircleCI is my continuous integration service of choice.

Inside this folder, the main requirement is a config.yml, but you can also store shell scripts in here to do more complex builds steps.

Currently I have a basic config:

version: 2
jobs:
  build:
    docker:
      - image: rust:1.29.0-slim
    environment:
      TZ: "/usr/share/zoneinfo/Etc/UTC"
    steps:
      - checkout
      - run: cargo build
      - run: cargo test

To start with, I tell CircleCI to use a Docker image that has Rust installed (rust:1.29.0-slim) and then I have some commands to run for my integration tests.

First we checkout git using the commit that triggered this build. Next, we run cargo build to build all the dependencies by downloading and installing based on the settings in the Cargo.toml. Finally, we run cargo test which will locate all unit tests inside the project and run them. Later on, we could add a code coverage step here as a final check to verify that all of the code is tested.

Source Code

Now let's look at the architecture of our app, and once again here is the link to the source code repository on GitHub.

We have three Rust files (*.rs) which are

# rust_roguelike/src/
šŸ—Ž character.rs
šŸ—Ž computer.rs
šŸ—Ž main.rs

As per Rust convention, main.rs is our entry point into the application and has a function inside also named main. We'll come back to this file later.

character.rs

The character module is the central place for the user's character actions, health, and other stats.

We have our Character struct (structure), comprised of some String fields for the name and RPG class, and a handful of i32 (integers) for health and stats. I went with the classic C struct style:

pub struct Character {
    pub name: String,
    pub class: String,
    pub health: i32,
    attack: i32,
    dodge: i32,
    luck: i32,
    xp: i32,
}

In Rust, think of a struct as a way to have standardized data. But how do we work with that data? We use a trait!

You'll also see the Player trait in the file, which is essentially equivalent to interfaces.

pub trait Player {
    fn new(
        name: String,
        class_name: String,
        health: i32,
        attack: i32,
        dodge: i32,
        luck: i32,
    ) -> Character;

    fn select(&self, player_name: String, player_luck: i32) -> Self;

    fn damage(&mut self, damage_amount: i32);

    fn heal(&mut self, heal_amount: i32);

    fn attack(&self) -> i32;

    fn dodge(&self) -> i32;

    fn info(&self) -> String;

    fn stats(&self) -> String;
}

I love traits because they allow programmers to have zero-cost abstractions - traits are a huge feature in Rust.

All told, the trait system is the secret sauce that gives Rust the ergonomic, expressive feel of high-level languages while retaining low-level control over code execution and data representation.
- via The Rust Programming Language Blog

Next, we have our implementation, which is a way to define methods on an object such as a struct and can even implement according to the specifications of a trait.

You'll see in the declaration that I denote that I am "implementing" the Player trait for the Character struct. By doing so, I am now required to implement all of the functions required by the Player trait. Take a look at the full implementation:

impl Player for Character {
    fn new(
        name: String,
        class_name: String,
        health: i32,
        attack: i32,
        dodge: i32,
        luck: i32,
    ) -> Character {
        Character {
            name: name.to_string(),
            class: class_name.to_string(),
            health: health,
            attack: attack,
            dodge: dodge,
            luck: luck,
            xp: 0,
        }
    }

    fn select(&self, player_name: String, player_luck: i32) -> Self {
        Self::new(
            player_name,
            self.class.to_string(),
            self.health,
            self.attack,
            self.dodge,
            self.luck + player_luck,
        )
    }

    fn damage(&mut self, damage_amount: i32) {
        self.health -= damage_amount;
        self.xp += 2;
    }

    fn heal(&mut self, heal_amount: i32) {
        self.health += heal_amount;
        self.xp += 1;
    }

    fn attack(&self) -> i32 {
        self.xp + self.attack + self.luck / 2
    }

    fn dodge(&self) -> i32 {
        self.xp + self.dodge + self.luck / 2
    }

    fn info(&self) -> String {
        format!(
            "{} \thp: {} attack: {} dodge: {} luck: {}",
            self.class, self.health, self.attack, self.dodge, self.luck
        )
    }

    fn stats(&self) -> String {
        format!(
            "{} - hp: {} attack: {} dodge: {} luck: {} experience: {}",
            self.class, self.health, self.attack, self.dodge, self.luck, self.xp
        )
    }
}

The new method is essentially a constructor. Of greater interest is the select method, which allows us to clone an instance of a Character and return it back, while also allowing name and luck customization.

The methods heal and damage are nearly identical, where one reduces the Character's health and the other increases it, and both increment the total experience. Note that we must use &mut self to require a mutable version of the instance to be used in order to properly make the health and XP adjustments.

Likewise, you'll see that the attack and dodge methods return an i32 value based on the Character's stats, and use a normal &self instance reference since they don't mutate the Character.

Finally, we have a couple of String formatting helpers, info and stats. The method info is more useful when listing out the base Character class information, which we'll see in use inside main.rs. The stats method is as a way to get relevant stats for the user to see mostly for when they start the game and when they die.

Lastly, at the bottom of the module file are my unit tests. We are required to wrap unit tests in a module closure in order for proper syntax:

#[cfg(test)]
mod tests {
    use super::*;
    
    // unit tests here
}

Let's take a close look at one of the tests, the specification for attack:

    #[test]
    fn test_attack() {
        // arrange
        const EXPECTED_ATTACK: i32 = 6;
        let player = Character::new("".to_string(), "Rogue".to_string(), 1, 4, 1, 4);

        // act
        let result = player.attack();

        // assert
        assert_eq!(result, EXPECTED_ATTACK);
    }

When I write unit tests, I follow the style of Arrange/Act/Assert pattern, the benefits of which are best be summed up by:

  • Clearly separates what is being tested from the setup and verification steps.
  • Clarifies and focuses attention on a historically successful and generally necessary set of test steps.

Makes some TestSmells more obvious:

  • Assertions intermixed with "Act" code.
  • Test methods that try to test too many different things at once.
    -via Arrange Act Assert

I like to insert comments to create a sort of template for later maintainers to follow too.

Anyways, our test expects an attack power of 6 (EXPECTED_ATTACK), which we can figure out by looking at the source code and applying the values passed to the constructor into the equation from the method, like so:

// we constructed a player with 4 attack and 4 luck:
let player = Character::new("".to_string(), "Rogue".to_string(), 1, 4, 1, 4);

// our method code
fn attack(&self) -> i32 {
    self.xp + self.attack + self.luck / 2
}

// simplify -> plug in values -> PEMDAs FTW
xp + attack + luck / 2
   = 0 + 4 + 4 / 2
   = 4 + 2
   = 6

Following the Arrange/Act/Assert pattern, we can easily set up a roadmap and then assert that the result of the method matched our expected result:

// assert
assert_eq!(result, EXPECTED_ATTACK);

computer.rs

Our Computer module is built in a similar fashion to the Character module, utilizing a struct, a trait, and an impl.

pub struct Computer {
    level: i32,
    difficulty: i32,
}

pub trait Enemy {
    fn new(level: i32, difficulty: i32) -> Self;

    fn action(&self) -> (i32, i32);

    fn level_up(&mut self);

    fn stats(&self) -> String;
}

impl Enemy for Computer {
    fn new(level: i32, difficulty: i32) -> Computer {
        Computer {
            level: level,
            difficulty: difficulty,
        }
    }

    fn action(&self) -> (i32, i32) {
        (self.level, self.difficulty)
    }

    fn level_up(&mut self) {
        self.level += 1;
        self.difficulty += 3;
    }

    fn stats(&self) -> String {
        format!("level: {} difficulty: {}", self.level, self.difficulty)
    }
}

The code is pretty straightforward if you understand the Character module, as it has similar functions.

The method action inside the Computer impl is unique though, as we utilize a Tuple of two integers ((i32, i32)) as our return type:

fn action(&self) -> (i32, i32) {
    (self.level, self.difficulty)
}

Using a Tuple here allows us to return two values and enable the main.rs file to create a range of action power for the computer, that lets the computer grow with the player as they advanced throughout the game. The lower bound of the range is the computer's level, and the upper bound is the difficulty level. You'll note that the difficulty jumps up by 3 points each round, making the game potentially much harder as the game progresses (as this is the upper bound).

main.rs

Now let's cruise on over to the main file - our entry point to the app. It has two functions, and a bunch of importing statements at the top.

We load external crates from our dependencies, load our project's modules, and state what types we intend to use.

fn main

The main function starts with a little banner, which reads from our toml file and shows our package version and description.

println!(
    "=== Welcome to RRL {} the {}! ====\n",
    player.name, player.class
);

We instantiate a group of characters for the game: a Cleric, Warrior, Hunter, Wizard, and Thief using 25 total attribute points distributed based on archetype (feel free to comment on or criticize my allocation). We also generate a bit of random luck for the user's session:

let characters: [character::Character; 5] = [
    character::Character::new("".to_string(), "Cleric".to_string(), 7, 5, 6, 7),
    character::Character::new("".to_string(), "Warrior".to_string(), 10, 5, 5, 5),
    character::Character::new("".to_string(), "Hunter".to_string(), 5, 7, 7, 6),
    character::Character::new("".to_string(), "Wizard".to_string(), 3, 10, 5, 7),
    character::Character::new("".to_string(), "Thief".to_string(), 4, 5, 6, 10),
];

let _luck_amount = rand::thread_rng().gen_range(2, 6);

Next, we ask the user for their name and for them to select one of our character classes. Once the user has selected a class, we use our Character's select helper method and pass the player's name and their generated luck amount as parameters:

let mut player =
        characters[character_index - 1].select(_character_name.to_string(), _luck_amount);

This clones out the selected RPG archetype into a new mutable version, leaving the sample instances intact. I did this because we could come back later to this project and enhance the game with a "new game" feature, which is why we don't want to corrupt the original Character instances.

fn play

The play function runs our game loop - typically, when you make a video game, a loop is used to wait for some "game over" indicator, often when the player's health is equal or less than zero.

Before the loop we also create a new Computer enemy:

let mut enemy = computer::Computer::new(1, 16);

Inside the loop we retrieve the computer's Tuple range for the action, and use it with the imported rand crate:

let _action = enemy.action();
let _cpu_action = rand::thread_rng().gen_range(_action.0, _action.1);

Be aware that the random range is inclusive on the lower bound but exclusive on the upper bound, so a level 1 computer with 16 difficulty will request a number between 1 and 15.

Then we ask the player what they want to do: they can either attack or dodge, and depending on their choice we retrieve their attack or dodge power from their Character and compare it to the Computer action power.

With that, we have enough to determine a win or loss for the round, which triggers a heal or damage respectively.

And that's it. That's the entire game.

Final Thoughts

What we have here is a unit tested game built in Rust.

We haven't tested the main class as I am still looking for ways to test standard in / standard out based code, but we have a functional albeit limited roguelike built with Rust that you can play in the terminal.

kevin-horstmann-345328-unsplash.jpg
Some eventual features that we could add (pull requests welcome!):

  • A new game option, which could just take you back to the character selection
  • More classes to choose from
  • Multiple enemy types
  • Boss enemies every 5-10 rounds or so
  • Graphics, maybe even just Emoji or ASCII-art

Thanks for your time.

Discover and read more posts from Cameron Manavian
get started
post commentsBe the first to share your opinion
Show more replies