Skip to main content

Closing the Terminal Is Not Game Over.

Save and Load Your Rust Game With Serde and Result

Updated 37 min readMarkdown
Part of seriesRustbound - Build Your First Rust App, One Function at a Time
Closing the Terminal Is Not Game Over.

Our game can simulate an hour without making us wait an hour.

It cannot survive the program ending.

Close the terminal and everything is gone: the stages, the gold, the levels, the upgrade you bought. The next cargo run starts at stage 1 with a fresh slime, as if nothing ever happened.

An idle game that forgets you the moment you leave is not much of an idle game.

I learned what lost progress feels like on a 386, building DOOM maps. It crashed in the middle of one and took an entire afternoon of carefully placed monsters and secret rooms with it. Nothing has taught me to save my work quite like that afternoon.

Our game deserves better than my DOOM maps got.

Today the game learns to write itself down, and to read itself back.

Not to a file, or a browser, or a server. The library will not know where saves go, and it should not. It will only know how to say, in text:

This is everything about me.

And, handed that same text later:

I remember.

By the end, main() clears three stages, saves, throws the game away, loads the save, and then spends the hour away:

Saved: {"save_version":1,"stage":4,"player":{"gold":11,"xp":4,"level":2,"click_upgrade_level":1},"enemy_health":50}
Loaded. Stage 4, level 2.
One hour away.
Stage 4 -> 131  Level 2 -> 48  Gold 11 -> 17410

Same hour. Same numbers. A different game object, rebuilt from a line of text.

Saving always works. Loading might not: the text could be anything. So Rust hands the loaded game back in a box that holds one of two things, the game or the reason there is no game, and will not let us reach for the game until we have said what happens if it is the other one.

Along the way we add our first dependency, and that box turns out to be how Rust handles everything that can fail. There are five quick knowledge checks, two live examples you can edit and run in the page, and a coding challenge.


Our starting point

This series builds a small idle game: a player clicks to fight slimes, earns gold and XP, and keeps progressing while you are away.

If you are only reading along, skip ahead to "A save is just text." The code is folded away below, one file at a time.

If you are building along, you need the library we built last time: a project called rustbound-idle with six source files and one outside test. (If you kept your project from the last article, you already have all of this. The article_9 branch of the companion repository has it too.)

rustbound-idle/
├── Cargo.toml
├── src/
│   ├── lib.rs
│   ├── main.rs
│   ├── rules.rs
│   ├── enemy.rs
│   ├── player.rs
│   └── game.rs
└── tests/
    └── idle_hour.rs

Cargo.toml is whatever cargo new rustbound-idle gave you. It should say edition = "2024", which needs Rust 1.85 or newer.

src/lib.rs · 8 lines
mod enemy;
mod game;
mod player;
mod rules;

pub use enemy::Enemy;
pub use game::Game;
pub use player::{Player, PurchaseOutcome};
src/main.rs · 56 lines
use rustbound_idle::{Game, PurchaseOutcome};

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

    while game.stage <= 3 {
        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,
                game.player.xp_needed()
            );

            if game.player.level > level {
                println!(
                    "Level up! Now level {}. Click damage: {}",
                    game.player.level,
                    game.player.click_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,
                game.player.click_damage(),
                game.player.gold
            ),
            PurchaseOutcome::NotEnoughGold => {}
        }
    }

    let stage = game.stage;
    let level = game.player.level;
    let gold = game.player.gold;

    game.tick(3600);

    println!("One hour away.");
    println!(
        "Stage {} -> {}  Level {} -> {}  Gold {} -> {}",
        stage, game.stage, level, game.player.level, gold, game.player.gold
    );
}
src/rules.rs · 105 lines
pub(crate) fn click_damage(base: u32, bonus: u32) -> u32 {
    base.saturating_add(bonus)
}

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

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

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

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

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

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

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

pub(crate) fn idle_damage_for_level(level: u32) -> u32 {
    level
}

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

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

#[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 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 levels_make_the_player_hit_harder() {
        assert_eq!(base_damage_for_level(1), 5);
        assert_eq!(base_damage_for_level(2), 6);
    }

    #[test]
    fn idle_damage_grows_with_level() {
        assert_eq!(idle_damage_for_level(1), 1);
        assert_eq!(idle_damage_for_level(2), 2);
    }

    #[test]
    fn upgrades_cost_more_each_time() {
        assert_eq!(click_upgrade_cost(0), 10);
        assert_eq!(click_upgrade_cost(1), 20);
    }
}
src/enemy.rs · 19 lines
use crate::rules::{enemy_health_for_stage, gold_reward_for_stage, xp_reward_for_stage};

pub struct Enemy {
    pub name: String,
    pub health: u32,
    pub(crate) gold_reward: u32,
    pub(crate) xp_reward: u32,
}

