The tutorial · Step 7

Saving progress

Play this step Its whole project

The player shouldn't have to start again each time. In this step, the game remembers the furthest level reached, and the levels past it can't be chosen yet:

The level menu with only Level 1 bright: the levels not reached yet are dim.

Levels not reached are dim. Win the first, and the next lights up:

After winning level 1, the menu is on Level 2, and Level 2 is now bright.
FileWhat changes
crates/game/src/progress.rsNew: what is kept, and how
crates/game/src/levels.rsThe menu reads it and writes it
crates/game/src/stages.rsLevelMenu::new now needs ctx
crates/game/src/lib.rspub mod progress;
Cargo.toml, crates/game/Cargo.tomlAdds ron
crates/game/tests/board.rsTests of saving

Add ron as you added serde. In the top Cargo.toml, under [workspace.dependencies], add the line ron = "0.12", and in crates/game/Cargo.toml, under [dependencies], add ron.workspace = true.

Where saves go

Shuyi: a game never opens a file itself. It goes through storage, which the platform hands it: a file on a computer, the browser's storage on the web, and only memory in a test. So the same code saves on every machine the game runs on, and a test needs no disk. It's the same idea as actions, and as ctx itself: the game asks for a thing, and doesn't care how it's done.

Storage keeps small named texts, and that's all. ctx.storage has read and write, and a key names each text, just as a key names each line of your words.

What is kept

Game idea: always write the version of your save's format into the save. Some day you'll change what a save holds, and a player will still have an old one. A later build has to be able to tell what it's reading, or it will read it wrong.

Make crates/game/src/progress.rs:

use serde::{Deserialize, Serialize};
use shuyi::core::{SaveFormat, SaveHeader};
use shuyi::ui::storage::{Storage, StorageError};
/// What is kept: the version of the format, and the furthest level.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Progress {
    /// The version of the format that wrote it.
    pub version: u32,
    /// The furthest level reached, counted from 0.
    pub furthest: usize,
}

impl SaveFormat for Progress {
    const NAME: &'static str = "Progress";
    /// Raised whenever this type changes shape or meaning.
    const VERSION: u32 = 1;
}

/// Where it is kept, in the player's profile.
// check-text: not player text, a storage key
pub const KEY: &str = "progress";

/// The furthest level reached: the first, if nothing readable is kept.
pub fn furthest(storage: &dyn Storage) -> usize {
    let Ok(Some(text)) = storage.read(KEY) else {
        return 0;
    };
    match ron::from_str::<SaveHeader<Progress>>(&text) {
        Ok(header) if header.is_current() => {
            ron::from_str::<Progress>(&text).map_or(0, |progress| progress.furthest)
        }
        _ => 0,
    }
}

/// Keeps `level` as the furthest reached, if it is further.
pub fn reached(storage: &mut dyn Storage, level: usize) -> Result<(), StorageError> {
    if level <= furthest(storage) {
        return Ok(());
    }
    let progress = Progress {
        version: Progress::VERSION,
        furthest: level,
    };
    let text = ron::to_string(&progress).map_err(|e| StorageError::Backend(e.to_string()))?;
    storage.write(KEY, &text)
}

Take this piece by piece.

Progress is what's kept. It derives Serialize and Deserialize, so serde can write it as text and read it back. SaveFormat gives it a name and a version number, which you raise whenever the type changes.

furthest reads it. Its job is to never fail: if anything's wrong, the answer is level 0.

Rust: storage: &dyn Storage means "a borrow of anything that implements the Storage trait". This function doesn't care whether that's a file or the browser. It's the same dyn as Box<dyn Screen>.

let Ok(Some(text)) = storage.read(KEY) else { return 0; }; is let-else: "this must match, or else do this". read returns a Result holding an Option: it could have failed, and if not, there might still be nothing saved. This handles both, in one line, and from here on text is the saved text.

