Skip to main content

`false` Is Not an Answer.

Build Click Upgrades With Rust Enums and Match

Updated 18 min readMarkdown
Part of seriesRustbound - Build Your First Rust App, One Function at a Time
 `false` Is Not an Answer.

Our player earns gold now. After eight stages, they have ninety-six of it and nothing to spend it on.

That is not a reward. That is a high score.

So today we are going to sell them something: more damage per click, at a price that goes up every time they buy it.

The shopping is the easy part.

The interesting part is what happens when we try to tell the rest of the program whether the purchase worked.

Because the obvious answer, true or false, turns out to be not quite an answer at all.

Bought click upgrade 1! Click damage: 7  Gold left: 2

Our starting point

If you are only reading along, you can skip past this code to "The free upgrade nobody earned."

If you are building along, you need three structs, three impl blocks, nine small functions, and thirteen tests:

struct Player {
    bonus_damage: u32,
    gold: u32,
    xp: u32,
    level: u32,
}

struct Enemy {
    name: String,
    health: u32,
    gold_reward: u32,
    xp_reward: u32,
}

struct Game {
    stage: u32,
    player: Player,
    enemy: Enemy,
}

impl Player {
    fn new() -> Player {
        Player {
            bonus_damage: 2,
            gold: 0,
            xp: 0,
            level: 1,
        }
    }

    fn add_gold(&mut self, amount: u32) {
        self.gold = self.gold.saturating_add(amount);
    }

    fn add_xp(&mut self, amount: u32) {
        self.xp = self.xp.saturating_add(amount);

        while self.xp >= xp_to_next_level(self.level) {
            self.xp -= xp_to_next_level(self.level);
            self.level = self.level.saturating_add(1);
        }
    }

    fn base_damage(&self) -> u32 {
        base_damage_for_level(self.level)
    }
}

impl Enemy {
    fn for_stage(stage: u32) -> Enemy {
        Enemy {
            name: String::from("Training Slime"),
            health: enemy_health_for_stage(stage),
            gold_reward: gold_reward_for_stage(stage),
            xp_reward: xp_reward_for_stage(stage),
        }
    }
}

impl Game {
    fn new() -> Game {
        Game {
            stage: 1,
            player: Player::new(),
            enemy: Enemy::for_stage(1),
        }
    }

    fn click(&mut self) {
        attack(&self.player, &mut self.enemy);

        if is_defeated(self.enemy.health) {
            self.defeat_enemy();
        }
    }

    fn defeat_enemy(&mut self) {
        self.player.add_gold(self.enemy.gold_reward);
        self.player.add_xp(self.enemy.xp_reward);

        self.stage = self.stage.saturating_add(1);
        self.enemy = Enemy::for_stage(self.stage);
    }
}

fn click_damage(base: u32, bonus: u32) -> u32 {
    base.saturating_add(bonus)
}

fn health_after_hit(health: u32, damage: u32) -> u32 {
    health.saturating_sub(damage)
}

fn is_defeated(health: u32) -> bool {
    health == 0
}

fn enemy_health_for_stage(stage: u32) -> u32 {
    stage.saturating_mul(10).saturating_add(10)
}

fn gold_reward_for_stage(stage: u32) -> u32 {
    stage.saturating_mul(2).saturating_add(3)
}

fn xp_reward_for_stage(stage: u32) -> u32 {
    stage.saturating_mul(2).saturating_add(4)
}

fn xp_to_next_level(level: u32) -> u32 {
    level
        .saturating_sub(1)
        .saturating_mul(15)
        .saturating_add(20)
}

fn base_damage_for_level(level: u32) -> u32 {
    level.saturating_add(4)
}

fn attack(player: &Player, enemy: &mut Enemy) {
    let damage = click_damage(player.base_damage(), player.bonus_damage);

    enemy.health = health_after_hit(enemy.health, damage);
}