impl Enemy {
    pub(crate) 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),
        }
    }
}
src/player.rs · 114 lines
use crate::rules::{
    base_damage_for_level, click_damage, click_upgrade_bonus, click_upgrade_cost, xp_to_next_level,
};

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

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

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

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

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

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

    pub fn xp_needed(&self) -> u32 {
        xp_to_next_level(self.level)
    }

    pub fn click_damage(&self) -> u32 {
        click_damage(self.base_damage(), self.bonus_damage())
    }

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

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

    pub(crate) 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
    }
}

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

    #[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 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);
    }
}
src/game.rs · 158 lines
use crate::enemy::Enemy;
use crate::player::{Player, PurchaseOutcome};
use crate::rules::{health_after_hit, idle_damage_for_level, is_defeated};

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

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

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

    pub fn tick(&mut self, seconds: u32) {
        for _ in 0..seconds {
            let damage = idle_damage_for_level(self.player.level);

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

    fn resolve_hit(&mut self) {
        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);
    }

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

impl Default for Game {
    fn default() -> Game {
        Game::new()
    }
}

fn attack(player: &Player, enemy: &mut Enemy) {
    enemy.health = health_after_hit(enemy.health, player.click_damage());
}

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

    #[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 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);
    }

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

        game.tick(1);

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

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

        game.tick(0);

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

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

        game.tick(20);

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

    #[test]
    fn one_long_tick_matches_many_short_ones() {
        let mut long = Game::new();
        let mut short = Game::new();

        long.tick(45);

        for _ in 0..45 {
            short.tick(1);
        }

        assert_eq!(long.stage, short.stage);
        assert_eq!(long.enemy.health, short.enemy.health);
        assert_eq!(long.player.gold, short.player.gold);
        assert_eq!(long.player.xp, short.player.xp);
        assert_eq!(long.player.level, short.player.level);
    }
}
tests/idle_hour.rs · 11 lines
use rustbound_idle::Game;

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

    game.tick(3600);

    assert_eq!(game.stage, 128);
    assert_eq!(game.player.level, 47);
}

Run cargo test, and twenty-two tests pass: twenty-one inside the library and one in tests/idle_hour.rs. Run cargo run, and the player clears three stages, buys an upgrade, and spends an hour away.

Hold on to twenty-two. Today we add six.


A save is just text

What is a save, exactly?

Strip away the menus and the little floppy-disk icon, and a save is a description of the game, written down somewhere that outlives the program.

That icon was a real object once. Our house had hundreds of them, the 5.25-inch kind that actually flopped, full of games and programs. They are probably still boxed up somewhere at my parents' house. The idea outlived them.

For our game, "the game" is one Game value: a stage, a player, and the slime they are fighting. Write those numbers down and you have a save. Read them back into a Game and you have loaded it.

So the question for today is: write them down how?

We could invent a format. One number per line, in a fixed order, and a parser that reads them back:

4
11
4
2
1
50

It would work, until the day we add a field in the middle and every old save quietly reads the wrong number into the wrong slot. Nothing crashes. The player just loads in with fifty gold and an upgrade level of eleven.

There is a better answer, and most programs that save anything already use it.

JSON is a plain-text format for describing values with names. Here is a brand-new game, written as JSON:

{
  "save_version": 1,
  "stage": 1,
  "player": {
    "gold": 0,
    "xp": 0,
    "level": 1,
    "click_upgrade_level": 0
  },
  "enemy_health": 20
}

Every value has a name, and values can nest: player is a group of four values inside the bigger one.

Notice that this is not a copy of every field in Game. There is no enemy name and no reward. It is the minimum we need to rebuild the same game: the stage, the player's progress, and how much health the current slime has left.

That last one matters more than it looks. Growing up, my brothers and I often played Super Nintendo games on an emulator instead of the console, and a big reason was being able to save anywhere. enemy_health is our version of that. We can save in the middle of a fight, and the slime will still be wounded when we come back.

The save_version line looks like it does nothing yet. It is there for a future version of us, and we will come back to it.

Question: Why save the game as named JSON values instead of one number per line?

  1. JSON files are always smaller
  2. Every value carries its name, so a new or reordered field cannot slide into the wrong slot
  3. Rust cannot read a file of plain numbers

Answer: 2. Every value carries its name, so a new or reordered field cannot slide into the wrong slot

Explanation: A fixed order is a promise that nothing will ever change. Names let the loader find gold wherever it is.

We are not going to write the JSON writer and reader by hand. Someone already has, and it is very good.


The first dependency

Until now, every line of this game has been ours. Today we borrow some.

Rust code that other people share is published as crates, and most of them live on crates.io. A crate your project uses is called a dependency. We are adding two:

  • serde knows how to take a Rust value apart into named pieces, and put it back together. The name is short for serialize and deserialize, which are the formal words for writing a value down and reading it back.
  • serde_json knows how to write those pieces as JSON, and read JSON back into pieces.

Open Cargo.toml. The [dependencies] section has been empty since the first article. Fill it in:

Cargo.toml

