Save and Load
Saving a mid-conversation means saving two things:
- The runner’s internal state - current node, visit counts, which
<<once>>blocks have fired. - Your variable storage - all the
$variablesthe 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_nodeis set to the active node title.- Visit counts and
<<once>>state are included. - The pending option list and
RunnerPhase::AwaitingOptionare 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
RunningorDone. - 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
Optionsevent (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::UnknownNodefromrestore. Catch it and start a safe fallback node ("MainMenu","RecoveryScene"). - Missing variables → just missing. Your
VariableStorage::getreturnsNone; 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