The book · Chapter 7

Saves and settings

A player closes the game and comes back: their garden should be as they left it, and the music as quiet as they set it. That means keeping something after the program stops.

On a computer, you'd write a file. But a browser has no files for you to write, a console has its own system, and a test shouldn't touch your disk at all. So a game never opens a file. Its saves and the player's settings go through storage, which the platform hands it: files on desktop, the browser's localStorage on the web, a console's own save files, and memory in tests. The same code saves on every target, and a test needs no disk.

Storage

Storage keeps small named texts: read, write, delete and list, under keys of lowercase letters, digits, _ and -, at most 64 long. A screen reaches it through ctx.storage.

Storage is a trait, a list of functions, and each platform has a type that keeps that promise in its own way.

Profiles

ctx.storage is the storage of the profile in use. A profile holds one player's settings, keys and saves, so the people who share a computer each keep their own, on the engine's profiles screen (shuyi::ui::screens::profiles). Each key keeps the name it would have without profiles: the game writes garden, and the profile keeps it apart from another player's.

A save

A game changes between versions. What should a new build do with a save from an old one, whose shape has changed? Reading it anyway can give nonsense without any error. So a save names the version of its format. A build reads only saves of its own version, and refuses another's with a message that says so, rather than reading it wrong:

/// A save: the version of its format, and the garden.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Save {
    /// The version of the format that wrote it.
    pub version: u32,
    /// The game in progress.
    pub garden: Garden,
}

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

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

/// Why a save didn't load.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LoadError {
    /// The storage failed.
    Storage(StorageError),
    /// Another version of the format wrote it.
    Version(u32),
    /// It doesn't read.
    Damaged(String),
}

/// Writes the garden under [`SAVE_KEY`].
pub fn save(storage: &mut dyn Storage, garden: &Garden) -> Result<(), StorageError> {
    let save = Save {
        version: Save::VERSION,
        garden: garden.clone(),
    };
    let text = ron::to_string(&save).map_err(|e| StorageError::Backend(e.to_string()))?;
    storage.write(SAVE_KEY, &text)
}

/// The saved garden, if there is one this build can read.
pub fn load(storage: &dyn Storage) -> Result<Option<Garden>, LoadError> {
    let Some(text) = storage.read(SAVE_KEY).map_err(LoadError::Storage)? else {
        return Ok(None);
    };
    // The version alone first, so another version is told from damage.
    let header: SaveHeader<Save> =
        ron::from_str(&text).map_err(|e| LoadError::Damaged(e.to_string()))?;
    if !header.is_current() {
        return Err(LoadError::Version(header.version));
    }
    let save: Save = ron::from_str(&text).map_err(|e| LoadError::Damaged(e.to_string()))?;
    Ok(Some(save.garden))
}

Rust: there's a lot of error handling here, since saving is where things go wrong for real. A disk can be full.

storage: &mut dyn Storage means "lend me some type with the Storage trait, which I may change". So this same function works with whatever the platform has. save gives back Result<(), StorageError>: fine, or what went wrong. load gives back Result<Option<Garden>, LoadError>, three answers in one: a garden, or Ok(None) for no save yet, which is no problem at all on a first run, or what went wrong.

garden.clone() makes a copy, because garden is only borrowed (&Garden) and the Save needs to own its own. ron::to_string writes the Save as text, using what Serialize derived, and .map_err(...) turns its kind of error into this function's kind. The ? then hands an error back at once.

let Some(text) = ... else { return Ok(None); }; says: this must be a Some, and name what's inside text. Or else, do this.

SaveHeader reads the version alone from any save, even one whose other fields no longer parse, so the refusal can say "another version" rather than "damaged". Raise VERSION whenever a saved type changes shape or meaning. What a save holds, and when the game writes it, are the game's: a turn game's rules state is plain data (chapter 6), so saving it is one line.

    #[test]
    fn a_save_comes_back_from_the_players_storage() {
        let mut ctx = game().ctx().unwrap();
        let garden = Garden::new(&Plan::new(6, 9, 75), 3);
        save(&mut ctx.storage, &garden).unwrap();
        assert_eq!(load(&ctx.storage), Ok(Some(garden)));
    }

A record or a save may also say what made it, with shuyi_core::Made: the engine's version, the game's word for its build, and a fingerprint of the files it ships. made() in a new game's lib.rs makes it.

Settings

The player's settings are the engine's (text speed, full screen, the color theme, the music and sound volumes, whether voices play and how loud, the frame cap, the layout of keys) and the game's own. ctx.settings() reads them, and ctx.change_settings changes them and saves them in the profile:

    #[test]
    fn a_changed_setting_is_kept_in_the_players_profile() {
        let mut ctx = game().ctx().unwrap();
        ctx.change_settings(|settings| settings.music_volume = 40)
            .unwrap();
        assert_eq!(ctx.settings().music_volume, 40);
        assert!(ctx.storage.read(SETTINGS_KEY).unwrap().is_some());
    }

Rust: |settings| settings.music_volume = 40 is a small nameless function: change_settings hands it the settings to change, and then saves them. So you can't forget the save. .unwrap() stops the test if the save failed.

ctx.preview_settings changes them without saving, for an options screen that shows a change before the player keeps it. The engine's options screen (shuyi::ui::screens::options) takes the game's rows, and the key bindings are saved apart, by the Key bindings screen. A game with settings of its own writes a GameSettings type and its own GameData (chapter 2).

What the game does while its window has no focus is the game's choice, not the player's: a constant of its GameSettings. By default it runs on with its sound. A game that pauses, or goes quiet, or both, says so in one line of its impl GameSettings: const BACKGROUND: Background = Background::KEEP_RUNNING.paused().muted();. Paused, the game isn't updated and shows its last frame until the player comes back, and the time away makes no steps. Muted, its music keeps its place in silence. Background is in shuyi::ui::settings.

When it goes wrong

Forgetting the copy. Write garden, in place of garden: garden.clone(),, and the compiler says what's wrong, and how to fix it:

error[E0308]: mismatched types
46 |         garden,
   |         ^^^^^^ expected `Garden`, found `&Garden`
help: consider using clone here
46 |         garden: garden.clone(),

Forgetting to raise VERSION. No tool can catch this one. Add a field to Garden without raising it, and an old save has the right version but doesn't parse, so load says Damaged to a player whose save was fine. Whenever a saved type changes, raise it.

A bad key. A storage key takes only lowercase letters, digits, _ and -, at most 64 long. storage.write answers with an error for any other. So a player's name is no key: name your keys in code, and keep the name inside the save.

Two games with one id. The player's saves are kept under the game's id (chapter 9), so two games left on the placeholder com.example.… id share them. cargo shuyi check --target warns of it.

On the web. A player who clears their browser's site data loses their saves, as localStorage belongs to the browser. So on the web, as anywhere, load must handle Ok(None).