[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"

"1" means any version 1.x. Cargo picks the newest 1.x it can find, and never jumps to a 2.0 on its own.

The features = ["derive"] part matters, and we will see why in a moment.

Build it:

cargo build

The first time, Cargo downloads the two crates and the handful of smaller ones they depend on, and compiles all of them. You will see a dozen Compiling lines you did not write, and then your own:

   Compiling serde v1.0.229
   Compiling serde_json v1.0.154
   Compiling serde_derive v1.0.229
   ...
   Compiling rustbound-idle v0.1.0

Your version numbers may be newer. That is fine.

That only happens once. Cargo keeps the compiled crates around, and later builds are as fast as before.

Look in your project folder and you will find a new file, Cargo.lock. Cargo wrote it. It records the exact versions that got picked, so that anyone who builds this project later gets the same ones. Leave it alone, and if you use Git, commit it.


Give the save its own shape

serde can turn Rust values into text and back again, but there is a choice to make first.

We could teach serde about Game, Player, and Enemy directly.

That would work.

It would also make our runtime structs the permanent shape of our save files.

Those are not the same job.

A Game is whatever the engine needs while it is running.

A save is the minimum information needed to rebuild that game later.

The difference matters because some values can be recalculated from the rules. An enemy's gold and XP rewards come from its stage. If we saved those numbers, a save made today would carry today's rewards into next month's version of the game, even after we rebalanced them.

So the save gets its own small types.

Create src/save.rs:

src/save.rs

use serde::{Deserialize, Serialize};

const SAVE_VERSION: u32 = 1;

#[derive(Serialize, Deserialize)]
struct PlayerSave {
    gold: u32,
    xp: u32,
    level: u32,
    click_upgrade_level: u32,
}

#[derive(Serialize, Deserialize)]
struct SaveData {
    save_version: u32,
    stage: u32,
    player: PlayerSave,
    enemy_health: u32,
}

The two structs are the JSON from before, written as Rust. Same names, same nesting.

const declares a constant: a value fixed when the program is compiled, which can never change while it runs. Constants are written in capitals by convention, and they need their type spelled out. SAVE_VERSION is the version of the save format this code writes.

The interesting line is the one above each struct.

Asking for a trait

In plain terms, that line tells Rust: these two structs can be written down, and read back.

#[derive(Serialize, Deserialize)] is the same kind of line as the #[derive(Debug, PartialEq)] we put on PurchaseOutcome.

Serialize and Deserialize are traits. A trait is a named ability a type can have. PartialEq is the ability to be compared with ==. Serialize is the ability to be taken apart into named pieces. Deserialize is the ability to be built back up from them.

derive asks the compiler to write that ability for us, by looking at the fields. We could write it by hand. Nobody wants to. The thing that does the writing is called a derive macro; a macro is code that writes code.

That is also what features = ["derive"] was for. Without it, serde gives us the traits but not the code that derives them, and the build stops on every struct:

error: cannot find derive macro `Serialize` in this scope
  --> src\save.rs:5:10
   |
 5 | #[derive(Serialize, Deserialize)]
   |          ^^^^^^^^^
   |
note: `Serialize` is imported here, but it is only a trait, without a derive macro

Only a trait, without a derive macro. The ability exists; the machine that writes it for us was left out.

Last, the library has to know the new file exists. Add mod save; to src/lib.rs:

src/lib.rs

mod enemy;
mod game;
mod player;
mod rules;
mod save;

pub use enemy::Enemy;
pub use game::Game;
pub use player::{Player, PurchaseOutcome};

If you build now, Cargo warns that SAVE_VERSION, PlayerSave, and SaveData are never used. Fair. The next section uses them.

Question: Why does the save store the stage but not the enemy's gold_reward?

  1. gold_reward is pub(crate), so serde cannot see it
  2. JSON cannot hold more than six numbers
  3. The reward can be rebuilt from the stage, so an old save cannot carry old balance rules into a newer game

Answer: 3. The reward can be rebuilt from the stage, so an old save cannot carry old balance rules into a newer game

Explanation: Save what only the player could have done. Rebuild everything else from the current rules.


Writing it down

Saving is the easy direction. Every Game can be described, so save_json() always gets an answer.

Add an impl Game block to src/save.rs, below the structs. It is a second impl block for Game, in a different file from the first, and Rust is fine with that: an impl block can live anywhere in the crate that owns the type.

First, one more import at the top of the file, beside the serde one:

src/save.rs

use crate::game::Game;

Then the block itself:

src/save.rs

impl Game {
    pub fn save_json(&self) -> String {
        let save = SaveData {
            save_version: SAVE_VERSION,
            stage: self.stage,
            player: PlayerSave {
                gold: self.player.gold,
                xp: self.player.xp,
                level: self.player.level,
                click_upgrade_level: self.player.click_upgrade_level,
            },
            enemy_health: self.enemy.health,
        };

        serde_json::to_string(&save).expect("a save made of numbers should always serialize")
    }
}

The method copies the numbers that matter out of the running game into a SaveData, and hands it to serde_json::to_string.

That last line has a word we have not seen.

serde_json::to_string does not hand back a String. It hands back a Result: either Ok, holding the text, or Err, holding what went wrong. It has to, because some Rust values cannot be written as JSON at all.

Ours can. A struct full of numbers always can. So we call .expect(), which says:

Give me what is inside the Ok. If it is somehow an Err, stop the program with this message.

expect() is for the cases where failure would mean a bug in our own code, not bad luck. We will meet a case that is pure bad luck in a moment, and there we will not use it.

Now a test that pins the format down. Add it to the bottom of src/save.rs:

src/save.rs

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

    #[test]
    fn a_new_game_saves_its_starting_numbers() {
        let json = Game::new().save_json();

        assert_eq!(
            json,
            r#"{"save_version":1,"stage":1,"player":{"gold":0,"xp":0,"level":1,"click_upgrade_level":0},"enemy_health":20}"#
        );
    }
}

