False Is Not an Answer.
Build Click Upgrades With Rust Enums and Match

Canonical: https://journeytocode.io/rust-enums-and-match-click-upgrades/
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:
src/main.rs · 268-line starting point
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)
);
}
}
Run and edit this example on the canonical article.
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.
Question: Why does Player now store click_upgrade_level instead of bonus_damage?
- Rust does not allow a struct to store a damage number
- The bonus is a consequence of what the player bought, so a rule can work it out
- Calling a method is faster than reading a field
- So the player can never buy more than one upgrade
Answer: 2. The bonus is a consequence of what the player bought, so a rule can work it out
Explanation: Store the progress, compute the rest. The number of purchases is the fact, and
click_upgrade_bonus()turns it into damage whenever we ask.
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.
Question: buy_click_upgrade() returns false. What does the calling code actually know?
- That the player did not have enough gold
- Only that the purchase did not happen
- That the method crashed
Answer: 2. Only that the purchase did not happen
Explanation:
falsesays no, and nothing else. The reason lives in the programmer's memory, not in the code, and a second way to fail would hide behind the samefalse.
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."),
}
}
}
Run and edit this example on the canonical article.
Question: You add a MaxLevelReached variant to PurchaseOutcome. What does Rust do the next time you build?
- Nothing, until a purchase actually returns
MaxLevelReached - It refuses to compile the enum itself
- Every
matchthat names the variants, but not the new one, stops compiling - Every
matchquietly skips the new variant
Answer: 3. Every match that names the variants, but not the new one, stops compiling
Explanation: The check happens when the program is built, not when it runs. The
matchinmain()gets anE0004error until it handles the new variant.
Question: Rust reports E0004 for a missing arm, and you silence it by adding _ => {}. What have you given up?
- The existing arms stop running
- Rust will no longer tell you when a variant added later needs handling
- The
matchno longer compiles
Answer: 2. Rust will no longer tell you when a variant added later needs handling
Explanation:
_means "anything else", so a future variant lands in it silently. Naming every variant is what keeps Rust's list.
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.
Question: You delete Debug from #[derive(Debug, PartialEq)]. What happens to the test that runs assert_eq!(player.buy_click_upgrade(), PurchaseOutcome::Purchased)?
- It compiles, but a failing test prints an empty message
- The test code no longer compiles
- Nothing changes
- It panics when the test runs
Answer: 2. The test code no longer compiles
Explanation:
assert_eq!has to be able to print both sides in case they differ, so it needsDebugbefore the test can even be built.
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:
src/main.rs · complete 339-line program
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.
- Add a third variant to
PurchaseOutcome, for an upgrade that is maxed out. - In
buy_click_upgrade(), return that variant when the player already has 5 upgrades. Check this before the price. - Press Run. Rust will refuse to compile until the
matchinmain()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
}
}
}
Spoiler: the checks for this challenge
These tests run when you press Check my code on the original article. Reading them first gives away the answer.
/// Once a player holds 5 upgrades, buying again should leave their gold and level alone. Look at what your function does at the cap.
#[test]
fn sixth_upgrade_is_refused() {
let mut player = Player {
gold: 1000,
click_upgrade_level: 5,
};
player.buy_click_upgrade();
assert_eq!(player.gold, 1000);
assert_eq!(player.click_upgrade_level, 5);
}
/// A player at the cap with an empty purse is maxed out, not broke. Which check comes first?
#[test]
fn cap_is_checked_before_price() {
let mut player = Player {
gold: 0,
click_upgrade_level: 5,
};
let outcome = player.buy_click_upgrade();
assert!(!matches!(outcome, PurchaseOutcome::NotEnoughGold));
assert!(!matches!(outcome, PurchaseOutcome::Purchased));
}
/// The fifth upgrade still has to sell. Look at where your cap starts.
#[test]
fn fifth_upgrade_still_sells() {
let mut player = Player {
gold: 1000,
click_upgrade_level: 4,
};
let outcome = player.buy_click_upgrade();
assert!(matches!(outcome, PurchaseOutcome::Purchased));
assert_eq!(player.click_upgrade_level, 5);
assert_eq!(player.gold, 950);
}
Run and edit this example on the canonical article.
Tip: Step 2 is an early
return, just like theNotEnoughGoldcheck below it. Compareself.click_upgrade_levelwith 5 using>=.
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 enumand 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. Join the discussion on the canonical article
About the author
- Web developer building full-stack applications with JavaScript, React, Node.js, and modern web APIs.
Comments
Join the discussionNo comments yet. Be the first to comment.
Rustbound - Build Your First Rust App, One Function at a Time
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.






