The Runner Lifecycle
The Runner is where compiled dialogue meets your game. Create one, point it at a node, pull events until it’s done. That’s the whole shape.
Create
use bubbles::{compile, HashMapStorage, Runner};
let program = compile(source)?;
let mut runner = Runner::new(program, HashMapStorage::new());
Two inputs:
- A compiled
Program. Build this once at load time; it’s cheap to clone if you need per-runner copies. - A
VariableStorage.HashMapStorage::new()is the batteries-included option. Your own implementation works too.
Tip: Keep one
Programper scripting asset, and spin up a freshRunnerwhenever you start a conversation. Runners are cheap; programs are the expensive thing you compiled once.
Configure (optional)
Before starting, you can swap defaults:
use bubbles::saliency::BestLeastRecentlyViewed;
use bubbles::HashMapProvider;
runner.set_saliency(BestLeastRecentlyViewed::new()); // variant picking
runner.set_provider(HashMapProvider::new()); // localisation
runner.library_mut().register("faction", |args| { /* … */ Ok(todo!()) });
These all return the runner (or &mut to it) so you can chain or call them one after the other.
Start
runner.start("Intro")?;
start validates the node exists and primes the runner. Calling it a second time resets execution - handy for “replay this scene” and for restoring from a snapshot.
An error here is almost always a typo in the node name. Bubbles tells you which one.
Pump events
while let Some(event) = runner.next_event()? {
match event {
DialogueEvent::Line { .. } => { /* render */ }
DialogueEvent::Options(opts) => {
runner.select_option(choose(opts))?;
}
DialogueEvent::Command { .. } => { /* dispatch */ }
_ => {}
}
}
next_event returns None when dialogue completes. Until then, it either:
- Returns the next event, or
- Returns an error (runtime type mismatch, bad option index, etc).
If you call next_event again after an Options event without first calling select_option, you get DialogueError::ProtocolViolation. Bubbles won’t guess which option you wanted.
Completion
You’ll see DialogueEvent::DialogueComplete on the last meaningful step, and then None on the next call. Both are fine to treat as “we’re done” - pick whichever fits your loop.
match runner.next_event()? {
Some(DialogueEvent::DialogueComplete) | None => break,
Some(other) => handle(other),
}
Inspecting state mid-run
Mid-run, you can read (and write) state directly:
let storage = runner.storage(); // &S
let storage_mut = runner.storage_mut(); // &mut S
Same for the function library:
runner.library_mut().register("reroll", |_| Ok(/* … */));
This is useful for late-binding custom functions (e.g. when a menu unlocks new capabilities) or for sneaking a variable in from outside:
use bubbles::{Value, VariableStorage};
runner.storage_mut().set("$player_name", Value::Text(player.name.clone()));
For debug HUDs, cheats, or tests you can also read through the runner without touching storage generics:
let _all = runner.all_variables(); // Vec<(String, Value)>; empty unless storage overrides `all_variables`
let _one = runner.variable("$gold");
let _borrowed = runner.variable_ref("$name"); // Cow<Value>, avoids cloning strings when using HashMapStorage
all_variables is implemented for HashMapStorage and lists every key. Custom VariableStorage types get a default empty list unless you override VariableStorage::all_variables.
Threading
Runner is Send when its storage is - that’s true for HashMapStorage. Run dialogue on any thread you like; just don’t share a single runner across threads without synchronisation. The pull-based API is designed to slot into whatever update scheme your engine uses (single thread, job system, task pool).
Next: Handling Events