That string starts with r#" and ends with "#. It is a raw string. JSON is full of double quotes, and in a normal string every one of them would need a backslash in front of it. In a raw string, a " is just a character, and only "# ends it.

serde_json::to_string writes everything on one line with no spaces, which is why the expected text is one long line.

cargo test

Twenty-three tests pass.


Reading it back

Loading is the hard direction.

Saving starts from a Game, which is always valid. Loading starts from text, and text can be anything. A save from an older version. A half-written file. Somebody's grocery list.

So what should a load function return when the text is not a save?

A Game? Which one? A brand-new game would quietly wipe out the player's progress, and nothing would tell them why. It is the same trap as the shop that answered false in an earlier lesson: an answer that looks like an answer, where a wrong guess about what it means looks just like success.

The honest return type is the one serde_json already used on us:

Result<Game, LoadError>

Either a Game, or the reason there is no Game.

LoadError is ours to define. It is an enum, like PurchaseOutcome, listing the ways loading can fail. For now there is one. Add it near the top of src/save.rs, below SAVE_VERSION:

src/save.rs

#[derive(Debug, PartialEq)]
pub enum LoadError {
    Unreadable,
}

Then the load itself, inside the same impl Game block, below save_json():

src/save.rs

    pub fn from_saved_json(json: &str) -> Result<Game, LoadError> {
        let save: SaveData = match serde_json::from_str(json) {
            Ok(save) => save,
            Err(_) => return Err(LoadError::Unreadable),
        };

        let mut game = Game::new();

        game.stage = save.stage;
        game.player.gold = save.player.gold;
        game.player.xp = save.player.xp;
        game.player.level = save.player.level;
        game.player.click_upgrade_level = save.player.click_upgrade_level;

        game.enemy = Enemy::for_stage(save.stage);
        game.enemy.health = save.enemy_health;

        Ok(game)
    }

And one more import at the top, because we build an enemy:

src/save.rs

use crate::enemy::Enemy;

Let us walk through it.

json: &str means the function borrows some text instead of taking it. &str is a borrowed piece of text, the way &Player is a borrowed player. Whoever calls from_saved_json() keeps their String.

serde_json::from_str(json) reads the text. It returns a Result too, and this time failure is real: the text might be anything. So we match on it, exactly the way we matched on PurchaseOutcome, one arm per possibility:

  • Ok(save) => save takes the SaveData out of the Ok and makes it the value of the whole match.
  • Err(_) => return Err(LoadError::Unreadable) ignores the details of what went wrong (that is the _) and leaves the function early with our own error.

The : SaveData after let save matters more than it looks. from_str can build anything that implements Deserialize. That annotation is how it knows to build a SaveData, and therefore which names to look for.

The rest rebuilds the game. Start from a new Game, copy the saved progress over it, then ask Enemy::for_stage() for the enemy. That one line is where the save's small shape pays off. The rewards come from the current rules, not from the file. Only the slime's remaining health comes from the save.

Last, Ok(game) wraps the finished game in the success half of the Result.

Here is the same round trip on its own. It runs in the page, using the same two crates.

Run it, and you should see the player saved and loaded. Then break the save on purpose: delete "level":2, from the text line and run again. Then put it back and change 11 to -5.

Live example · break the save on purpose

use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize)]
struct PlayerSave {
    gold: u32,
    xp: u32,
    level: u32,
    click_upgrade_level: u32,
}

fn main() {
    let player = PlayerSave {
        gold: 11,
        xp: 4,
        level: 2,
        click_upgrade_level: 1,
    };

    let json = serde_json::to_string(&player)
        .expect("a save made of numbers should always serialize");
    println!("Saved: {json}");

    let text = r#"{"gold":11,"xp":4,"level":2,"click_upgrade_level":1}"#;
    let loaded: Result<PlayerSave, serde_json::Error> = serde_json::from_str(text);

    match loaded {
        Ok(player) => println!("Loaded: level {} with {} gold", player.level, player.gold),
        Err(error) => println!("Not a save: {error}"),
    }
}

Run and edit this example on the canonical article.

The first break prints:

Not a save: missing field `level` at line 1 column 42

The second:

Not a save: invalid value: integer `-5`, expected u32 at line 1 column 10

serde_json knew exactly what was wrong both times. from_saved_json() throws those details away and says Unreadable, because the game cannot do anything more useful with them. Either way, the Err arm prints and nothing crashes.

Question: Why does from_saved_json() return Result<Game, LoadError> instead of a Game?

  1. A Result loads faster than a plain struct
  2. Text that is not a save has no honest Game to return, and the caller has to handle both cases
  3. serde_json only works inside functions that return a Result

Answer: 2. Text that is not a save has no honest Game to return, and the caller has to handle both cases

Explanation: Returning Game::new() on failure would look like success and quietly wipe the player's progress.


