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

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 Program per scripting asset, and spin up a fresh Runner whenever 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