Skip to content
Journey to Code

Rustbound

False Is Not an Answer.

Build Click Upgrades With Rust Enums and Match

· By · Updated · 11 min read · 3 views

Cover reading "False Is Not an Answer." contrasting a bool-returning upgrade function with a PurchaseOutcome enum handled by match

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

Along the way: four quick knowledge checks, two live examples you can edit and run in the page, and a coding challenge at the end.


Our starting point#

If you are only reading along, 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.

If you build now, Rust warns that both are never used. The next few steps use them.

Want to feel the price curve before you commit to it? Edit the numbers and press Run.

Live example · tune the shop
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 main() {
    for upgrades in 0..5 {
        println!(
            "{upgrades} bought: +{} damage, next one costs {} gold",
            click_upgrade_bonus(upgrades),
            click_upgrade_cost(upgrades)
        );
    }
}

Try saturating_mul(3) for a stronger upgrade, or saturating_mul(25) for a pricier one. Keep the original numbers in your project, though. The run output later in this article depends on them.

Now replace the field with a count of purchases:

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

Rust immediately complains about every place that still mentions bonus_damage. The next few steps clear those errors one at a time.

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());

One more place still reads the old field: the "Level up!" line in main(). Rust points straight at it (the line is wrapped here to fit):

error[E0615]: attempted to take value of method
`bonus_damage` on type `Player`

It even suggests the fix: use parentheses to call the method.

                    click_damage(game.player.base_damage(), game.player.bonus_damage())

Now it builds again.

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.

Why does Player now store click_upgrade_level instead of bonus_damage?

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.

The true on the last line needs no return. It is the same rule every function so far has used: the last expression, with no semicolon, is the value the function gives back. return is only for leaving from somewhere in the middle.

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 warns that they are never used, and click_upgrade_cost() along with them. main() will fix that shortly.


false 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.

buy_click_upgrade() returns false. What does the calling code actually know?

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, after Game. It is a type, like they are. Until something uses it, Rust adds one more never-used warning to the pile.

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)]

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

Debug is what lets Rust print an outcome for a programmer to read, so a failing test can show NotEnoughGold in its message.

Numbers like u32 come with both abilities built in. Our brand-new enum starts with neither.

Rust calls abilities like these traits. That is all you need about traits today: a trait is a named ability a type can have, like "can be compared with ==."

#[derive(...)] is the one-line way to ask Rust for the standard version of a trait, instead of writing it by hand.

Our tests will need 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 called an arm: a pattern, an arrow, and what to do.

That arrow is =>, a fat arrow. It is not the -> from function signatures, and your fingers will try to type that one at least once.

Arms end with a comma, like items in a list. An arm whose body is a block in braces can leave the comma off.

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 that names the variants will stop compiling until it says what to do about it.

Rust is keeping a list for us.

The full error message also offers to fill the gap for you, with a wildcard pattern or a todo!() placeholder that crashes if it ever runs. A wildcard (_ =>, meaning "anything else") would compile, but it switches this check off for every variant you add later. Naming each variant is what keeps the list.

Try it. Run this, then delete one arm and run it again. Then put the arm back, add a third variant to the enum, and run it without touching the match.

Live example · break the match on purpose
enum PurchaseOutcome {
    Purchased,
    NotEnoughGold,
}

fn try_to_buy(gold: u32, cost: u32) -> PurchaseOutcome {
    if gold < cost {
        return PurchaseOutcome::NotEnoughGold;
    }

    PurchaseOutcome::Purchased
}

fn main() {
    for gold in [5, 10, 15] {
        match try_to_buy(gold, 10) {
            PurchaseOutcome::Purchased => println!("{gold} gold: bought it!"),
            PurchaseOutcome::NotEnoughGold => println!("{gold} gold: not enough."),
        }
    }
}
You add a MaxLevelReached variant to PurchaseOutcome. What does Rust do the next time you build?
Rust reports E0004 for a missing arm, and you silence it by adding _ => {}. What have you given up?

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. Put the new tests in mod tests, after levels_make_the_player_hit_harder:

    #[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.

assert_eq! needs both. PartialEq does the comparison. Debug prints the two sides if they do not match. Leave either one out of the derive line and these tests will not even compile.

cargo run would still build without them, because match needs neither. It is the tests that ask.

You delete Debug from #[derive(Debug, PartialEq)]. What happens to the test that runs assert_eq!(player.buy_click_upgrade(), PurchaseOutcome::Purchased)?

Run them:

cargo test

All sixteen should pass.


The complete program#

If you are only reading along, skip ahead to the coding challenge.

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.


Your turn: cap the upgrades#

A third way for a purchase to fail is exactly what the enum was built for. This is your chance to watch Rust walk you to every place that needs to know.

Coding challenge: a maxed-out upgrade

Stop selling click upgrades after the fifth one.

  1. Add a third variant to PurchaseOutcome, for an upgrade that is maxed out.
  2. In buy_click_upgrade(), return that variant when the player already has 5 upgrades. Check this before the price.
  3. Press Run. Rust will refuse to compile until the match in main() handles your new variant. Add that arm.

Keep Player, its two fields and buy_click_upgrade() as they are, because the checks call them. Check my code will tell you how you are doing.

Expected result: five "Bought upgrade" lines, then two lines saying the upgrades are maxed out. Gold left stops at 850.

Coding challenge · cap the upgrades
struct Player {
    gold: u32,
    click_upgrade_level: u32,
}

enum PurchaseOutcome {
    Purchased,
    NotEnoughGold,
    // TODO 1: add a third variant for "this upgrade is maxed out"
}

impl Player {
    fn buy_click_upgrade(&mut self) -> PurchaseOutcome {
        // TODO 2: stop at 5 upgrades, before checking the price

        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
    }
}

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

fn main() {
    let mut player = Player {
        gold: 1000,
        click_upgrade_level: 0,
    };

    for _ in 0..7 {
        match player.buy_click_upgrade() {
            PurchaseOutcome::Purchased => println!(
                "Bought upgrade {}. Gold left: {}",
                player.click_upgrade_level, player.gold
            ),
            PurchaseOutcome::NotEnoughGold => println!("Not enough gold."),
            // TODO 3: tell the player the upgrade is maxed out
        }
    }
}
Show one solution

The new variant:

    MaxedOut,

The early return, at the top of buy_click_upgrade():

        if self.click_upgrade_level >= 5 {
            return PurchaseOutcome::MaxedOut;
        }

The new arm in main():

            PurchaseOutcome::MaxedOut => println!("Click upgrades are maxed out."),

The challenge is its own small program, so your src/main.rs stays exactly as it is above.


What we learned#

Along the way, we encountered:

  • a second rule-derived value, replacing a stored one
  • early return
  • enum and variants
  • traits, in one sentence, and #[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.

Comments require JavaScript.