Rust will not let us forget

Time to use it. In src/main.rs, after the while loop and before the hour away, save the game:

src/main.rs

    let save = game.save_json();
    println!("Saved: {save}");

Then load it back and print where we are. The tempting version is two lines, right under the save lines:

    let mut game = Game::from_saved_json(&save);
    println!("Loaded. Stage {}, level {}.", game.stage, game.player.level);

Build that, and Rust refuses, several times over:

error[E0609]: no field `stage` on type `Result<Game, rustbound_idle::save::LoadError>`
  --> src\main.rs:49:50
   |
49 |     println!("Loaded. Stage {}, level {}.", game.stage, game.player.level);
   |                                                  ^^^^^ unknown field

The other errors say the same thing about player, and about every later line that touches game.

game is not a Game. It is a Result, and a Result has no stage. The Game is inside it, maybe.

That is the whole point. Other languages often hand back the game or a special empty value and trust you to check. Rust hands back a box with one of two things in it, and will not let you reach for the game until you have said what happens if it is the other thing.

So we say it, with a match. Replace the two tempting lines with:

src/main.rs

    let mut game = match Game::from_saved_json(&save) {
        Ok(game) => game,
        Err(error) => {
            println!("Could not load the save: {error:?}");
            return;
        }
    };

    println!("Loaded. Stage {}, level {}.", game.stage, game.player.level);

If the load worked, the Game comes out of the Ok. If not, we print the error and return from main(), which ends the program. {error:?} prints the error with Debug, which is why LoadError derives it.

Anyone outside the library who wants to match on the different kinds of LoadError has to be able to name it. So it goes on the front door, in src/lib.rs:

src/lib.rs

pub use save::LoadError;

main.rs does not name it yet. The browser version of the game, a couple of lessons from now, will.


Throw the game away

Look at the code we just wrote again. There is something odd about it.

We already had a game. Then we wrote let mut game = ... a second time.

That is allowed. A new let with an old name makes a brand-new variable, and the old one can no longer be reached by that name. Rust calls this shadowing.

But the old game is still there, sitting in memory until main() ends. And we want to prove the load works, not just print a game that might secretly be the old one.

So we throw the old one away first, on purpose, right after saving:

src/main.rs

    let save = game.save_json();
    println!("Saved: {save}");

    drop(game);

drop() takes a value and destroys it.

Takes is the important word. In the borrowing article, passing a Player into a function moved it: the function owned it now, and the old variable was empty. drop(game) moves our game into drop, and drop simply ends it.

Try to use the old game anyway. Put this on the line right after drop(game);:

    println!("{}", game.stage);

Rust stops you:

error[E0382]: borrow of moved value: `game`
  --> src\main.rs:49:20
   |
 4 |     let mut game = Game::new();
   |         -------- move occurs because `game` has type `Game`, which does not implement the `Copy` trait
...
48 |     drop(game);
   |          ---- value moved here
49 |     println!("{}", game.stage);
   |                    ^^^^^^^^^^ value borrowed here after move

Delete that line again. Value moved here. After drop(game), the only things left of the game are the text in save and whatever we rebuild from it.

So when the program prints Loaded. Stage 4, level 2. and then spends an hour, every number it shows came through that line of JSON.

Try it on a smaller game. Run it, and you should see Restored game: stage 4. Then remove the // in front of the println! that reads the old game and run it again. Put it back when you have read the error.

Live example · use it after it is gone

struct Game {
    stage: u32,
}

fn main() {
    let game = Game { stage: 4 };
    let saved_stage = game.stage;

    drop(game);

    // println!("Old game: stage {}", game.stage);

    let game = Game { stage: saved_stage };

    println!("Restored game: stage {}", game.stage);
}

Run and edit this example on the canonical article.

Back in your project, run the real game:

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.
Saved: {"save_version":1,"stage":4,"player":{"gold":11,"xp":4,"level":2,"click_upgrade_level":1},"enemy_health":50}
Loaded. Stage 4, level 2.
One hour away.
Stage 4 -> 131  Level 2 -> 48  Gold 11 -> 17410

The last line is the same one the game printed last time, with no save involved. The game that spent the hour was not the game that cleared the stages. It only remembered being it.


A save from the future

Now that save_version field.

Some day the save format will change. Maybe a second upgrade, maybe a pet. When it does, we bump SAVE_VERSION to 2.

Then someone opens the new version of the game with a save from version 1. Or, worse, opens an old copy of the game with a save from version 2. Their save might even read correctly as JSON, and still mean something different from what this code thinks it means.

We cannot know today how to convert between formats that do not exist yet. What we can do today is notice the mismatch and refuse, instead of guessing.

That is a second way loading can fail. And unlike Unreadable, this one has a detail worth keeping: which version the save claimed to be.

Coding challenge: Refuse a save from the future

Below is a smaller loader that only reads a version and a stage. It already turns unreadable text into LoadError::Unreadable. Where the // TODO is, add the check that refuses any save_version that is not SAVE_VERSION, with LoadError::UnsupportedVersion holding the version the save claimed.

