Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Save and Load

Saving a mid-conversation means saving two things:

  1. The runner’s internal state - current node, visit counts, which <<once>> blocks have fired.
  2. Your variable storage - all the $variables the dialogue has touched.

Runner::snapshot and Runner::restore work on a RunnerSnapshot without any feature flags. That covers in-memory bookmarks and anything you can clone yourself.

To write a snapshot or HashMapStorage to JSON, bincode, or your save format, enable the serde feature so those types derive Serialize / Deserialize:

[dependencies]
bubbles-dialogue = { version = "1.0.1", features = ["serde"] }

You still handle (2) however your game already handles saves. serde is only required when you persist to disk or over the network.

Snapshot the runner

let snapshot = runner.snapshot();
// With the serde feature:
let json = serde_json::to_string(&snapshot)?;
std::fs::write("save.json", json)?;

snapshot() always returns a RunnerSnapshot - a small, cheap copy containing:

  • current_node: Option<String> - the node the runner was in.
  • visits: HashMap<String, u32> - how many times each node has completed.
  • once_seen: HashSet<String> - fired <<once>> block ids.

That’s everything specific to the runner. With serde, serialize it alongside storage as part of your save file.

Save your variables separately

let storage_json = serde_json::to_string(runner.storage())?;
std::fs::write("vars.json", storage_json)?;

HashMapStorage already derives Serialize/Deserialize under the serde feature. If you’ve written your own storage, implement those traits yourself (or wrap your actual save format around the VariableStorage trait).

Saving at a choice screen

If the player saves while an Options event is showing, understand what Runner::snapshot captures:

  • current_node is set to the active node title.
  • Visit counts and <<once>> state are included.
  • The pending option list and RunnerPhase::AwaitingOption are not stored.

Runner::restore clears the runner and starts that node from the beginning. The player will see the node’s lines again before the choice reappears.

Practical patterns:

  • Save after the player picks an option, when the runner is back in Running or Done.
  • Split long scenes into smaller nodes so “replay from node start” feels natural.
  • Accept the replay if your UI can skip straight to the last Options event (track choice state in your own save data).

Restore

On the other side of a save/load, you’re recreating the runner from scratch and then pouring state back in.

use bubbles::{compile, HashMapStorage, Runner, RunnerSnapshot};

let program = compile(source)?;

let saved_storage: HashMapStorage = serde_json::from_str(&std::fs::read_to_string("vars.json")?)?;
let snapshot: RunnerSnapshot = serde_json::from_str(&std::fs::read_to_string("save.json")?)?;

let mut runner = Runner::new(program, saved_storage);
runner.restore(snapshot)?;

After restore, the runner is back at the start of the snapshotted node, with visit counts and <<once>> state intact.

Note: Restore resumes at the beginning of the saved node, not mid-line. For most games that’s perfect - scenes are natural save points. If you need finer-grained resumes, break long scenes into smaller nodes and snapshot more often.

Handling script updates

What if the dialogue script changed between saving and loading? A node got renamed, a variable got removed?

  • Unknown node → DialogueError::UnknownNode from restore. Catch it and start a safe fallback node ("MainMenu", "RecoveryScene").
  • Missing variables → just missing. Your VariableStorage::get returns None; the script’s <<declare>> reinitialises it. This is why you should almost always use <<declare>> for saved state: it survives script updates gracefully.
  • Stale <<once>> ids → harmless. Bubbles doesn’t re-fire once blocks it didn’t save for, but new ones will work normally.

The pattern is: try restore, fall back gracefully, lose as little as possible.

A complete save/load cycle

use bubbles::{compile, DialogueEvent, HashMapStorage, Runner};

fn save(runner: &Runner<HashMapStorage>) -> std::io::Result<()> {
    let snap = runner.snapshot();
    let bundle = SaveBundle {
        snapshot: snap,
        storage: runner.storage().clone(),
    };
    let json = serde_json::to_string(&bundle)?;
    std::fs::write("save.json", json)
}

fn load(program: bubbles::Program) -> std::io::Result<Runner<HashMapStorage>> {
    let json = std::fs::read_to_string("save.json")?;
    let bundle: SaveBundle = serde_json::from_str(&json)?;
    let mut runner = Runner::new(program, bundle.storage);
    runner.restore(bundle.snapshot).map_err(|e| {
        std::io::Error::new(std::io::ErrorKind::Other, e.to_string())
    })?;
    Ok(runner)
}

#[derive(serde::Serialize, serde::Deserialize)]
struct SaveBundle {
    snapshot: bubbles::RunnerSnapshot,
    storage: HashMapStorage,
}

One bundle, round-trips cleanly, easy to extend.

When not to use snapshots

If your game uses Bubbles for quick, self-contained conversations (think: short barks, UI flavour text), you probably don’t need snapshotting. The cost of serialising nothing is nothing - but the complexity of adding it might not be worth it.

A good signal: if a conversation can plausibly be interrupted by the player saving, snapshot it. If it runs to completion every time, don’t bother.


Next: Multi-file Projects