header.is_current() is checked in a match guard, like you saw in the last step, and _ => 0 catches everything else: a version it doesn't know, or text it can't read. map_or(0, ...) says "use 0 if this failed, and otherwise get this from it".

Shuyi: a SaveHeader reads only the version of a save, even one whose other fields can't be read any more. So a game can tell "this is from another version" apart from "this is damaged".

reached writes it, only if it's further than what's there. It returns a Result, as writing can fail, for instance on a full disk. map_err turns one kind of error into another, so ron's error can be returned as a StorageError.

Using it

The menu now has more to remember: how many levels are open. In levels.rs, add a field to the struct, pub open: usize,, and start it from what's saved. new needs the context for that:

    /// The menu, on the furthest level the player has reached.
    pub fn new(ctx: &Ctx<Data>) -> Self {
        let last = ctx.game.own.levels.len().saturating_sub(1);
        let furthest = progress::furthest(&ctx.storage).min(last);
        Self {
            focus: furthest,
            open: furthest + 1,
        }
    }

That's why stages.rs changes LevelMenu::new() to LevelMenu::new(ctx), in both first and screens. Note how the menu is careful with what it reads: .min(last) means an old save can't point past the last level, if a later version has fewer.

Levels past it can't be chosen. It's one more call in the chain:

    /// The menu's items: a text key and a value each.
    pub fn menu(&self, ctx: &Ctx<Data>) -> Menu<Choice> {
        let levels = ctx.game.own.levels.len();
        let items = (0..levels).map(|n| {
            MenuItem::keyed("levels.level")
                .with_arg("n", n + 1)
                .with_enabled(n < self.open)
                .with_value(Choice::Level(n))
        });
        Menu::new(items.collect()).focused(self.focus)
    }

The engine's Menu does the rest: it draws them dim, and skips them when moving the focus. Prove it to yourself by opening every level:

In crates/game/src/levels.rs:

- .with_enabled(n < self.open)
+ .with_enabled(true)
Only Level 1 is bright.
Before
Every level is bright, and can be chosen.
After

Then, when a level is won, keep the next one. In uncovered, after self.focus is set:

            self.open = self.open.max(self.focus + 1);
            // A storage that fails loses only the player's progress.
            let _ = progress::reached(&mut ctx.storage, self.focus);

let _ = is a decision here, not laziness, as the comment says: if saving fails, the player loses only their progress, and that's no reason to stop their game. &mut ctx.storage is the changing kind of borrow, as writing changes it.

The test

The Harness has a storage of its own, in memory, which starts empty for every test:

#[test]
fn the_furthest_level_is_kept_and_opens_the_menu_there() {
    let mut h = on_board();
    assert_eq!(game::progress::furthest(&h.ctx().storage), 0);
    h.keys("Right Right Enter");
    assert_eq!(game::progress::furthest(&h.ctx().storage), 1);
    // The menu made next, as on the next run, opens there.
    let menu = LevelMenu::new(h.ctx());
    assert_eq!((menu.focus, menu.open), (1, 2));
}

It makes a brand new menu to stand in for the next time the game starts, without any disk at all. And since bad saves are the sort of thing players really do have, it's worth testing that those count as nothing:

#[test]
fn a_kept_progress_of_another_version_or_damaged_counts_as_none() {
    let mut h = game::game().harness();
    let storage = &mut h.ctx_mut().storage;
    storage
        .write(game::progress::KEY, "(version: 9, furthest: 3)")
        .unwrap();
    assert_eq!(game::progress::furthest(storage), 0);
    storage.write(game::progress::KEY, "(version").unwrap();
    assert_eq!(game::progress::furthest(storage), 0);
}

This needs use shuyi::ui::storage::Storage; at the top, as you have to name a trait to call its functions.

Now play this step in your browser from the button at the top of this page. Win a level, reload the page, and the menu remembers. That save is in the browser's storage, and your code didn't need to know.

What you learned

Chapter 7 has more on saves. The game is now whole. Next: the things that make it pleasant.