Keep the names load_stage, LoadError, Unreadable, UnsupportedVersion, and SAVE_VERSION, because the checks use them. Check my code runs hidden tests against your version.

Expected result: the starter prints Ok(4), Ok(4), Err(Unreadable). With your check, the middle line becomes Err(UnsupportedVersion(99)). Until then you will also see two "never used" warnings, for SAVE_VERSION and UnsupportedVersion. They are a hint: your check is what uses them.

Refuse a save from the future

use serde::Deserialize;

const SAVE_VERSION: u32 = 1;

#[derive(Debug, PartialEq)]
enum LoadError {
    Unreadable,
    UnsupportedVersion(u32),
}

#[derive(Deserialize)]
struct SaveData {
    save_version: u32,
    stage: u32,
}

fn load_stage(json: &str) -> Result<u32, LoadError> {
    let save: SaveData = match serde_json::from_str(json) {
        Ok(save) => save,
        Err(_) => return Err(LoadError::Unreadable),
    };

    // TODO: refuse any save_version that is not SAVE_VERSION

    Ok(save.stage)
}

fn main() {
    println!("{:?}", load_stage(r#"{"save_version":1,"stage":4}"#));
    println!("{:?}", load_stage(r#"{"save_version":99,"stage":4}"#));
    println!("{:?}", load_stage("not a save"));
}
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.

/// A save with the current version should still load and give back its stage.
#[test]
fn the_current_version_loads() {
    assert_eq!(load_stage(r#"{"save_version":1,"stage":4}"#), Ok(4));
}

/// A save from version 99 should be refused with `LoadError::UnsupportedVersion`, carrying the 99 so the caller knows which version it was.
#[test]
fn another_version_is_refused() {
    assert_eq!(
        load_stage(r#"{"save_version":99,"stage":4}"#),
        Err(LoadError::UnsupportedVersion(99))
    );
}

/// Text that is not a save at all should still be `LoadError::Unreadable`. Check the version only after the text has been read.
#[test]
fn nonsense_is_still_unreadable() {
    assert_eq!(load_stage("not a save"), Err(LoadError::Unreadable));
}

/// `main()` should run to the end without panicking.
#[test]
fn main_runs() {
    main();
}

Run and edit this example on the canonical article.

Tip: It is an if with an early return, the same shape as the "not enough gold" check in buy_click_upgrade(): compare save.save_version with SAVE_VERSION, and return Err(LoadError::UnsupportedVersion(save.save_version)) when they differ.

Here is the same change in the real game. First the new variant:

src/save.rs

#[derive(Debug, PartialEq)]
pub enum LoadError {
    Unreadable,
    UnsupportedVersion(u32),
}

UnsupportedVersion(u32) is an enum variant that carries a value. PurchaseOutcome::Purchased was just a name. LoadError::UnsupportedVersion(99) is a name and a number: the version that got refused. A match can pull that number back out, the same way Ok(save) pulled out the save.

Then the check, in from_saved_json(), right after the match and before the game is rebuilt:

src/save.rs

        if save.save_version != SAVE_VERSION {
            return Err(LoadError::UnsupportedVersion(save.save_version));
        }

There is nothing to migrate yet. Version 1 is the only version that has ever existed. But every save written from today on says which version wrote it, and that is the part we could never add later. A save without a version number stays without one forever.


Tests that say no

A broken save should get a polite no, not a wiped game. These tests prove it does.

The loader has three ways out: a game, Unreadable, and UnsupportedVersion. Each one deserves a test.

The obvious way to write the first one does not compile:

assert_eq!(
    Game::from_saved_json("definitely not a save"),
    Err(LoadError::Unreadable)
);
error[E0369]: binary operation `==` cannot be applied to type `Result<game::Game, save::LoadError>`
note: an implementation of `PartialEq` might be missing for `game::Game`

error[E0277]: `game::Game` doesn't implement `Debug`

assert_eq! compares the two sides with ==, and prints both if they differ. A Result<Game, LoadError> can only do that if Game can, and Game has never needed to be compared or printed. We could derive PartialEq and Debug on Game, Player, and Enemy just for this. But we do not want to compare whole games here. We want to ask one question: is this an Err(LoadError::Unreadable)?

That is what matches! does. The ! means it is a macro, like println! and assert_eq!. It takes a value and a pattern, the same kind of pattern a match arm uses, and gives back true if the value fits:

assert!(matches!(
    Game::from_saved_json("definitely not a save"),
    Err(LoadError::Unreadable)
));

Here are all the new unit tests. They go inside the tests module at the bottom of src/save.rs, after a_new_game_saves_its_starting_numbers:

src/save.rs

    #[test]
    fn a_loaded_game_rebuilds_the_enemy_from_the_rules() {
        let mut game = Game::new();
        game.tick(600);

        let restored = Game::from_saved_json(&game.save_json()).expect("the save should load");

        assert_eq!(restored.stage, game.stage);
        assert_eq!(restored.enemy.health, game.enemy.health);
        assert_eq!(restored.enemy.gold_reward, game.enemy.gold_reward);
        assert_eq!(restored.enemy.xp_reward, game.enemy.xp_reward);
    }

    #[test]
    fn nonsense_is_not_a_save() {
        assert!(matches!(
            Game::from_saved_json("definitely not a save"),
            Err(LoadError::Unreadable)
        ));
    }

    #[test]
    fn a_save_with_missing_pieces_is_not_a_save() {
        let json = r#"{"save_version":1,"stage":4}"#;

        assert!(matches!(
            Game::from_saved_json(json),
            Err(LoadError::Unreadable)
        ));
    }

    #[test]
    fn a_save_from_another_version_is_refused() {
        let json = r#"{
            "save_version": 99,
            "stage": 1,
            "player": { "gold": 0, "xp": 0, "level": 1, "click_upgrade_level": 0 },
            "enemy_health": 20
        }"#;

        assert!(matches!(
            Game::from_saved_json(json),
            Err(LoadError::UnsupportedVersion(99))
        ));
    }

The first one checks the rewards, which are not in the save at all. It can read gold_reward because it lives inside the library, where pub(crate) fields are visible. Here expect() is the right tool: if a save we just made does not load, that is a bug, and the test should fail loudly.

The last one shows that JSON does not care about spaces or line breaks. The loader reads that save fine. It refuses it anyway, because of the 99.

Question: Why do the failure tests use assert!(matches!(...)) instead of assert_eq!?

  1. matches! runs faster than assert_eq!
  2. assert_eq! needs both sides to support == and Debug, and Game supports neither
  3. assert_eq! cannot compare enum variants that carry a value

Answer: 2. assert_eq! needs both sides to support == and Debug, and Game supports neither

Explanation: matches! only asks whether a value fits a pattern, so Game never has to be compared or printed.

One more test, from the outside, the same way tests/idle_hour.rs tests the library. Create tests/save_and_restore.rs:

tests/save_and_restore.rs

use rustbound_idle::Game;

#[test]
fn a_restored_game_plays_the_same_hour() {
    let mut original = Game::new();
    original.tick(600);

    let mut restored = Game::from_saved_json(&original.save_json()).expect("the save should load");

    original.tick(3600);
    restored.tick(3600);

    assert_eq!(restored.stage, original.stage);
    assert_eq!(restored.enemy.health, original.enemy.health);
    assert_eq!(restored.player.gold, original.player.gold);
    assert_eq!(restored.player.xp, original.player.xp);
    assert_eq!(restored.player.level, original.player.level);
}

It is a cousin of one_long_tick_matches_many_short_ones. Two games that should be the same, run side by side, compared at the end. If the save left anything out, an hour is plenty of time for the difference to show.

Run everything:

cargo test
cargo clippy

Twenty-eight tests pass: twenty-six inside the library, and one in each of the two files in tests/. Clippy should be quiet.


The new and changed files

If anything above went sideways, here is every file that changed. The rest are exactly as they were in "Our starting point."

Cargo.toml (only the [dependencies] section changed):

Cargo.toml

[package]
name = "rustbound-idle"
version = "0.1.0"
edition = "2024"

[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"

src/lib.rs:

src/lib.rs

mod enemy;
mod game;
mod player;
mod rules;
mod save;

pub use enemy::Enemy;
pub use game::Game;
pub use player::{Player, PurchaseOutcome};
pub use save::LoadError;
src/save.rs · 130 lines

src/save.rs

use crate::enemy::Enemy;
use crate::game::Game;
use serde::{Deserialize, Serialize};

const SAVE_VERSION: u32 = 1;

#[derive(Debug, PartialEq)]
pub enum LoadError {
    Unreadable,
    UnsupportedVersion(u32),
}

#[derive(Serialize, Deserialize)]
struct PlayerSave {
    gold: u32,
    xp: u32,
    level: u32,
    click_upgrade_level: u32,
}

#[derive(Serialize, Deserialize)]
struct SaveData {
    save_version: u32,
    stage: u32,
    player: PlayerSave,
    enemy_health: u32,
}

impl Game {
    pub fn save_json(&self) -> String {
        let save = SaveData {
            save_version: SAVE_VERSION,
            stage: self.stage,
            player: PlayerSave {
                gold: self.player.gold,
                xp: self.player.xp,
                level: self.player.level,
                click_upgrade_level: self.player.click_upgrade_level,
            },
            enemy_health: self.enemy.health,
        };

        serde_json::to_string(&save).expect("a save made of numbers should always serialize")
    }

    pub fn from_saved_json(json: &str) -> Result<Game, LoadError> {
        let save: SaveData = match serde_json::from_str(json) {
            Ok(save) => save,
            Err(_) => return Err(LoadError::Unreadable),
        };

        if save.save_version != SAVE_VERSION {
            return Err(LoadError::UnsupportedVersion(save.save_version));
        }

        let mut game = Game::new();

        game.stage = save.stage;
        game.player.gold = save.player.gold;
        game.player.xp = save.player.xp;
        game.player.level = save.player.level;
        game.player.click_upgrade_level = save.player.click_upgrade_level;

        game.enemy = Enemy::for_stage(save.stage);
        game.enemy.health = save.enemy_health;

        Ok(game)
    }
}

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

    #[test]
    fn a_new_game_saves_its_starting_numbers() {
        let json = Game::new().save_json();

        assert_eq!(
            json,
            r#"{"save_version":1,"stage":1,"player":{"gold":0,"xp":0,"level":1,"click_upgrade_level":0},"enemy_health":20}"#
        );
    }

    #[test]
    fn a_loaded_game_rebuilds_the_enemy_from_the_rules() {
        let mut game = Game::new();
        game.tick(600);

        let restored = Game::from_saved_json(&game.save_json()).expect("the save should load");

        assert_eq!(restored.stage, game.stage);
        assert_eq!(restored.enemy.health, game.enemy.health);
        assert_eq!(restored.enemy.gold_reward, game.enemy.gold_reward);
        assert_eq!(restored.enemy.xp_reward, game.enemy.xp_reward);
    }

    #[test]
    fn nonsense_is_not_a_save() {
        assert!(matches!(
            Game::from_saved_json("definitely not a save"),
            Err(LoadError::Unreadable)
        ));
    }

    #[test]
    fn a_save_with_missing_pieces_is_not_a_save() {
        let json = r#"{"save_version":1,"stage":4}"#;

        assert!(matches!(
            Game::from_saved_json(json),
            Err(LoadError::Unreadable)
        ));
    }

    #[test]
    fn a_save_from_another_version_is_refused() {
        let json = r#"{
            "save_version": 99,
            "stage": 1,
            "player": { "gold": 0, "xp": 0, "level": 1, "click_upgrade_level": 0 },
            "enemy_health": 20
        }"#;

        assert!(matches!(
            Game::from_saved_json(json),
            Err(LoadError::UnsupportedVersion(99))
        ));
    }
}
src/main.rs · 71 lines

src/main.rs

use rustbound_idle::{Game, PurchaseOutcome};

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

    while game.stage <= 3 {
        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,
                game.player.xp_needed()
            );

            if game.player.level > level {
                println!(
                    "Level up! Now level {}. Click damage: {}",
                    game.player.level,
                    game.player.click_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,
                game.player.click_damage(),
                game.player.gold
            ),
            PurchaseOutcome::NotEnoughGold => {}
        }
    }

    let save = game.save_json();
    println!("Saved: {save}");

    drop(game);

    let mut game = match Game::from_saved_json(&save) {
        Ok(game) => game,
        Err(error) => {
            println!("Could not load the save: {error:?}");
            return;
        }
    };

    println!("Loaded. Stage {}, level {}.", game.stage, game.player.level);

    let stage = game.stage;
    let level = game.player.level;
    let gold = game.player.gold;

    game.tick(3600);

    println!("One hour away.");
    println!(
        "Stage {} -> {}  Level {} -> {}  Gold {} -> {}",
        stage, game.stage, level, game.player.level, gold, game.player.gold
    );
}

tests/save_and_restore.rs is exactly as written in the section above.


What we learned

Two ideas are worth keeping.

The first is that failure is part of the answer. from_saved_json() does not return a Game. It returns a Result, and anyone who calls it has to deal with both halves before they get a Game out. There is no way to forget. That is Result doing for errors what PurchaseOutcome did for purchases: making the outcome a value with a name, so that false is never the whole story.

The second is what the save does not contain. No damage numbers, no thresholds, no costs, and no enemy rewards. It stores the player's progress, the stage, and the enemy's remaining health. Everything else is rebuilt from the current rules.

Along the way, we also encountered:

  • dependencies in Cargo.toml, and Cargo.lock
  • serde and serde_json
  • JSON
  • traits, and deriving Serialize and Deserialize
  • const
  • a save shape kept separate from the runtime Game
  • Result, Ok, and Err
  • match on a Result, and expect() when failure would be a bug
  • &str, and raw strings
  • drop(), and the word moved
  • shadowing: reusing a variable name with a new let
  • enum variants that carry a value
  • a save version, before there is anything to migrate
  • matches!

You do not need to memorize all of that yet.

There is still a loose end. A save is text, and anyone can edit text.

Question: A player opens their save and changes "gold":11 to "gold":11000000. What does from_saved_json() do today?

  1. Returns Err(LoadError::Unreadable), because the number is too big
  2. Loads it, because it is still valid JSON with the right version and a number that fits in a u32
  3. Refuses to compile until we add a maximum

Answer: 2. Loads it, because it is still valid JSON with the right version and a number that fits in a u32

Explanation: The loader checks that the text is a save, not that the save is believable. That is a different job.

Module privacy keeps other Rust code away from private engine details. It cannot make outside data trustworthy. Checking that a save makes sense is a different boundary, and it gets its own article later in the series. Not the next one.


Text crosses any wall

Our game can now describe itself in text, and rebuild itself from text.

Text is one of the easiest things for different languages and environments to share. A terminal can print it. A file can hold it. A browser can keep it in storage and hand it back.

So far, the only thing that has ever driven this engine is main(), in Rust, in a terminal.

Next time, we compile the library to WebAssembly, a format browsers can run right beside JavaScript, and the thing on the other side of the wall is JavaScript.

It will not learn how combat works. It will not learn what a slime is worth. It gets a handful of operations and a save that is already text.

That is all it needs.

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 10 of 10

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