fn main() {
    let mut game = Game::new();

    while game.stage <= 8 {
        let stage = game.stage;
        let level = game.player.level;

        game.click();

        if game.stage > stage {
            println!(
                "Stage {stage} cleared!  Gold: {}  XP: {}/{}",
                game.player.gold,
                game.player.xp,
                xp_to_next_level(game.player.level)
            );

            if game.player.level > level {
                println!(
                    "Level up! Now level {}. Click damage: {}",
                    game.player.level,
                    click_damage(game.player.base_damage(), game.player.bonus_damage)
                );
            }

            println!(
                "  Next up: {} with {} health.",
                game.enemy.name, game.enemy.health
            );
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn click_damage_adds_base_and_bonus() {
        assert_eq!(click_damage(3, 4), 7);
    }

    #[test]
    fn health_never_drops_below_zero() {
        assert_eq!(health_after_hit(3, 10), 0);
    }

    #[test]
    fn zero_health_is_defeated() {
        assert!(is_defeated(0));
        assert!(!is_defeated(1));
    }

    #[test]
    fn attack_reduces_enemy_health() {
        let player = Player::new();

        let mut enemy = Enemy::for_stage(1);
        enemy.health = 10;

        attack(&player, &mut enemy);

        assert_eq!(enemy.health, 3);
    }

    #[test]
    fn later_stages_have_tougher_enemies() {
        assert_eq!(enemy_health_for_stage(1), 20);
        assert_eq!(enemy_health_for_stage(2), 30);
    }

    #[test]
    fn later_stages_pay_better() {
        assert_eq!(gold_reward_for_stage(1), 5);
        assert_eq!(gold_reward_for_stage(2), 7);
        assert_eq!(xp_reward_for_stage(1), 6);
        assert_eq!(xp_reward_for_stage(2), 8);
    }

    #[test]
    fn levels_need_more_xp_as_they_go() {
        assert_eq!(xp_to_next_level(1), 20);
        assert_eq!(xp_to_next_level(2), 35);
    }

    #[test]
    fn leveling_up_keeps_leftover_xp() {
        let mut player = Player::new();

        player.add_xp(25);

        assert_eq!(player.level, 2);
        assert_eq!(player.xp, 5);
    }

    #[test]
    fn a_big_reward_can_skip_levels() {
        let mut player = Player::new();

        player.add_xp(60);

        assert_eq!(player.level, 3);
        assert_eq!(player.xp, 5);
    }

    #[test]
    fn levels_make_the_player_hit_harder() {
        assert_eq!(base_damage_for_level(1), 5);
        assert_eq!(base_damage_for_level(2), 6);
    }

    #[test]
    fn a_new_game_starts_at_stage_one() {
        let game = Game::new();

        assert_eq!(game.stage, 1);
        assert_eq!(game.enemy.health, 20);
        assert_eq!(game.player.gold, 0);
    }

    #[test]
    fn clicking_damages_the_enemy() {
        let mut game = Game::new();

        game.click();

        assert_eq!(game.enemy.health, 13);
    }

    #[test]
    fn defeating_an_enemy_pays_the_player() {
        let mut game = Game::new();
        game.enemy.health = 1;

        game.click();

        assert_eq!(game.stage, 2);
        assert_eq!(game.enemy.health, 30);
        assert_eq!(game.player.gold, 5);
        assert_eq!(game.player.xp, 6);
    }
}

Player stores what the player has earned: gold, XP, and a level. Defeating an enemy pays gold and XP, and enough XP levels the player up.

Click damage comes from two places. base_damage() works it out from the player's level. bonus_damage is just a number stored in the struct.

Run cargo test and all thirteen should pass.


The free upgrade nobody earned

Look at that Player again.

    bonus_damage: u32,

Every new player starts with 2 of it. For free. Since the first time we gave the player a second damage number.

base_damage is already a method, not a field. It is worked out from the player's level, because it is a consequence of that level, not a fact to store.

bonus_damage has the same problem, with a different cause.

Bonus damage should be a consequence of how many upgrades the player has bought.

So the player does not need to store the bonus. The player needs to store the shopping.

Two rules, under base_damage_for_level():

fn click_upgrade_bonus(upgrades: u32) -> u32 {
    upgrades.saturating_mul(2)
}

fn click_upgrade_cost(upgrades: u32) -> u32 {
    upgrades.saturating_add(1).saturating_mul(10)
}

Each upgrade adds 2 damage:

0 upgrades → 0 bonus
1 upgrade  → 2 bonus
2 upgrades → 4 bonus

And each one costs more than the last:

first upgrade  → 10 gold
second upgrade → 20 gold
third upgrade  → 30 gold

Both rules take the number of upgrades already bought. Neither one knows a Player exists.

Now replace the field with a count of purchases:

struct Player {
    gold: u32,
    xp: u32,
    level: u32,
    click_upgrade_level: u32,
}

A new player has bought nothing:

            click_upgrade_level: 0,

And just like base damage, bonus damage becomes a method that asks the rule. Put it under base_damage():

    fn bonus_damage(&self) -> u32 {
        click_upgrade_bonus(self.click_upgrade_level)
    }

attack() gets its second pair of parentheses:

    let damage = click_damage(player.base_damage(), player.bonus_damage());

Notice what Player is made of now.

Player
├── gold
├── xp
├── level
└── click_upgrade_level

Not a single damage number.

Every field is a record of progress: what the player has earned, and what they have done with it.

The damage numbers are worked out from that progress, by rules, on demand.

That is going to matter a great deal in a few articles, when we save the game.

We will only have to save what the player earned. Everything else can be recalculated.

The first fight changes

One honest side effect.

A new player used to hit for 7: 5 base plus 2 free bonus.

Now they hit for 5, because the bonus has to be bought.

The stage 1 slime still has 20 health, so it takes four clicks instead of three.

Two of our tests notice.

clicking_damages_the_enemy expected 13 health after one click. It is 15 now.

attack_reduces_enemy_health expected 3 health left from 10. It is 5.

Update both numbers and run cargo test.

Those tests were not broken. The game changed, and the tests were telling the truth about it.


Buying the upgrade

Here is the first version of the purchase. Add it to impl Player, under bonus_damage():

    fn buy_click_upgrade(&mut self) -> bool {
        let cost = click_upgrade_cost(self.click_upgrade_level);

        if self.gold < cost {
            return false;
        }

        self.gold -= cost;
        self.click_upgrade_level = self.click_upgrade_level.saturating_add(1);

        true
    }

Work out the price.

If the player cannot afford it, stop, and say so.

Otherwise, take the money, add the upgrade, and say so.

Two things to notice.

return false; leaves the method early.

Everything after it only runs if the player could afford the upgrade.

That lets the rest of the method read without an else wrapped around it.

And self.gold -= cost; is a plain minus sign, like the one in add_xp().

The check right above it guarantees self.gold is at least cost, so the subtraction cannot go below zero.

Then Game passes the request along, inside impl Game:

    fn buy_click_upgrade(&mut self) -> bool {
        self.player.buy_click_upgrade()
    }

The game does not know the price of an upgrade.

It does not know what happens when you buy one.

It knows the player handles that.

Nothing calls either method yet, so Rust will warn about both. main() will fix that shortly.


true is not an answer

This works.

Now imagine using it:

if game.buy_click_upgrade() {
    println!("Bought it!");
} else {
    println!("???");
}

What goes in the else?

false means the purchase did not happen.

It does not say why.

Today there is only one reason: not enough gold.

But look at this from the other side.

The code calling buy_click_upgrade() has to remember that false means "not enough gold."

Nothing in the code says so.

Add a second reason to fail someday, say a maximum upgrade level, and false starts meaning two different things.

Every place that checked it is now quietly wrong, and Rust will not say a word.

We want an answer that says what happened in plain words, and that Rust can check for us.


Give the outcome a name

Rust has a type for "one of these specific things":

#[derive(Debug, PartialEq)]
enum PurchaseOutcome {
    Purchased,
    NotEnoughGold,
}

Put it with the structs. It is a type, like they are.

enum is short for enumeration: a type whose values are listed out, one by one.

A PurchaseOutcome is either Purchased or NotEnoughGold.

It cannot be anything else.

It cannot be both.

The two options are called variants, and you name one the same way you reach into a type for a function:

PurchaseOutcome::NotEnoughGold

You can think about bool the same way for this purpose: it also stands for one of exactly two possibilities, true or false.

The difference is that our enum lets us give those possibilities names that belong to our game.

The line above the enum

#[derive(Debug, PartialEq)]

#[derive(...)] asks Rust to generate implementations for the traits we list. Here, that saves us from writing the standard Debug and PartialEq implementations by hand.

PartialEq is what lets == compare two outcomes. Without it, Rust has no idea whether Purchased == Purchased.

Debug is what lets a failing test print the outcome it got, so the failure message can say NotEnoughGold instead of nothing at all.

Our tests will want both.

Use it

Now the purchase can say what happened:

    fn buy_click_upgrade(&mut self) -> PurchaseOutcome {
        let cost = click_upgrade_cost(self.click_upgrade_level);

        if self.gold < cost {
            return PurchaseOutcome::NotEnoughGold;
        }

        self.gold -= cost;
        self.click_upgrade_level = self.click_upgrade_level.saturating_add(1);

        PurchaseOutcome::Purchased
    }

Same shape. Same early return.

Only the answers changed, and now they read like the game talking.

Game::buy_click_upgrade() changes its return type to match:

    fn buy_click_upgrade(&mut self) -> PurchaseOutcome {
        self.player.buy_click_upgrade()
    }

match: one branch per answer

Rust's match expression gives us a way to handle each possible variant explicitly.

Here is how main() will react to a purchase:

match game.buy_click_upgrade() {
    PurchaseOutcome::Purchased => println!("Bought it!"),
    PurchaseOutcome::NotEnoughGold => {}
}

match looks at the value and runs the branch that fits.

Each line is a pattern, an arrow, and what to do.

The {} on the second branch means: do nothing.

That is allowed. What is not allowed is leaving a variant out.

Delete the NotEnoughGold line and Rust refuses to compile:

error[E0004]: non-exhaustive patterns:
`PurchaseOutcome::NotEnoughGold` not covered

This is the payoff.

With our bool version, the caller had to remember what false meant.

With an enum and match, the possible outcomes are named, and Rust can make us handle every one.

If we add a third variant next month, every match in the program will stop compiling until it says what to do about it.

Rust is keeping a list for us.


main() goes shopping

Add the purchase to the loop, after the stage-cleared block:

        match game.buy_click_upgrade() {
            PurchaseOutcome::Purchased => println!(
                "Bought click upgrade {}! Click damage: {}  Gold left: {}",
                game.player.click_upgrade_level,
                click_damage(game.player.base_damage(), game.player.bonus_damage()),
                game.player.gold
            ),
            PurchaseOutcome::NotEnoughGold => {}
        }

Every turn, main() tries to buy an upgrade, and only says something when it works.

Worth being clear about what that means: in this terminal version, the game buys an upgrade the moment the player can afford one. Nobody is deciding anything.

Later, when this game has a button, the player will choose when to spend. The purchasing rule does not change when that happens. Only who calls it.

Run it:

cargo run
Stage 1 cleared!  Gold: 5  XP: 6/20
  Next up: Training Slime with 30 health.
Stage 2 cleared!  Gold: 12  XP: 14/20
  Next up: Training Slime with 40 health.
Bought click upgrade 1! Click damage: 7  Gold left: 2
Stage 3 cleared!  Gold: 11  XP: 4/35
Level up! Now level 2. Click damage: 8
  Next up: Training Slime with 50 health.
Stage 4 cleared!  Gold: 22  XP: 16/35
  Next up: Training Slime with 60 health.
Bought click upgrade 2! Click damage: 10  Gold left: 2
Stage 5 cleared!  Gold: 15  XP: 30/35
  Next up: Training Slime with 70 health.
Stage 6 cleared!  Gold: 30  XP: 11/50
Level up! Now level 3. Click damage: 11
  Next up: Training Slime with 80 health.
Bought click upgrade 3! Click damage: 13  Gold left: 0
Stage 7 cleared!  Gold: 17  XP: 29/50
  Next up: Training Slime with 90 health.
Stage 8 cleared!  Gold: 36  XP: 49/50
  Next up: Training Slime with 100 health.

Now gold goes somewhere.

It climbs, and then it drops to almost nothing, because something got bought.

Click damage goes 5 → 7 → 8 → 10 → 11 → 13, pushed up from two directions at once: levels the player earns, and upgrades the player buys.

Compare the last line to where we started this article. Ninety-six gold and nothing to do with it, versus thirty-six gold and three upgrades already paid for.


Testing the purchase

The pricing rule gets a test for its numbers:

    #[test]
    fn upgrades_cost_more_each_time() {
        assert_eq!(click_upgrade_cost(0), 10);
        assert_eq!(click_upgrade_cost(1), 20);
    }

Then the two things that can happen.

A broke player cannot buy anything, and a failed purchase changes nothing:

    #[test]
    fn buying_an_upgrade_needs_gold() {
        let mut player = Player::new();

        assert_eq!(player.buy_click_upgrade(), PurchaseOutcome::NotEnoughGold);
        assert_eq!(player.click_upgrade_level, 0);
        assert_eq!(player.bonus_damage(), 0);
    }

    #[test]
    fn buying_an_upgrade_spends_gold() {
        let mut player = Player::new();
        player.gold = 12;

        assert_eq!(player.buy_click_upgrade(), PurchaseOutcome::Purchased);
        assert_eq!(player.gold, 2);
        assert_eq!(player.click_upgrade_level, 1);
        assert_eq!(player.bonus_damage(), 2);
    }

Those assert_eq! lines comparing outcomes are exactly why we asked for PartialEq and Debug.

Without PartialEq, the comparison would not compile. Without Debug, a failure could not tell you which outcome it got instead.

Run them:

cargo test

All sixteen should pass.


The complete program

If you are only reading along, skip to the last two sections.

If you have been following along in your editor, here is everything in one place.

At this point, src/main.rs should look like this:

struct Player {
    gold: u32,
    xp: u32,
    level: u32,
    click_upgrade_level: u32,
}

struct Enemy {
    name: String,
    health: u32,
    gold_reward: u32,
    xp_reward: u32,
}

struct Game {
    stage: u32,
    player: Player,
    enemy: Enemy,
}

#[derive(Debug, PartialEq)]
enum PurchaseOutcome {
    Purchased,
    NotEnoughGold,
}

impl Player {
    fn new() -> Player {
        Player {
            gold: 0,
            xp: 0,
            level: 1,
            click_upgrade_level: 0,
        }
    }

    fn add_gold(&mut self, amount: u32) {
        self.gold = self.gold.saturating_add(amount);
    }

    fn add_xp(&mut self, amount: u32) {
        self.xp = self.xp.saturating_add(amount);

        while self.xp >= xp_to_next_level(self.level) {
            self.xp -= xp_to_next_level(self.level);
            self.level = self.level.saturating_add(1);
        }
    }

    fn base_damage(&self) -> u32 {
        base_damage_for_level(self.level)
    }

    fn bonus_damage(&self) -> u32 {
        click_upgrade_bonus(self.click_upgrade_level)
    }

    fn buy_click_upgrade(&mut self) -> PurchaseOutcome {
        let cost = click_upgrade_cost(self.click_upgrade_level);

        if self.gold < cost {
            return PurchaseOutcome::NotEnoughGold;
        }

        self.gold -= cost;
        self.click_upgrade_level = self.click_upgrade_level.saturating_add(1);

        PurchaseOutcome::Purchased
    }
}

impl Enemy {
    fn for_stage(stage: u32) -> Enemy {
        Enemy {
            name: String::from("Training Slime"),
            health: enemy_health_for_stage(stage),
            gold_reward: gold_reward_for_stage(stage),
            xp_reward: xp_reward_for_stage(stage),
        }
    }
}

impl Game {
    fn new() -> Game {
        Game {
            stage: 1,
            player: Player::new(),
            enemy: Enemy::for_stage(1),
        }
    }

    fn click(&mut self) {
        attack(&self.player, &mut self.enemy);

        if is_defeated(self.enemy.health) {
            self.defeat_enemy();
        }
    }

    fn defeat_enemy(&mut self) {
        self.player.add_gold(self.enemy.gold_reward);
        self.player.add_xp(self.enemy.xp_reward);

        self.stage = self.stage.saturating_add(1);
        self.enemy = Enemy::for_stage(self.stage);
    }

    fn buy_click_upgrade(&mut self) -> PurchaseOutcome {
        self.player.buy_click_upgrade()
    }
}

fn click_damage(base: u32, bonus: u32) -> u32 {
    base.saturating_add(bonus)
}

fn health_after_hit(health: u32, damage: u32) -> u32 {
    health.saturating_sub(damage)
}

fn is_defeated(health: u32) -> bool {
    health == 0
}

fn enemy_health_for_stage(stage: u32) -> u32 {
    stage.saturating_mul(10).saturating_add(10)
}

fn gold_reward_for_stage(stage: u32) -> u32 {
    stage.saturating_mul(2).saturating_add(3)
}

fn xp_reward_for_stage(stage: u32) -> u32 {
    stage.saturating_mul(2).saturating_add(4)
}

fn xp_to_next_level(level: u32) -> u32 {
    level
        .saturating_sub(1)
        .saturating_mul(15)
        .saturating_add(20)
}

fn base_damage_for_level(level: u32) -> u32 {
    level.saturating_add(4)
}

fn click_upgrade_bonus(upgrades: u32) -> u32 {
    upgrades.saturating_mul(2)
}

fn click_upgrade_cost(upgrades: u32) -> u32 {
    upgrades.saturating_add(1).saturating_mul(10)
}

fn attack(player: &Player, enemy: &mut Enemy) {
    let damage = click_damage(player.base_damage(), player.bonus_damage());

    enemy.health = health_after_hit(enemy.health, damage);
}

fn main() {
    let mut game = Game::new();

    while game.stage <= 8 {
        let stage = game.stage;
        let level = game.player.level;

        game.click();

        if game.stage > stage {
            println!(
                "Stage {stage} cleared!  Gold: {}  XP: {}/{}",
                game.player.gold,
                game.player.xp,
                xp_to_next_level(game.player.level)
            );

            if game.player.level > level {
                println!(
                    "Level up! Now level {}. Click damage: {}",
                    game.player.level,
                    click_damage(game.player.base_damage(), game.player.bonus_damage())
                );
            }

            println!(
                "  Next up: {} with {} health.",
                game.enemy.name, game.enemy.health
            );
        }

        match game.buy_click_upgrade() {
            PurchaseOutcome::Purchased => println!(
                "Bought click upgrade {}! Click damage: {}  Gold left: {}",
                game.player.click_upgrade_level,
                click_damage(game.player.base_damage(), game.player.bonus_damage()),
                game.player.gold
            ),
            PurchaseOutcome::NotEnoughGold => {}
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn click_damage_adds_base_and_bonus() {
        assert_eq!(click_damage(3, 4), 7);
    }

    #[test]
    fn health_never_drops_below_zero() {
        assert_eq!(health_after_hit(3, 10), 0);
    }

    #[test]
    fn zero_health_is_defeated() {
        assert!(is_defeated(0));
        assert!(!is_defeated(1));
    }

    #[test]
    fn attack_reduces_enemy_health() {
        let player = Player::new();

        let mut enemy = Enemy::for_stage(1);
        enemy.health = 10;

        attack(&player, &mut enemy);

        assert_eq!(enemy.health, 5);
    }

    #[test]
    fn later_stages_have_tougher_enemies() {
        assert_eq!(enemy_health_for_stage(1), 20);
        assert_eq!(enemy_health_for_stage(2), 30);
    }

    #[test]
    fn later_stages_pay_better() {
        assert_eq!(gold_reward_for_stage(1), 5);
        assert_eq!(gold_reward_for_stage(2), 7);
        assert_eq!(xp_reward_for_stage(1), 6);
        assert_eq!(xp_reward_for_stage(2), 8);
    }

    #[test]
    fn levels_need_more_xp_as_they_go() {
        assert_eq!(xp_to_next_level(1), 20);
        assert_eq!(xp_to_next_level(2), 35);
    }

    #[test]
    fn leveling_up_keeps_leftover_xp() {
        let mut player = Player::new();

        player.add_xp(25);

        assert_eq!(player.level, 2);
        assert_eq!(player.xp, 5);
    }

    #[test]
    fn a_big_reward_can_skip_levels() {
        let mut player = Player::new();

        player.add_xp(60);

        assert_eq!(player.level, 3);
        assert_eq!(player.xp, 5);
    }

    #[test]
    fn levels_make_the_player_hit_harder() {
        assert_eq!(base_damage_for_level(1), 5);
        assert_eq!(base_damage_for_level(2), 6);
    }

    #[test]
    fn upgrades_cost_more_each_time() {
        assert_eq!(click_upgrade_cost(0), 10);
        assert_eq!(click_upgrade_cost(1), 20);
    }

    #[test]
    fn buying_an_upgrade_needs_gold() {
        let mut player = Player::new();

        assert_eq!(player.buy_click_upgrade(), PurchaseOutcome::NotEnoughGold);
        assert_eq!(player.click_upgrade_level, 0);
        assert_eq!(player.bonus_damage(), 0);
    }

    #[test]
    fn buying_an_upgrade_spends_gold() {
        let mut player = Player::new();
        player.gold = 12;

        assert_eq!(player.buy_click_upgrade(), PurchaseOutcome::Purchased);
        assert_eq!(player.gold, 2);
        assert_eq!(player.click_upgrade_level, 1);
        assert_eq!(player.bonus_damage(), 2);
    }

    #[test]
    fn a_new_game_starts_at_stage_one() {
        let game = Game::new();

        assert_eq!(game.stage, 1);
        assert_eq!(game.enemy.health, 20);
        assert_eq!(game.player.gold, 0);
    }

    #[test]
    fn clicking_damages_the_enemy() {
        let mut game = Game::new();

        game.click();

        assert_eq!(game.enemy.health, 15);
    }

    #[test]
    fn defeating_an_enemy_pays_the_player() {
        let mut game = Game::new();
        game.enemy.health = 1;

        game.click();

        assert_eq!(game.stage, 2);
        assert_eq!(game.enemy.health, 30);
        assert_eq!(game.player.gold, 5);
        assert_eq!(game.player.xp, 6);
    }
}

Run both:

cargo run
cargo test

The game should clear eight stages, buying three upgrades along the way, and all sixteen tests should pass.


What we learned

Along the way, we encountered:

  • a second rule-derived value, replacing a stored one
  • early return
  • enum and variants
  • #[derive(Debug, PartialEq)]
  • match, and the compiler refusing to let us forget a case

Two ideas are worth keeping.

The first finishes the job base_damage() started. Player has no damage numbers in it at all now. It stores progress, and damage is a rule applied to that progress.

The second is PurchaseOutcome.

When a bool would have made the caller remember what false meant, we gave the answers names instead, and Rust started checking that every answer gets handled.

That pattern shows up everywhere in Rust. Two common examples you will eventually meet are Option, for a value that might not be there, and Result, for an operation that might have failed.

This is the first time we needed our own. It will not be the last.


Nothing happens unless you click

Our game finally has a progression loop.

Kill a slime, get paid, level up, buy damage, kill a tougher slime.

There is just one problem with calling it an idle game.

clicks per second while you are playing: a lot
clicks per second while you are not:    zero

Nothing happens unless you click.

Walk away, come back an hour later, and the slime is exactly where you left it.

An idle game is supposed to keep working while you do not.

Next time, we are going to give the game time.

Not a timer. Not a clock. Just a number: how many seconds went by.

The game will take that number and work out everything that should have happened.

Small function. Bigger game.

About the author

  • Web developer building full-stack applications with JavaScript, React, Node.js, and modern web APIs.

No comments yet. Be the first to comment.

Rustbound - Build Your First Rust App, One Function at a Time

Part 7 of 7

Learn Rust by building a real idle game from small functions to a complete WebAssembly app with testing, persistence, Web Components, PWA support, and deployment.

Start from the beginningSkip Hello World.Write the First Rules of an Idle Game