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

Introduction

Welcome to Bubbles - a small, friendly dialogue runtime for Rust games.

You write branching dialogue in .bub scripts. Bubbles compiles them once at startup and hands you a simple loop: ask for the next event, show it on screen, select an option, repeat. That is the whole API.

while let Some(event) = runner.next_event()? {
    // draw, wait, choose, continue
}

No async. No global state. No engine lock-in. Bubbles runs wherever Rust runs - Bevy, Godot, Macroquad, a custom engine, the web via WebAssembly, even a terminal. Unity and other native hosts can use the bubbles-ffi C library (JSON events, P/Invoke); see Unity and native hosts (C ABI).

What Bubbles gives you

  • A tiny text format for nodes, lines, options, and branching
  • Typed variables (Number, Text, Bool) with a real expression language
  • Jumps, detours, conditionals, <<once>> blocks, interpolation, host commands
  • Inline markup ([b]text[/b], [wave]text[/wave], [pause /]) stripped to byte-precise spans your renderer consumes
  • Line groups and node groups for variety (no more hearing the same bark twice)
  • A pluggable localisation seam, custom functions, and custom saliency strategies
  • Save/load via serde snapshots
  • An allocation-conscious runtime with zero async primitives

Who Bubbles is for

You want dialogue in a Rust game. You do not want a 30 MB runtime, a scripting VM, or a DSL that fights with your borrow checker. You want something you can read the source of in an afternoon and drop into a release build without a second thought.

If that sounds like you, you are in the right place.

A taste of .bub

title: Tavern
---
Barkeep: Evening, stranger.
-> A mug of ale <<if $gold >= 5>>
    <<set $gold = $gold - 5>>
    Barkeep: Here you are.
-> Ask about rumours
    <<jump Rumours>>
-> Nothing, just passing through.
    Barkeep: Safe travels, then.
===

That is a complete, working dialogue. A speaker line, three options (one guarded by a condition), a variable assignment, and a jump. The harbour walkthrough uses the same building blocks - speaker lines, guarded options, and jumps - to build a full dockside scene from scratch in The Harbour.

Jump right in

Want to skip the setup and play first?

cargo run -p bubbles-tui -- examples/harbour/harbour.bub examples/harbour/services.bub

That’s the harbour example: a playable pirate dockside scene that covers most of the language in one go. Come back here when you’re done.

How to read this guide

The chapters are meant to be read in order, but each one stands on its own:

Ready? Let’s write a dialogue.


Next: Your First Dialogue

Your First Dialogue

Let’s get something talking. We will go from an empty project to a narrator greeting the player, in about ten lines of Rust.

Install Bubbles

In a fresh Rust project, add Bubbles to your Cargo.toml:

cargo add bubbles-dialogue@0.8.0

Or by hand:

[dependencies]
bubbles-dialogue = "0.8.0"

The crates.io package key is bubbles-dialogue. In Rust you still use bubbles::… everywhere; only the dependency name in Cargo.toml is hyphenated.

Requires Rust 1.94 or later. Bubbles targets a recent stable toolchain so it can lean on modern features without unsafe.

Write a dialogue

Paste this into src/main.rs:

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

fn main() -> Result<(), bubbles::DialogueError> {
    let program = compile(r#"
        title: Start
        ---
        Narrator: Hi. I'm the narrator.
        ===
    "#)?;

    let mut runner = Runner::new(program, HashMapStorage::new());
    runner.start("Start")?;

    while let Some(event) = runner.next_event()? {
        if let DialogueEvent::Line { speaker, text, .. } = event {
            println!("{}: {text}", speaker.as_deref().unwrap_or("*"));
        }
    }

    Ok(())
}

Run it:

cargo run

You should see:

Narrator: Hi. I'm the narrator.

That is a full Bubbles loop. Take a second to appreciate how little is happening.

What just happened?

Three moving parts:

  1. The script. A .bub node called Start, with one line of narration. Every node starts with title:, a --- header separator, the body, and a closing ===.
  2. The compile step. compile parses and validates the source, returning a Program. Do this once at load time.
  3. The runner. Runner::new takes the Program and a variable storage backend. HashMapStorage is the default in-memory one. We call start with the node name, then pull events out with next_event until it hands back None.

Add another node

Let’s make the narrator actually go somewhere. Update the script:

title: Start
---
Narrator: Hi. I'm the narrator.
<<jump Adventure>>
===

title: Adventure
---
Narrator: Let's go on an adventure.
===

Run it again. Now you’ll see both lines.

Narrator: Hi. I'm the narrator.
Narrator: Let's go on an adventure.

<<jump Adventure>> is a command - anything between << and >> is a built-in directive. We’ll meet more of them throughout the guide.

Tip: You do not need newlines between nodes, but they help readability. Bubbles happily parses them smushed together.

Give the player a choice

Dialogue without branching is just narration. Let’s add options:

title: Adventure
---
Narrator: Where do you go?
-> The forest.
    Narrator: You hear wolves.
-> The mountain.
    Narrator: The air is thin, but the view is worth it.
===

Running it now prints the prompt, then… nothing happens. Bubbles is politely waiting for you to pick an option. Our Rust loop only handles Line events - we ignore the Options event entirely.

Fixing that is the next chapter.


Next: The Event Loop

The Event Loop

Bubbles is pull-based. You call next_event(), you get back one DialogueEvent, you handle it, you call again. That’s the entire integration surface.

Here are the events you’ll actually handle in a game:

EventWhenWhat to do
LineA speaker has something to sayRender it; wait for the player to advance
OptionsBranching choiceShow the choices; call select_option(i)
CommandA host directive like <<play_sound bell>>Dispatch to your engine
NodeStarted / NodeCompleteA node begins or endsAnalytics, transitions, scene fades
DialogueCompleteThe whole conversation is doneClean up, return to the game

Handling options

Let’s finish the dialogue from the previous chapter. We need to handle the Options event and tell the runner which one we picked.

use bubbles::{compile, DialogueEvent, HashMapStorage, Runner};
use std::io::{self, BufRead};

fn main() -> Result<(), bubbles::DialogueError> {
    let program = compile(r#"
        title: Adventure
        ---
        Narrator: Where do you go?
        -> The forest.
            Narrator: You hear wolves.
        -> The mountain.
            Narrator: The view is worth it.
        ===
    "#)?;

    let mut runner = Runner::new(program, HashMapStorage::new());
    runner.start("Adventure")?;

    let stdin = io::stdin();
    while let Some(event) = runner.next_event()? {
        match event {
            DialogueEvent::Line { speaker, text, .. } => {
                println!("{}: {text}", speaker.as_deref().unwrap_or("*"));
            }
            DialogueEvent::Options(opts) => {
                for (i, opt) in opts.iter().enumerate() {
                    println!("  {i}) {}", opt.text);
                }
                let line = stdin.lock().lines().next().unwrap().unwrap();
                let choice: usize = line.trim().parse().unwrap_or(0);
                runner.select_option(choice)?;
            }
            _ => {}
        }
    }

    Ok(())
}

That’s it. The runner pauses on Options until you call select_option. Miss the call and next_event will keep returning the same Options event - Bubbles never moves forward without your say-so.

The full event match

In a real game you probably want to handle every event. Here is the complete set with friendly comments:

while let Some(event) = runner.next_event()? {
    match event {
        DialogueEvent::NodeStarted(name) => {
            // good place for a scene transition or analytics ping
        }
        DialogueEvent::Line { speaker, text, spans, tags, line_id } => {
            // render and wait for input
            // spans: Vec<MarkupSpan> - byte ranges for [b], [color …], etc.
        }
        DialogueEvent::Options(opts) => {
            // show a menu; each opt has .text, .available, .spans, .tags, .line_id
            runner.select_option(chosen)?;
        }
        DialogueEvent::Command { name, args, tags } => {
            // <<play_sound bell>> arrives here
            match name.as_str() {
                "play_sound" => play_sound(&args[0]),
                _ => {}
            }
        }
        DialogueEvent::NodeComplete(name) => {
            // node finished - save, fade, whatever
        }
        DialogueEvent::DialogueComplete => {
            // all done
        }
        _ => {} // DialogueEvent is #[non_exhaustive]
    }
}

Note: DialogueEvent is marked #[non_exhaustive]. Always include a _ => arm to stay forward-compatible when new event kinds land.

Why pull-based?

Games run on frames. Dialogue runs on beats. A push-based callback system would either block your frame or force you to spawn threads. Pull-based means:

  • You poll the runner when you’re ready - usually when the player presses a key.
  • The runner never allocates on its own. The event you get back owns its data; the runner moves on.
  • Saving and loading is trivial: snapshot the runner, come back next Tuesday.

This is also why you won’t find async anywhere. Bubbles is meant to be called from a fixed-timestep update() with no tears.

A quick self-check

Before moving on, make sure you can answer these:

  • Where does DialogueEvent come from? (Your call to runner.next_event().)
  • What stops the loop? (Returning None, usually paired with DialogueEvent::DialogueComplete on the step before.)
  • What do you do with an Options event? (Show the choices, then call select_option(i).)

Good. Now let’s zoom into the .bub format itself.


Next: Using the TUI

Using the TUI

Before diving into the language guide, set up a playground. Trust me, it’s worth it.

The bubbles-tui crate is a terminal dialogue player built for iterating on .bub scripts. Load any file, play through it, edit it, and reload it in place. It runs the same Runner your game would use, so what you see is exactly what a real integration would receive.

Open a second terminal alongside this guide. You’ll want them side by side.

Run the harbour

The harbour is the main example: a two-file pirate dockside scene that covers most of the language in one playable go. Start there:

cargo run -p bubbles-tui -- examples/harbour/harbour.bub examples/harbour/services.bub

You arrive at Barnacle Bay and meet Stumpy McGee, the cantankerous harbormaster. He wants a permit fee. Try each option, run out of gold, find the map seller around the corner. Come back to this guide when you’re done.

What you’re looking at

Transcript (the main panel) logs every event the runner emits: node markers ([→ Name] / [← Name]), speaker lines, commands (⚙ play_sfx clink), and the option you chose each time. Scroll it to see the full history. The title bar shows the total event count and how far you’ve scrolled back.

Options appears at the bottom whenever a choice is active. Tab swaps focus between the options list and the transcript, so you can scroll back through history while a choice is waiting.

Error overlay pops up on any parse or runtime error. It shows the file, line number, and a snippet of the offending source. Press x to dismiss, or r to reload and try again.

Keys

KeyWhat it does
Enter / SpaceAdvance; confirm the focused option
/ k, / jNavigate options or scroll the transcript
19Pick an option directly by number
TabSwap focus between the options list and transcript
b / BackspaceStep back one event
rReload: re-read files from disk, reset everything
R (Shift+r)Rerun: restart from the beginning, keeping variables and <<once>> history
q / EscQuit

r vs R

These two are the most important keys to know:

r (reload) picks up every change you’ve made to the .bub files on disk. Use it after editing a script. Variables reset, <<once>> history resets, everything starts fresh.

R (rerun) restarts from the beginning of the dialogue but keeps the current variable values and <<once>> seen history. Use it to reach lines that only appear on a second visit - the <<else>> branch of a <<once>> block, a node that only triggers after buying something, a counter that changes behaviour on the fifth loop.

The workflow: write a scene, press r to reload, play through it, press R to get the second-visit state, press r to pick up new edits.

Run your own scripts

# single file
cargo run -p bubbles-tui -- my-scene.bub

# multiple files compiled together (cross-file jumps and detours work)
cargo run -p bubbles-tui -- main.bub services.bub characters.bub

# explicit start node (defaults to "Start" if omitted)
cargo run -p bubbles-tui -- my-scene.bub TownSquare

Using it while reading the guide

Each language chapter has a Try it block with the snippet command to run. Keep the TUI handy, run the snippet, poke at the script, then come back. The guide covers the mechanics; the TUI shows you exactly what lands in your event loop.


Next: What We’re Building

What We’re Building

This tutorial builds a real game dialogue from scratch in four sessions. Each one adds a layer, explains why it works, and links to the full language reference when there’s more to know.

By the end you’ll have Mira, a potion seller who remembers your first visit, locks her prices when you’re broke, plays ambient sounds via commands, and never repeats the same harbour noise twice.

Here’s the complete mira.bub you’ll end up with:

title: Start
---
<<declare $gold = 8>>

=> Seagull: Squawk!
=> A cart rumbles past with barrels of fish.
=> The salt air stings your eyes.

<<once>>
    Mira: Oh! A new customer. Fresh stock just arrived, as luck would have it.
<<else>>
    Mira: Back again? The price hasn't changed.
<<endonce>>

Mira: Five gold for a health potion. Best deal in the harbour district.
-> Buy a health potion (5 gold) <<if $gold >= 5>>
    <<set $gold = $gold - 5>>
    <<play_sfx "clink">>
    Mira: Pleasure doing business. You've got {$gold} gold left.
-> I can't afford that right now. <<if $gold < 5>>
    Mira: Come back when your pockets are heavier.
-> Just looking.
    Mira: ...You're still just looking?
===

About 30 lines. Playable in under a minute. Every feature fits in a single node, so you can see the whole script without scrolling.

What you’ll need

  • A Rust project with the bubbles-dialogue dependency in Cargo.toml and use bubbles::… in code (see Your First Dialogue if you haven’t done this yet)
  • The TUI runner open in a second terminal (see Using the TUI)
  • A file called mira.bub somewhere you can edit and reload

Create the file now. You’ll paste into it as each session adds features.


Next: Your First NPC

Your First NPC

Let’s get Mira talking. Paste this into mira.bub:

title: Start
---
Mira: Hello, traveller. Potions? I've got potions.
Mira: Best prices in the harbour district. Probably.
-> How much for a health potion?
    Mira: Five gold. Non-negotiable.
-> Just looking.
    Mira: You've been just looking for ten minutes.
===

Run it:

cargo run -p bubbles-tui -- mira.bub

Pick an option. Notice what happens: both branches lead to a line from Mira, then the dialogue ends. Not much of a sales pitch yet, but it works.

What’s in here

The node. Everything between title: Start, the --- separator, and the closing === is one node. Nodes are the chunks your game jumps between. This one is called Start, which is the default entry point.

Speaker lines. Mira: ... is a speaker line. The name before the colon becomes the speaker field in DialogueEvent::Line. Lines with no name are narrator lines - speaker arrives as None.

Options. Lines starting with -> are choices. When the runner hits a set of them, it emits DialogueEvent::Options(...) and waits. Your game shows the choices, the player picks one, you call runner.select_option(i), and execution continues into that option’s indented body.

DialogueEvent::Options(opts) => {
    // opts[0].text = "How much for a health potion?"
    // opts[1].text = "Just looking."
    runner.select_option(player_choice)?;
}

Fall-through. After both option bodies finish, there’s nothing else in the node - so the dialogue ends. That’s fine for now.

Try it

Change Mira’s greeting to something grumpier. Press r in the TUI to reload. The change shows up immediately.

Add a third option. Reload again. The new choice appears in the options list.

Try putting some lines after the options block (outside the indented bodies, back at zero indent). Those lines run after whichever option the player picks.

===          <- add something before this
===

Something like:

Mira: Anyway. Come back if you change your mind.
===

That line runs no matter which option was chosen. Handy for “and then we get back to business” beats.

Where this is going

The dialogue works, but Mira doesn’t care whether you can actually pay. Let’s add gold.


Next: Add State

Add State

Right now Mira will sell to anyone, even a broke traveller with zero gold. Let’s fix that.

Update mira.bub:

title: Start
---
<<declare $gold = 8>>

Mira: Hello, traveller. Potions? I've got potions.
Mira: Five gold each. Take it or leave it.
-> Buy a health potion (5 gold) <<if $gold >= 5>>
    <<set $gold = $gold - 5>>
    Mira: Pleasure doing business. You've got {$gold} gold left.
-> I can't afford that right now. <<if $gold < 5>>
    Mira: Come back when your pockets are heavier.
-> Just looking.
    Mira: ...Still just looking?
===

Reload with r. Try buying. Try changing the <<declare $gold = 8>> to <<declare $gold = 3>>, reload, and notice the buy option becomes locked.

What’s new

<<declare>> registers the variable with its type and starting value. Bubbles infers the type from the literal: 8 is a number, "hello" is text, true is a bool. It only sets the value on the first run - subsequent runs (and save/load cycles) leave the existing value alone.

<<declare $gold = 8>>   <- first run: $gold = 8
                        <- second run: $gold unchanged (already exists)

Prefer <<declare>> for anything that should persist between sessions. See Variables for the full picture.

<<set>> assigns a new value. The right side is a full expression, so arithmetic works:

<<set $gold = $gold - 5>>

Option guards. <<if $gold >= 5>> after an option text is a guard. When the guard is false, the option stays visible in the list but available: false in the event - your UI can grey it out or hide it. Bubbles won’t let the player select a locked option.

DialogueEvent::Options(opts) => {
    for opt in &opts {
        // opt.available tells you whether to render it as selectable
    }
}

{$gold} interpolation. Variables (and any expression) inside {...} get substituted into the line text before the event reaches you. By the time your game sees "You've got {$gold} gold left.", it already reads "You've got 3 gold left." No parsing on your end.

Self-check

Before moving on:

  • What happens if you set $gold = 5 and buy once? What does $gold become?
  • What’s the difference between a guard (<<if>> on an option) and a conditional block (<<if>> in the body)?
  • Why use <<declare>> instead of just <<set>>?

(Answers: 0; guard = option is shown but locked, conditional = lines only run at all when true; <<declare>> preserves values across save/load and is a no-op on revisit.)

Where this is going

State is working. Now let’s make the scene feel like a real place - ambient sounds, a first-visit greeting that doesn’t repeat, and a command to let your engine play a sound effect.


Next: Add Polish

Add Polish

Two problems with Mira so far: she says the same opening line every time, and the scene feels silent and static. Let’s fix both, and add a sound cue so your engine can react to the sale.

Update mira.bub to the complete version:

title: Start
---
<<declare $gold = 8>>

=> Seagull: Squawk!
=> A cart rumbles past with barrels of fish.
=> The salt air stings your eyes.

<<once>>
    Mira: Oh! A new customer. Fresh stock just arrived, as luck would have it.
<<else>>
    Mira: Back again? The price hasn't changed.
<<endonce>>

Mira: Five gold for a health potion. Best deal in the harbour district.
-> Buy a health potion (5 gold) <<if $gold >= 5>>
    <<set $gold = $gold - 5>>
    <<play_sfx "clink">>
    Mira: Pleasure doing business. You've got {$gold} gold left.
-> I can't afford that right now. <<if $gold < 5>>
    Mira: Come back when your pockets are heavier.
-> Just looking.
    Mira: ...You're still just looking?
===

Reload with r. Play through. Then press R (rerun) - Mira now gives her second-visit greeting. Play through again. The ambient line at the top changes each time.

<<once>>

<<once>> runs the first branch exactly once. Every visit after fires the <<else>> branch. No variable needed.

<<once>>
    Mira: Oh! A new customer. Fresh stock just arrived.
<<else>>
    Mira: Back again? The price hasn't changed.
<<endonce>>

The “once-seen” state is saved with the runner and survives save/load. Press r (full reload) to reset it, R (rerun) to keep it.

You can also make a block that runs once and goes completely silent after:

<<once>>
    Narrator: Something shifts in the air as you approach.
<<endonce>>

See Once Blocks for the full forms, including <<once if condition>>.

Line groups (=>)

The three => lines at the top are a line group. Every time the runner reaches them, it picks one. With the default strategy, it picks whichever one you’ve seen least recently - so you never hear the same ambient line twice in a row.

=> Seagull: Squawk!
=> A cart rumbles past with barrels of fish.
=> The salt air stings your eyes.

Your game receives a normal DialogueEvent::Line for whichever one got picked. It doesn’t know there were three variants - it just gets the chosen one.

Tell the runner which strategy to use. BestLeastRecentlyViewed is the right pick for ambient barks:

use bubbles::saliency::BestLeastRecentlyViewed;

runner.set_saliency(BestLeastRecentlyViewed::new());

See Line Groups for guarded variants and mixing in tags.

Commands: talking to your engine

<<play_sfx "clink">> is a command. Bubbles doesn’t know what it means - it just emits a DialogueEvent::Command with the name and arguments, and your game handles it.

DialogueEvent::Command { name, args, .. } => {
    match name.as_str() {
        "play_sfx" => audio.play_one_shot(&args[0]),
        _ => {}
    }
}

Commands are the bridge between your dialogue and your engine. Some common patterns:

Audio cues:

<<play_sfx "door_creak">>
<<play_music "tavern_night">>
<<stop_music>>

Voice-over by line id. Tag a line with #line:some_id and use line_id in the event to look up the VO clip:

Mira: Fresh stock just arrived. #line:mira_greet_first
DialogueEvent::Line { line_id, .. } => {
    if let Some(id) = &line_id {
        audio.play_voice(id); // "mira_greet_first"
    }
}

Per-line metadata with tags. Tags travel with every Line event and are yours to interpret:

Mira: Five gold each. #portrait_mood angry #subtitle_style bold
DialogueEvent::Line { tags, .. } => {
    for tag in &tags {
        if let Some(mood) = tag.strip_prefix("portrait_mood ") {
            ui.set_portrait_mood(mood); // "angry"
        }
    }
}

Animations, VFX, game state:

<<trigger_cutscene "mira_winks">>
<<give_item "health_potion">>
<<set_camera_target "mira">>

The rule: if your dialogue needs a value back (a reputation check, an inventory count), use a custom function. If it’s a side effect (play a sound, trigger an animation, give an item), use a command. See Commands and Custom Functions for the full story on each.

What’s next

That’s the tutorial. You’ve got a working NPC with state, variety, first-visit behaviour, and engine signals. The language reference chapters cover everything in depth - head there for the full forms of anything you want to extend.


Next: Nodes and Lines - the full language reference starts here

Nodes and Lines

Every .bub script is made of nodes. A node is a named scene - a chunk of dialogue your game can jump into. Lines live inside nodes.

Here is the smallest possible node:

title: Start
---
Hello.
===

That’s a valid program. It has one node named Start, containing one narrator line.

Anatomy of a node

title: Start        <- header: must start with title
tags: intro safe    <- optional: space-separated tags
---                 <- header/body separator
Hello, traveller.   <- body: lines, commands, options, etc.
===                 <- node terminator

Three rules to remember:

  1. Every node starts with title: SomeName.
  2. --- separates the header from the body.
  3. === ends the node.

Node titles must start with a letter and can only contain letters, numbers, and underscores.

Tip: Multiple nodes can live in the same file. Bubbles doesn’t care where node boundaries sit, only that each one has its header, body, and terminator.

Lines

A line is any non-empty body text that isn’t an option, command, or header. There are two flavours:

Aria: Evening, friend.      <- speaker line
The wind howls outside.     <- narrator line (no speaker)

A speaker line starts with Name:. The name is passed through to you in the DialogueEvent::Line event:

DialogueEvent::Line { speaker, text, .. } => {
    // speaker = Some("Aria"), text = "Evening, friend."
}

A narrator line has no colon-prefix and speaker comes through as None. Use whichever fits the moment - Bubbles doesn’t judge.

Note: The speaker is everything before the first colon on the line. If your dialogue needs literal colons in the middle, put them after the first one and you’re fine: Aria: Tip: always carry salt.

A richer example

Let’s write a short scene with three nodes. Don’t worry about the <<jump>> yet - we’ll cover it soon.

title: CampfireIntro
---
Aria: You made it.
Aria: Sit down. It's cold tonight.
<<jump CampfireTalk>>
===

title: CampfireTalk
---
Aria: So. What brings you out this far?
The fire crackles while she waits.
<<jump CampfireEnd>>
===

title: CampfireEnd
---
Aria: Well. Rest up. We ride at dawn.
===

Three nodes, six lines, one scene. Bubbles will walk them in order because each one jumps to the next.

Blank lines, comments, and whitespace

Blank lines inside a node are ignored. Leading indentation only matters for options (coming up next) - everywhere else it’s cosmetic.

Bubbles doesn’t have line-level comments. For scene notes, add tags to the node header (see Tags and Metadata) or use a narrator convention like Narrator: [TODO: rewrite this].

A preview of what’s next

Here’s a node using features from later chapters. Don’t panic - each piece gets its own page.

title: Crossroads
tags: outdoor day
---
Aria: Which path?
-> The forest.
    Aria: Wolves live there. You keep walking anyway.
    <<jump Forest>>
-> The mountain.
    Aria: Cold, but the view is worth it.
    <<jump Mountain>>
-> Wait here a moment.
    Aria: Take your time.
===

Options, indented bodies, jumps - we’ll unpack them one by one.


Next: Options

Options

A node that just prints lines is a monologue. A node with options is a conversation.

title: Crossroads
---
Aria: Which way do you go?
-> The forest path.
    Aria: You hear wolves. You press on anyway.
-> The mountain pass.
    Aria: Cold, but clear. You can see for miles.
===

Every line starting with -> is an option. When the runner hits a set of options, it pauses and emits a DialogueEvent::Options(…). Your game shows the choices, the player picks one, you call runner.select_option(index), and execution continues into that option’s body.

Indentation matters

The body of an option is everything indented beneath it:

-> The forest path.
    Aria: You hear wolves.     <- runs only if this option is chosen
    Aria: You keep walking.    <- also inside the forest branch
-> The mountain pass.
    Aria: Cold, but clear.     <- runs only if the mountain option is chosen

Use spaces or tabs - just be consistent inside a block. After the options end, execution continues with whatever comes next in the node:

-> Yes.
    Aria: Good.
-> No.
    Aria: Fine.
Aria: Either way, we leave at dawn.   <- runs after either branch

Guards

Options can have conditions - so the “buy a sword” choice only shows up if you can afford it.

Merchant: What'll it be?
-> A fine sword (10g) <<if $gold >= 10>>
    Merchant: Sharp as a winter morning.
-> A loaf of bread (1g) <<if $gold >= 1>>
    Merchant: Still warm.
-> Nothing for now.
    Merchant: Come back soon.

The <<if>> after the option text is a guard. When the guard evaluates to false, the option is still presented - but marked available: false in the event:

DialogueEvent::Options(opts) => {
    for opt in &opts {
        if opt.available {
            println!("  [ ] {}", opt.text);
        } else {
            println!("  [x] {} (locked)", opt.text);
        }
    }
}

Whether you render locked options greyed-out, hidden, or with a tooltip is your call. Bubbles gives you the available field and steps aside.

Tip: If the player tries to select_option on an unavailable entry, Bubbles returns a runtime error. Filter or disable those choices in your UI.

Options that don’t jump

You don’t have to <<jump>> out of an option. If the body just runs and ends, execution falls through to whatever comes after the options block:

Aria: Ready?
-> Yes.
-> Actually, wait.
    Aria: Take a breath.
Aria: Alright, let's go.   <- always runs, after either branch

Both options land on the final line. That’s a handy pattern for “gate” choices where you want a beat before continuing.

A shopping example

Let’s make something practical - a little shop with a running gold total. This uses variables (next chapter!) but the shape is all options.

title: Shop
---
<<declare $gold = 20>>
Merchant: Welcome. What would you like?
-> Sword, 15 gold <<if $gold >= 15>>
    <<set $gold = $gold - 15>>
    Merchant: A fine blade.
-> Torch, 3 gold <<if $gold >= 3>>
    <<set $gold = $gold - 3>>
    Merchant: Mind the sparks.
-> Just browsing.
    Merchant: Suit yourself.
Merchant: Anything else? You have {$gold} gold left.
===

Three options, each guarded by gold, each doing its own thing. The final line runs regardless - and uses {$gold} interpolation to show the updated balance.


Try it: examples/snippets/options.bub: an insult sword-fight duel showing guarded options and an <<elseif>> skill chain.

cargo run -p bubbles-tui -- examples/snippets/options.bub

Next: Tags and Metadata

Tags and Metadata

Your game needs to know more than just what a line says. It needs to know how to say it: which portrait to show, whether to play a voice-over clip, how to style the subtitle, whether a line should trigger a camera cut. Tags are how you attach that information to a line without hardcoding it into your Rust.

A tag is just a #word at the end of a line, option, or node header. It doesn’t change dialogue behaviour - it travels with the event so your game code can act on it.

Line tags

Aria: Halt! #combat #loud

Those tags arrive on the event:

DialogueEvent::Line { text, tags, .. } => {
    // tags = ["combat", "loud"]
    if tags.iter().any(|t| t == "loud") {
        audio.play_on_bus("sfx_shout", "loud_bus");
    }
}

Some things you can do with this:

  • Trigger audio: #sfx door_creak, #voice_bus ambient
  • Swap a portrait or expression: #portrait angry, #portrait relieved
  • Style the subtitle: #subtitle_style whisper, #subtitle_style urgent
  • Camera direction: #camera closeup, #camera wide
  • Gameplay hooks: #hostile, #quest_complete, #gift
Mira: You won't find better prices in three ports. #portrait smug #sfx coin_jingle
DialogueEvent::Line { tags, .. } => {
    for tag in &tags {
        if let Some(mood) = tag.strip_prefix("portrait ") {
            ui.set_portrait(mood); // "smug"
        }
        if let Some(sfx) = tag.strip_prefix("sfx ") {
            audio.one_shot(sfx); // "coin_jingle"
        }
    }
}

Options take tags the same way:

-> Sneak past. #stealth
-> Charge in! #combat #loud

At runtime those strings show up on each DialogueOption::tags inside DialogueEvent::Options, the same idea as DialogueEvent::Line::tags for spoken lines.

Node tags

Nodes can carry tags in their header:

title: TavernEvening
tags: scene indoor warm
---
Barkeep: Evening.
===

Multiple tags, space-separated. Query them before running to pre-load music, prepare ambient systems, or build an editor scene list:

if let Some(tags) = program.node_tags("TavernEvening") {
    if tags.iter().any(|t| t == "indoor") {
        audio.load_ambience("room_tone");
    }
}

Node tags also surface via node_titles and related introspection APIs.

Stable line IDs for voice-over

One tag is special: #line:some_id. It marks a line with a stable id for localisation and voice-over lookup.

Aria: Evening, friend. #line:aria_greet_evening

That id flows onto every related event:

DialogueEvent::Line { line_id, text, .. } => {
    // line_id = Some("aria_greet_evening")
    if let Some(id) = &line_id {
        audio.play_voice_over(id);
    }
}

The id is also the key Bubbles uses when looking up translations through a LineProvider. One tag, two jobs.

Tip: Pick a naming convention early. Something like scene_speaker_variant (tavern_aria_greet_first) scales well and makes VO manifests easy to audit. The ids are never sent anywhere until you ask for them - they cost nothing at runtime.

Option groups for UI constraints

Options can be grouped with #group:<name> to signal mutual exclusivity or radio-button semantics:

Choose your faction:
-> Join the Reds #group:faction
-> Join the Blues #group:faction
-> Stay neutral

Each group value flows onto DialogueOption::group:

DialogueEvent::Options(opts) => {
    for opt in opts {
        if let Some(g) = &opt.group {
            // This option belongs to a group; you could disable others in the same group
            // e.g. if g == "faction" { /* radio-button logic */ }
        }
    }
}

The group tag itself also remains in DialogueOption::tags if you need it for styling.

Getting structured metadata from tags

Two helper functions extract special tags:

use bubbles::{line_id_from_tags, option_group_from_tags};

let id = line_id_from_tags(&tags);          // Option<String>
let group = option_group_from_tags(&tags);  // Option<String>

Both return the first match and reject empty values.

Reserved tags

Only two:

  • line:<id> - stable id for voice-over and localisation.
  • group:<name> - option group name for UI constraints.

Every other tag is yours. Bubbles won’t grow a reserved list that clashes with #combat or #boss. Go ahead and use whatever makes sense for your engine.


Try it: examples/snippets/commands.bub: see line tags (#dramatic, #sfx, #eerie) alongside commands in the transcript pane.

cargo run -p bubbles-tui -- examples/snippets/commands.bub

Next: Variables

Variables

Your game has state: gold, health, quest flags, faction reputation, whether the player has already met a character. Variables let your .bub scripts read and write that state.

Bubbles variables start with $ and have one of three types:

TypeExample values
Number42, 3.14, -1
Text"Aria", "Welcome!"
Booltrue, false

Once a variable has a type, it’s fixed. Storing a string into a number variable is an error, not a silent coercion.

Declaring

Use <<declare>> (usually at the top of a starting node) to register a variable with its type and default value. It initialises the variable once - subsequent runs leave the existing value alone.

title: Tavern
---
<<declare $gold = 50>>
<<declare $name = "stranger">>
<<declare $greeted = false>>

Barkeep: Evening, {$name}.
===

First visit: $gold = 50, $name = "stranger", $greeted = false. Second visit (after save/load or a jump back): values preserved. <<declare>> is a no-op if the variable already exists.

Tip: Use <<declare>> for anything that should persist - gold, quest flags, reputation counters. It makes save/load work automatically: old saves carry their values, new variables get their defaults.

Assigning

<<set>> changes a value anywhere in a node body:

<<set $gold = 100>>
<<set $gold = $gold + 10>>
<<set $greeted = true>>
<<set $name = "Aria">>

The right side is a full expression, so arithmetic, comparisons, and function calls all work:

<<set $hp = clamp($hp - $dmg, 0, 100)>>
<<set $greeting = "Hello, " + $name>>

Reading

Reference a variable by name in any expression:

<<if $gold >= 10>>
    Merchant: You can afford it.
<<endif>>

In line text, use {...} interpolation:

Merchant: That'll be 10 gold. You have {$gold}.

Type safety

Types are fixed. Mixing them the wrong way fails at compile time when possible, at runtime with a clear error otherwise.

<<declare $gold = 50>>
<<set $gold = "broke">>   # runtime error: type mismatch

Concatenating strings with + is fine when both sides are strings:

<<set $greeting = "Hello, " + $name>>   # OK
<<set $bad = "gold: " + $gold>>         # error: mix of string and number

To format a number into a string, use the built-in string():

<<set $label = "gold: " + string($gold)>>   # OK

Introspection

The Program API exposes every declared variable. Useful for pre-populating UIs, building save migrations, or validating external data:

for decl in program.variable_declarations() {
    println!("{} = {}", decl.name, decl.default_src);
}

That lists every <<declare>> across the whole program, with its source text.

declare vs set - quick rule

  • <<declare>> for game state that should persist: gold, HP, faction rep, quest flags.
  • <<set>> when the value should always reset - a per-scene counter, a temp calculation result.
<<declare $reputation = 0>>   # persists across saves
<<set $tmp_roll = dice(20, 1) + $luck>>   # scratch value, fine to clobber

Note: Storage is pluggable. If HashMapStorage isn’t enough - maybe you want variables to live in your game’s save system - implement the VariableStorage trait. Bubbles never touches your state except through those two methods.


Try it: examples/snippets/variables.bub: a grog shop where the price rises with each purchase, showing <<declare>>, <<set>>, arithmetic, and interpolation in action.

cargo run -p bubbles-tui -- examples/snippets/variables.bub

Next: Expressions

Expressions

Wherever Bubbles expects a value - the right side of <<set>>, the condition of <<if>>, an option guard, or a {...} interpolation - you can write a full expression. Arithmetic, comparisons, logic, function calls, all of it.

<<set $hp = clamp($hp - $dmg * 2, 0, 100)>>
<<if $gold >= 10 && !$banned>>
You have {$gold + 5} gold after the tip.

Operators

In rough order of precedence (lowest first):

CategoryOperators
Logical OR||
Logical AND&&
Equality==, !=
Comparison<, <=, >, >=
Additive+, -
Multiplicative*, /, %
Unary-, !
Grouping( ... )

They work the way you’d expect from any C-family language. Use parentheses to override precedence:

<<if ($hp < 20 || $poisoned) && !$invulnerable>>
    Aria: You don't look well.
<<endif>>

Literals

42            <- Number
3.14          <- Number
-7            <- Number
"Aria"        <- Text
"It's cold."  <- Text (escapes: \", \\, \n)
true          <- Bool
false         <- Bool

String concatenation uses + - both sides must be strings. See Variables for how to format a number into a string first.

Built-in functions

Numeric

FunctionReturns
round(x)nearest integer
floor(x)largest integer <= x
ceil(x)smallest integer >= x
abs(x)absolute value
min(a, b, ...)smallest
max(a, b, ...)largest
clamp(x, lo, hi)x clamped to [lo, hi]

Conversions

FunctionReturns
int(x)number truncated to integer
string(x)value formatted as Text

Random (the rand feature, on by default)

FunctionReturns
random()uniform float in [0, 1)
random_range(lo, hi)uniform int in [lo, hi] inclusive
dice(sides, count)sum of count rolls of a sides-sided die

Narrative helpers

FunctionReturns
visited(node)true once you’ve run the named node at least once
visited_count(node)how many times the node has completed
plural(n, sing, plur)sing if `
select(key, "k1:text|k2:text|other:fallback")picks a branch by key

plural and select are handy for localisation and gender-aware dialogue (see Localisation):

You found {$n} {plural($n, "gem", "gems")}.
{select($gender, "m:He|f:She|other:They")} nods.

Your own functions

Your game probably has things Bubbles can’t know about - inventory checks, faction reputation, proximity queries. Register closures with the runner’s FunctionLibrary:

runner.library_mut().register("faction_at_least", |args| {
    let Some(bubbles::Value::Text(name)) = args.first() else {
        return Err(bubbles::DialogueError::Function {
            name: "faction_at_least".into(),
            message: "expected string argument (faction name)".into(),
        });
    };
    let Some(bubbles::Value::Number(thresh)) = args.get(1) else {
        return Err(bubbles::DialogueError::Function {
            name: "faction_at_least".into(),
            message: "expected number argument (threshold)".into(),
        });
    };
    let score = game::faction_score(name);
    Ok(bubbles::Value::Bool(score >= *thresh))
});

Then in your dialogue:

<<if faction_at_least("thieves_guild", 50)>>
    Aria: One of us, are you?
<<endif>>

A fuller example

Here’s a skill check that uses several features together:

title: SkillCheck
---
<<declare $luck = 4>>
<<declare $attempts = 0>>

<<set $attempts = $attempts + 1>>
<<set $roll = dice(20, 1) + $luck>>

<<if $roll >= 15>>
    Narrator: You thread the needle. Attempt {$attempts}, roll {$roll}.
<<elseif $roll >= 10>>
    Narrator: Close. Try again? Attempt {$attempts}, roll {$roll}.
<<else>>
    Narrator: Nope. ({$roll}) {plural($attempts, "attempt", "attempts")} and counting.
<<endif>>
===

dice, plural, <<if>>, variables, interpolation - all in one readable node. You can follow what it does without looking anything up.


Next: Conditionals

Conditionals

Dialogue that plays the same every time isn’t really dialogue. <<if>> lets you branch based on what’s actually true right now - gold, quest state, reputation, whatever your game tracks.

<<if $gold >= 10>>
    Merchant: A pleasure doing business.
<<elseif $gold >= 5>>
    Merchant: Every coin counts, friend.
<<else>>
    Merchant: Come back when you're flush.
<<endif>>

Four directives: <<if condition>> opens, <<elseif condition>> adds branches, <<else>> is the fallback, <<endif>> closes. All must balance.

Each branch is a full body - lines, options, commands, nested <<if>>s, whatever the scene needs.

Nested conditionals

You can nest as deep as the story needs:

<<if $has_key>>
    <<if $door_locked>>
        Aria: The key fits. The door groans open.
        <<set $door_locked = false>>
    <<else>>
        Aria: The door's already open.
    <<endif>>
<<else>>
    Aria: Locked, and no key. We'll find another way.
<<endif>>

Keep it readable. If a block starts looking like a pyramid, consider splitting it into separate nodes.

Option guards vs <<if>>

Two different tools. Know when to reach for which.

<<if>> changes what gets run. The lines inside only execute when the condition is true.

<<if $hp < 20>>
    Aria: You're bleeding.
<<endif>>

Option guards change whether an option is available. The player sees the option either way; it’s locked if the guard is false.

-> Drink a potion <<if $potions > 0>>
    Aria: That's better.

Rule of thumb: if the player should know the option exists but isn’t accessible, use a guard. If they shouldn’t even see that branch, use <<if>> around the ->.

<<once>>: the special case

If you want “run this the first time only,” you don’t need to manage a variable - Bubbles has a dedicated form:

<<once>>
    Aria: Welcome to the ruins.
<<else>>
    Aria: Here again, I see.
<<endonce>>

It’s covered in full in Once Blocks.

A practical scene

title: BakeryDoor
---
<<declare $knocked = false>>
<<declare $baker_awake = false>>

<<if $baker_awake>>
    Baker: We're open. Come in.
<<elseif $knocked>>
    Baker: (grumbling from upstairs) One moment!
    <<set $baker_awake = true>>
<<else>>
    Narrator: The door is shut. A sign says "Back at dawn."
    -> Knock anyway.
        <<set $knocked = true>>
        <<jump BakeryDoor>>
    -> Come back later.
        <<jump Street>>
<<endif>>
===

Three states, one node, one <<jump>> back to itself to re-run the logic after the knock flips the flag. That’s a very common Bubbles pattern.


Next: Jumps and Detours

Jumps and Detours

Nodes don’t flow into each other automatically. After the last line in a node, dialogue ends - unless you tell it where to go next. <<jump>> moves one-way to a new node. <<detour>> calls one and comes back.

<<jump>>: one-way

title: Start
---
Narrator: Welcome.
<<jump Adventure>>
===

title: Adventure
---
Narrator: Here we go.
===

<<jump>> is exactly what it sounds like. Execution leaves the current node and begins the named one. Anything after the <<jump>> in the original node never runs.

Jumps clear the call stack, so you can’t “return” from a jump. Use this for scene changes and story transitions.

Tip: If you reference a node that doesn’t exist, Bubbles catches it at compile time. compile() returns an error pointing at the bad name - no more typos surfacing at runtime.

<<detour>> and <<return>>: call and come back

Sometimes you want to call into a subroutine and resume where you left off.

title: Tavern
---
Barkeep: What'll it be?
-> A mug of ale.
    <<detour PourAle>>
    Barkeep: Anything else?
===

title: PourAle
---
<<play_pour_sound>>
Barkeep: Here you are.
<<return>>
===

The flow:

  1. <<detour PourAle>> pushes the current position and jumps to PourAle.
  2. PourAle runs, finishing with <<return>>.
  3. Execution picks up exactly where <<detour>> left off - the “Anything else?” line runs next.

You can detour from within a detour. The stack handles any depth.

If a detour node runs off the end without a <<return>>, Bubbles returns anyway. <<return>> is just the explicit form.

<<stop>>: end the dialogue right here

When you want to exit the whole dialogue - not just the current node, and not return to the caller - use <<stop>>.

title: Guard
---
Guard: State your business.

-> I'm just passing through.
    Guard: Move along.
-> ...
    Guard: That's enough. You're done here.
    <<stop>>

Guard: Anything else?
===

Unlike <<return>>, which pops one frame, <<stop>> clears the entire call stack and emits a single DialogueComplete event. Nothing after it in the current node runs, and no caller resumes.

Reach for <<stop>> for bad-ending branches, timed interruptions, or any beat where the conversation is simply over - regardless of how deep the detour stack is.

When to jump, when to detour

  • Jump for scene changes: <<jump StreetAtNight>>, <<jump Ending>>.
  • Detour for reusable bits: buying an item, telling a joke, a shared “she nods and looks away” beat you want to play from several places.

A simple way to tell: if the current conversation should continue after this bit, detour. If the current conversation is over, jump.

A richer example

title: Tavern
---
<<declare $gold = 10>>

Barkeep: What'll it be?

-> Ale, 5 gold <<if $gold >= 5>>
    <<set $gold = $gold - 5>>
    <<detour PourDrink>>
-> Wine, 10 gold <<if $gold >= 10>>
    <<set $gold = $gold - 10>>
    <<detour PourDrink>>
-> Nothing. <<jump Leave>>

Barkeep: Anything else?
-> Another round. <<jump Tavern>>
-> I'm done. <<jump Leave>>
===

title: PourDrink
---
<<play_pour_sound>>
Barkeep: Here you go.
<<return>>
===

title: Leave
---
Barkeep: Safe travels.
===

PourDrink is a shared beat - both the ale and wine branches detour into it, and both come back to the “Anything else?” line. No duplication, clean flow.

Infinite loops

Bubbles won’t save you from an infinite jump cycle:

title: A
---
<<jump B>>
===

title: B
---
<<jump A>>
===

That will spin your game until something else stops it. If you need a loop, make sure something inside the loop consumes an event (a Line, Options, or Command) so next_event actually yields.

Note: Every Line or Options event is a natural yield point. A loop with at least one dialogue beat per lap is always safe.


Next: Once Blocks

Once Blocks

“Say something different the first time” is a thing you’ll write a hundred times in any game. Bubbles has a dedicated form for it.

<<once>>
    Aria: Welcome to the ruins. They say the old king's crown is still down there.
<<else>>
    Aria: Back again, I see. Find anything this time?
<<endonce>>

First time the runner reaches that block: the first branch runs. Every time after: the <<else>> branch runs. If there’s no <<else>>, subsequent visits skip the block entirely.

Three shapes

Run once, then silent:

<<once>>
    Narrator: The door creaks open for the first time.
<<endonce>>

Run once, then something else forever:

<<once>>
    Narrator: A bell tolls in the distance.
<<else>>
    Narrator: The bell is silent now.
<<endonce>>

Run once, but only if a condition is true:

<<once if $has_amulet>>
    Narrator: The amulet glows. You feel warmer.
<<endonce>>

With <<once if>>, the block “consumes” its one shot the first time the condition is true. After that it never runs again - even if the condition becomes true later. If you want the block to wait for its moment, use the conditional guard, not a plain <<if>>.

How “once” is tracked

Each once block gets a stable id derived from the node and its position. That id lives in the runner’s “seen” set, which is part of the RunnerSnapshot. Save and load pick up right where they left off - including which once blocks have fired.

Tip: Because once-ness is stored with the runner, it resets if you make a fresh Runner. That’s usually what you want (new game, fresh first-times) - but if you’re scripting a cutscene you want to replay, remember to save/restore the snapshot.

A little NPC

Everyone writes this NPC at some point. With <<once>>, it’s three blocks of dialogue.

title: Merchant
---
<<once>>
    Merchant: Oh! A new face. Welcome to my shop.
<<else>>
    <<once>>
        Merchant: Back again? Good memory - I'm glad you came.
    <<else>>
        Merchant: Hello again.
    <<endonce>>
<<endonce>>

Merchant: What can I get you?
-> Show me your wares.
    <<detour Wares>>
-> Nothing today.
    Merchant: Always welcome.
===

Three distinct greetings: first visit, second visit, all subsequent visits - without a single variable. The harbour example uses the same pattern for rumours that play out in full on first ask and are briefly acknowledged on return.

<<once>> vs a flag variable

You could write:

<<declare $greeted = false>>
<<if !$greeted>>
    Aria: Welcome.
    <<set $greeted = true>>
<<else>>
    Aria: Back again.
<<endif>>

…and it works. But <<once>> is shorter, harder to mess up (no forgetting the <<set>>), and the state is saved for you automatically. Reach for a variable when you need to read the flag elsewhere. Reach for <<once>> when you just need a one-shot.


Try it: examples/snippets/once.bub: Barnacle Pete’s kraken tale, epic on first visit and acknowledged on every repeat. Run to the end, then press R (rerun) to see the second-visit lines without losing the once history. Press b to step back through individual events.

cargo run -p bubbles-tui -- examples/snippets/once.bub

Next: Interpolation

Interpolation

Hard-coding values into lines breaks the moment anything changes. {...} lets you drop any expression directly into text - variables, math, function calls, all of it substituted before the event reaches your game.

Aria: You have {$gold} gold and {$potions} potions.

The expression is evaluated when the line is produced. By the time DialogueEvent::Line arrives in your game code, the text is already resolved:

DialogueEvent::Line { text, .. } => {
    // text = "You have 42 gold and 3 potions."
}

What can go inside {…}?

Anything that’s a valid expression.

Aria: Half of your gold is {$gold / 2}.
Aria: You are {plural($lives, "life", "lives")} from a new record.
Aria: The roll was {dice(6, 2)}.
Aria: {select($mood, "happy:Lovely|sad:Sigh|other:Hm")}.

Strings, numbers, booleans - all formatted with sensible defaults:

  • Numbers: integers as 42, floats as 3.14 (no trailing zeros).
  • Booleans: true / false.
  • Strings: inserted verbatim.

For finer formatting, do it in an expression or a custom function:

Aria: You have {round($hp)} HP.
Aria: Gold: {string($gold)}.

Escaping

If you need a literal { or } in your text, double them:

Aria: She said "{{one}}" - I don't know what it meant.

That line arrives as: She said "{one}" - I don't know what it meant.

Tip: Curly braces outside of interpolation are rare in natural prose, so you’ll almost never need this. But when you do (code, JSON, serialised data), {{ and }} have your back.

Interpolation in options and commands

{…} works anywhere text flows:

-> Give the Barkeep {$gold} gold.
    Barkeep: Much obliged.
<<play_sound "coin_{$coin_type}">>

The option text substitutes before the Options event, so your menu shows real numbers. The command argument substitutes before the Command event, so your host code sees coin_gold, not coin_{$coin_type}.

A worked example

title: LevelUp
---
<<declare $lvl = 1>>
<<declare $xp = 0>>
<<declare $to_next = 100>>

<<set $xp = $xp + 40>>

<<if $xp >= $to_next>>
    <<set $lvl = $lvl + 1>>
    <<set $xp = $xp - $to_next>>
    Narrator: Level up! You are now level {$lvl}.
    Narrator: {$to_next - $xp} XP until level {$lvl + 1}.
<<else>>
    Narrator: {$xp}/{$to_next} XP - keep going.
<<endif>>
===

$lvl, arithmetic expressions, and a full sentence composed of three interpolations. Readable, fast, and it sits squarely in the territory you’d expect a scripting language to cover.

Localisation-aware interpolation

When a line carries a #line:id tag and a LineProvider returns a translation, {…} expressions in the translation are evaluated against the current variable storage. Translators can reorder or reshape interpolations for their language:

# English source
You found {$n} {plural($n, "gem", "gems")}.

# German translation (stored in the provider)
Du hast {$n} {plural($n, "Edelstein", "Edelsteine")} gefunden.

Same variables, same expressions, grammatically correct output.

Markup and interpolation together

{expr} and [markup] syntax both live in the same text and are processed in one pass. Byte offsets in the returned MarkupSpans are always relative to the final, expression-substituted string:

[b]{$name}[/b] found the key.

If $name is "Alice", the text arrives as "Alice found the key." and the span is { name: "b", start: 0, length: 5 }. The span points at the right characters regardless of how long the expression result turns out to be.

See Markup for the full tag syntax and span reference.


Next: Markup

Markup

Markup lets you annotate a range of text inside a line: a word, a phrase, an entire sentence, without changing what the text says. The tags are stripped before the event reaches your game. What you get instead is a spans list that tells you exactly where each annotation starts and how long it is.

Alice: [wave]Hello[/wave] there!

The text field arrives as "Hello there!". The spans field carries one entry: { name: "wave", start: 0, length: 5 }. Your rendering code decides what “wave” means.

Syntax

Open and close:

[wave]Hello[/wave]
[b]bold phrase[/b]

Self-closing (zero-length span, good for events and triggers):

Wait[pause /]here.

With properties:

[color value=red]Danger![/color]
[sfx name=coin_drop /]

Properties are key=value pairs, space-separated inside the tag. Keys must be identifiers. Values are unquoted words.

What the event looks like

DialogueEvent::Line { text, spans, .. } => {
    // text  = "Hello there!"
    // spans = [MarkupSpan { name: "wave", start: 0, length: 5, properties: [] }]
    for span in &spans {
        match span.name.as_str() {
            "wave"  => renderer.start_wave(span.start, span.length),
            "shake" => renderer.start_shake(span.start, span.length),
            _       => {}
        }
    }
}

MarkupSpan fields:

FieldTypeMeaning
nameStringTag name, e.g. wave
startusizeByte offset into text where the span begins
lengthusizeByte length of the spanned text. Zero for self-closing tags.
propertiesVec<(String, String)>Key-value pairs from the tag, in order.

Spans are in source order. Nested tags produce multiple spans at overlapping ranges; your renderer handles layering.

Combined with interpolation

Markup and {expr} work together. Byte offsets are computed after expression evaluation, so the span always points at the right characters:

<<declare $name = "Alice">>
[b]{$name}[/b] found the key.

Text: "Alice found the key.", span: { name: "b", start: 0, length: 5 }. Five bytes because "Alice" is five characters.

On option text

Markup works on option text the same way. The spans field on each DialogueOption follows the same rules:

-> [b]Fight[/b]
-> Run
DialogueEvent::Options(opts) => {
    for opt in &opts {
        render_option(&opt.text, &opt.spans);
    }
}

Localisation

Translators can place markup in their translated strings. Write the translation with tags wherever the visual treatment belongs in that language, and the spans come back with correct byte offsets for the translated text:

# English source
[b]Warning![/b] The bridge is out.

# French translation (in your LineProvider)
[b]Attention![/b] Le pont est coupé.

{expr} interpolations inside translated strings work the same way: translate first, then evaluate, then compute spans.

Brackets that are not markup

If the text inside [...] doesn’t match the markup pattern (starts with a digit, contains spaces, no valid identifier name), the brackets are left in the text verbatim:

The answer is [42].
[sic] as written in the original.

Both arrive with the brackets intact and an empty spans list.


Next: Commands

Commands

Commands are how your dialogue talks to your engine. Play a sound, trigger a cutscene, give the player an item, switch the music - write <<command_name args>> in your script and your game code handles it.

Anything between <<...>> that isn’t a reserved keyword is a command. Bubbles hands the name and arguments to your game via a DialogueEvent::Command:

<<play_sfx "door_creak">>
<<set_portrait "angry">>
<<give_item "health_potion">>
<<start_cutscene "mira_winks">>

Each of those emits a DialogueEvent::Command with:

  • name: String - "play_sfx", "set_portrait", etc.
  • args: Vec<String> - the whitespace-separated tokens after the name
  • tags: Vec<String> - any trailing #tags

Handling commands in Rust

while let Some(event) = runner.next_event()? {
    match event {
        DialogueEvent::Command { name, args, .. } => match name.as_str() {
            "play_sfx" => audio.one_shot(&args[0]),
            "set_portrait" => ui.set_portrait(&args[0]),
            "give_item" => inventory.give(&args[0]),
            "shake_camera" => {
                let strength: f32 = args[0].parse().unwrap_or(0.2);
                camera.shake(strength);
            }
            other => log::warn!("unknown dialogue command: {other}"),
        },
        _ => {}
    }
}

That match block is the heart of your engine integration. Add one arm per command name. The _ => arm catches typos early - much easier than debugging a silent miss at runtime.

Voice-overs

The most common pattern in narrative games: trigger a voice-over clip when a line plays. Two approaches:

Via command: explicitly fire a <<play_voice>> alongside or before the line:

<<play_voice "aria_greet_01">>
Aria: Evening, friend.

Via line_id: tag the line with #line:aria_greet_01 and look it up in the Line event:

Aria: Evening, friend. #line:aria_greet_01
DialogueEvent::Line { line_id, text, .. } => {
    if let Some(id) = &line_id {
        audio.play_voice_over(id); // "aria_greet_01"
    }
    ui.show_line(text);
}

The line_id approach is usually cleaner: one tag instead of two lines, and the id doubles as the localisation key (see Tags and Metadata and Localisation).

Portraits and per-line metadata

Combine commands and tags to drive rich UI state:

Mira: I told you - five gold, no exceptions. #portrait angry #sfx table_slam
DialogueEvent::Line { tags, .. } => {
    for tag in &tags {
        if let Some(mood) = tag.strip_prefix("portrait ") {
            ui.set_portrait_expression(mood);
        }
        if let Some(sfx) = tag.strip_prefix("sfx ") {
            audio.one_shot(sfx);
        }
    }
}

Tags travel passively with lines. Commands are explicit events that pause nothing and fire in order. Use whichever fits the beat.

Interpolation in arguments

Arguments support {...} interpolation:

<<set $pitch = 1.0 + $nervous * 0.3>>
<<play_voice "aria_greet_01" {$pitch}>>

By the time your handler runs, args is already ["aria_greet_01", "1.3"]. No parsing of {$pitch} in your game code.

Commands vs functions

Two ways to talk to the host:

  • Commands (<<give_item "key">>) - fire-and-forget side effects. Emit an event, your code reacts. Use for audio, VFX, animations, inventory changes, quest triggers.
  • Functions (reputation("thieves_guild")) - synchronous values you need back in an expression. Use inside <<if>>, <<set>>, or {...}. See Custom Functions.

If the dialogue needs a result to keep going, use a function. If it’s kicking off something elsewhere, use a command.

Reserved command names

These are built-in directives - they’re not dispatched as events:

set, declare, if, elseif, else, endif, once, endonce, jump, detour, return, stop.

Everything else is yours. <<save_checkpoint>>, <<play_cutscene "ending_a">>, <<roll 2d6>> - go wild.

A worked example

A small ambush scene with audio, camera, and a music change:

title: Ambush
---
Narrator: Rocks clatter down the path.
<<shake_camera 0.4>>
<<play_sfx "rockfall">>

Bandit: Your gold. Now.

-> Hand it over.
    <<play_sfx "sigh">>
    <<jump PeacefulExit>>
-> Fight!
    <<play_music "combat_tense">>
    <<jump Combat>>
===
DialogueEvent::Command { name, args, .. } => {
    match name.as_str() {
        "shake_camera" => camera.shake(args[0].parse().unwrap_or(0.2)),
        "play_sfx" => audio.one_shot(&args[0]),
        "play_music" => audio.music(&args[0]),
        _ => {}
    }
}

Six lines of dialogue drive camera shake, two sound effects, and a music change - with zero coupling between the script and your engine beyond the command names you agree on.


Try it: examples/snippets/commands.bub: a treasure chest discovery that fires fanfare, particles, and a curse, with line tags for audio cues.

cargo run -p bubbles-tui -- examples/snippets/commands.bub

Next: Line Groups

Line Groups

Players notice when an NPC says the same thing every time. Line groups let you write a handful of variants and let Bubbles pick which one plays - so your dock worker doesn’t shout “Oi, watch yer step!” on every single visit.

=> Barkeep: The fire crackles nearby.
=> Barkeep: A minstrel plucks softly in the corner.
=> Barkeep: The smell of roasting meat fills the air.

Three lines, all starting with =>. When the runner reaches this block, the active saliency strategy picks one. That one line emits a DialogueEvent::Line. The others stay silent.

With BestLeastRecentlyViewed (BLRV), the player hears a different line each visit, cycling through all three before any repeats. Set it once on the runner:

use bubbles::saliency::BestLeastRecentlyViewed;

runner.set_saliency(BestLeastRecentlyViewed::new());

Your game receives a normal DialogueEvent::Line - it doesn’t know there were three variants. It just gets the chosen one.

Saliency strategies

StrategyBehaviour
FirstAvailableAlways the first eligible line (default; deterministic)
RandomAvailableUniformly random (needs the rand feature)
BestLeastRecentlyViewedPrefers the one seen least recently

FirstAvailable is the default. It’s great for node groups where you’ve sorted variants by priority (see Node Groups and Saliency). For ambient barks, BLRV is almost always the right pick.

Conditions on variants

Each => line can have its own guard:

=> <<if $weather == "rain">> Barkeep: Dreary out, isn't it?
=> <<if $weather == "snow">> Barkeep: Mind the ice on the steps.
=> Barkeep: Another fine evening.

The strategy only picks among variants whose guard is true. The last line has no guard, so it’s always eligible - a nice “default” to keep the group from going silent.

Mixing with speaker, tags, and commands

=> lines behave like any other line: they can have a speaker, tags, even a #line:id for localisation.

=> Barkeep: Welcome back. #line:tavern_greet_01
=> Barkeep: Evening, stranger. #line:tavern_greet_02 #warm
=> Barkeep: Mind the step. #line:tavern_greet_03

You can also put commands inside a variant’s indented body:

=> Barkeep: The fire snaps louder than usual.
    <<shake_camera 0.05>>
=> Barkeep: The fire settles to embers.

A talkative guard

Line groups shine for NPCs who hang around:

title: Guard
---
Guard: Halt! ... Oh, it's you.

=> Guard: Quiet day, thankfully.
=> Guard: Thought I saw a shadow on the wall. Probably nothing.
=> Guard: My feet are killing me.
=> Guard: Heard anything from the capital?

-> Ask about the road.
    <<jump GuardRoad>>
-> Be on your way.
===

Every time you pass the guard, you hear one of four lines - and (with BLRV) probably a different one. No scripting, no counters, no bespoke code.

Not the same as options

Easy to confuse at first. Quick table:

Options (->)Line groups (=>)
Who picksThe playerThe saliency strategy
Event emittedOptionsLine
IntentBranching storyVariant / flavour

-> means “give the player a choice.” => means “you choose one for me.”


Try it: examples/snippets/saliency.bub: five ambient dock worker barks cycling with BLRV so the same line never plays twice in a row.

cargo run -p bubbles-tui -- examples/snippets/saliency.bub

Next: Node Groups and Saliency

Node Groups and Saliency

Line groups pick a line. Node groups pick a whole node. It’s the same idea, one level up: write several nodes with the same title and different when: conditions, and Bubbles picks the right one for the current game state.

This is the pattern for NPCs that feel different depending on who you’ve become. The baker who hates you after you stole from her. The guard who suddenly has respect for you after the quest. The tavern that transforms on festival night.

title: GreetPlayer
when: $reputation >= 50
---
Baker: Ah, my favourite customer! A warm loaf for you, on the house.
===

title: GreetPlayer
when: $reputation < 0
---
Baker: Out. You're not welcome here.
===

title: GreetPlayer
---
Baker: Good morning. What can I get you?
===

Three nodes, all called GreetPlayer. When you <<jump GreetPlayer>>, Bubbles filters down to the ones whose when: is currently true (the third node has no when:, so it’s always eligible) and hands the result to the active saliency strategy.

The node group rules

  • Every node with the same title: forms a group.
  • Each can have its own when: <expression> header.
  • A node without when: is always eligible - it’s the fallback.
  • The saliency strategy (see Saliency Strategies) picks one eligible node to run.
  • If nothing is eligible - no when: matches, no fallback node exists - Bubbles returns a runtime error.

Tip: Always include an unconditional fallback in a group. It’s a belt-and-braces guarantee that “we tried to greet the player” never turns into a runtime crash.

Order of precedence

With the default FirstAvailable strategy, the first declared eligible node wins. So write your most specific when: conditions first, and the generic fallback last:

title: Entrance
when: $quest_complete && $hero_level >= 10
---
Guard: Hero! The captain wants to see you.
===

title: Entrance
when: $quest_complete
---
Guard: Well done out there.
===

title: Entrance
---
Guard: Move along.
===

Read top to bottom, it reads like a priority list: “if all the big stuff is true, take that branch; else if some of it is true, take that one; else the plain one.”

Variety via BLRV

Swap FirstAvailable for BestLeastRecentlyViewed and node groups become a proper “pick something fresh” mechanism. If two or more nodes are eligible, BLRV prefers the one you’ve seen least recently - great for re-usable vignettes, daily-life scenes, or barks that should feel alive without a full state machine.

runner.set_saliency(bubbles::BestLeastRecentlyViewed::new());

When to use node groups

If the variation is a single line, reach for a line group. If it’s a whole scene with its own options and branches, use a node group.

Great for:

  • Different greetings based on reputation, quest state, or time of day
  • Randomised vignettes in a hub scene - a handful of mini-scenes the player might trigger
  • Gated content where the same entry point has wildly different beats depending on who the player has become

A hub scene

Let’s do a tavern entrance that feels different every time.

title: TavernEntry
when: $time == "night" && $festival
---
Barkeep: You made it for the feast! Get in here.
<<play_music tavern_festive>>
<<jump TavernFestival>>
===

title: TavernEntry
when: $time == "night"
---
Barkeep: Evening. Fire's warm.
<<jump TavernNight>>
===

title: TavernEntry
when: $time == "day"
---
Barkeep: Early one today, are we?
<<jump TavernDay>>
===

title: TavernEntry
---
Barkeep: Welcome.
===

Four variants, ranked from most specific to fallback. Every <<jump TavernEntry>> lands on the right scene for the moment.


Try it: examples/snippets/saliency.bub: a storyteller NPC with four Storyteller nodes where when: $time_of_day == "..." picks the right one. Change the <<declare>> value and press r to reload (re-reads from disk).

cargo run -p bubbles-tui -- examples/snippets/saliency.bub

Next: The Runner Lifecycle

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

Handling Events

The runner hands you one event at a time. You handle it, call next_event again, and repeat. That’s the whole integration surface.

Here’s what each event looks like in practice, and what you’ll typically do with it.

NodeStarted(String)

Fires when execution enters a node - from start(), a <<jump>>, or a <<detour>>.

DialogueEvent::NodeStarted(name) => {
    analytics.track("dialogue.node.started", &name);
    renderer.fade_in(0.3);
}

Good for scene transitions, analytics, or music changes tied to entering a location. Pair it with NodeComplete for symmetric fade in / fade out.

Line { speaker, text, line_id, tags, line_mode, spans }

A line ready to display. Interpolation is done, tags are parsed, and the localised template (if any) has already been resolved.

use bubbles::LineMode;

DialogueEvent::Line {
    speaker,
    text,
    line_id,
    tags,
    line_mode,
    ..
} => {
    if line_mode == LineMode::Debug && !cfg!(debug_assertions) {
        return; // skip QA lines in release builds
    }

    // Voice-over lookup via stable id
    if let Some(id) = &line_id {
        audio.play_voice_over(id);
    }

    // Per-line metadata via tags
    for tag in &tags {
        if let Some(mood) = tag.strip_prefix("portrait ") {
            ui.set_portrait_expression(mood);
        }
    }

    ui.show_line(speaker.as_deref(), &text);
    wait_for_advance();
}

Fields:

  • speaker: Option<String> - the Speaker: prefix, or None for narrator lines.
  • text: String - already interpolated, already localised, markup tags stripped.
  • line_id: Option<String> - the #line:... stable id if present. Use this for VO lookups and localisation.
  • tags: Vec<String> - every other #tag on the line. Portrait cues, audio buses, subtitle styles, plus #narration / #debug if you used them for line_mode.
  • line_mode: LineMode - Normal, Narration (from a #narration tag), or Debug (from #debug; wins if both tags are present). Same tags still appear in tags if you need them.
  • spans: Vec<MarkupSpan> - inline markup spans over text, in source order. Empty when the line has no markup tags. Each span carries a name, byte start, byte length, and optional properties. See Markup.

Options(Vec<DialogueOption>)

A branching choice. Execution halts until you call select_option(i). Don’t call next_event again before that - you’ll get the same Options event back.

DialogueEvent::Options(opts) => {
    let choice = ui.show_choice_menu(&opts);
    runner.select_option(choice)?;
}

You can also enforce UI constraints using the group field:

DialogueEvent::Options(opts) => {
    // Group options by their `group` field for radio-button or exclusion logic
    let mut groups: std::collections::HashMap<Option<String>, Vec<_>> = std::collections::HashMap::new();
    for (i, opt) in opts.iter().enumerate() {
        groups.entry(opt.group.clone()).or_insert_with(Vec::new).push(i);
    }
    
    // Show a radio-button style menu for options in the same group
    let choice = ui.show_choice_menu(&opts);
    runner.select_option(choice)?;
}

Each option has:

  • text: String - pre-interpolated, markup tags stripped.
  • available: bool - result of the <<if>> guard. false means locked.
  • line_id: Option<String> - stable id if #line:... is on the option.
  • tags: Vec<String> - any other tags.
  • group: Option<String> - the #group:<name> value if present. Use this to enforce UI constraints like radio-buttons or mutually exclusive choices.
  • spans: Vec<MarkupSpan> - inline markup spans over text, same shape as on Line.

Note: Trying to select an unavailable option returns an error. Filter or disable those choices in your UI before the player can pick them.

Command { name, args, tags }

Everything between <<...>> that isn’t a reserved keyword. Your code decides what it means.

DialogueEvent::Command { name, args, tags } => {
    match name.as_str() {
        "play_sfx" => audio.one_shot(&args[0]),
        "give_item" => inventory.add(&args[0]),
        "shake" => camera.shake(args[0].parse().unwrap_or(0.1)),
        "save_checkpoint" => save::checkpoint(),
        other => log::warn!("unknown dialogue command: {other}"),
    }
}

Arguments are already interpolated - no raw {$pitch} surviving into your handler. Commands don’t pause the dialogue, so keep pulling events after handling one.

NodeComplete(String)

Fires when a node finishes - either runs off the end, or <<return>>s from a detour. Pair with NodeStarted for symmetric transitions.

DialogueEvent::NodeComplete(name) => {
    analytics.track("dialogue.node.complete", &name);
    save::maybe_autosave();
}

DialogueComplete

The whole conversation is over. next_event returns None after this.

DialogueEvent::DialogueComplete => {
    ui.hide_dialogue_panel();
    gameplay.resume();
}

#[non_exhaustive]

DialogueEvent is marked #[non_exhaustive]. Always include a _ => arm so new variants added in a minor version don’t break your match:

match event {
    DialogueEvent::Line { .. } => { /* ... */ }
    DialogueEvent::Options(_) => { /* ... */ }
    DialogueEvent::Command { .. } => { /* ... */ }
    DialogueEvent::NodeStarted(_) | DialogueEvent::NodeComplete(_) => {}
    DialogueEvent::DialogueComplete => break,
    _ => {} // forward-compatible
}

A realistic game loop

Putting it together - a minimal but honest frame-tick handler:

fn tick_dialogue(runner: &mut Runner<HashMapStorage>, engine: &mut Engine) -> bool {
    loop {
        match runner.next_event() {
            Ok(Some(DialogueEvent::Line {
                speaker,
                text,
                line_id,
                tags,
                line_mode,
                ..
            })) => {
                if line_mode == bubbles::LineMode::Debug && !cfg!(debug_assertions) {
                    continue;
                }
                if let Some(id) = &line_id {
                    engine.audio.play_voice_over(id);
                }
                engine.ui.show_line(speaker.as_deref(), &text, &tags);
                return true; // wait for player input next frame
            }
            Ok(Some(DialogueEvent::Options(opts))) => {
                engine.ui.show_options(&opts);
                return true;
            }
            Ok(Some(DialogueEvent::Command { name, args, .. })) => {
                engine.dispatch_command(&name, &args);
                // no yield - commands don't need player input
            }
            Ok(Some(DialogueEvent::NodeStarted(n))) => {
                engine.analytics.node_started(&n);
            }
            Ok(Some(DialogueEvent::NodeComplete(n))) => {
                engine.analytics.node_complete(&n);
            }
            Ok(Some(DialogueEvent::DialogueComplete)) | Ok(None) => return false,
            Ok(Some(_)) => {} // forward-compatible
            Err(e) => {
                log::error!("dialogue error: {e}");
                return false;
            }
        }
    }
}

Returns true when the dialogue is waiting on the player, false when it’s done. Call from your frame tick; only yield back when there’s something for the player to respond to.


Next: Variable Storage

Variable Storage

Every <<declare>> and <<set>> in a .bub script goes through the runner’s storage. The default - HashMapStorage - works fine for most games. When you need variables to live inside your own save system, ECS component, or database row, implement the VariableStorage trait.

The trait

The core is two methods, plus an optional borrow-friendly variant the runner prefers on the hot expression-evaluation path:

use std::borrow::Cow;

pub trait VariableStorage {
    fn get(&self, name: &str) -> Option<Value>;
    fn set(&mut self, name: &str, value: Value);

    /// Default: forwards to `get` and wraps the result in `Cow::Owned`.
    /// Override to return `Cow::Borrowed` when you already own the value,
    /// so `{$text}` reads don't clone on every evaluation.
    fn get_ref(&self, name: &str) -> Option<Cow<'_, Value>> {
        self.get(name).map(Cow::Owned)
    }
}

name is the variable name as written in the script, including the leading $. Value is a tagged enum: Number(f64), Text(String), or Bool(bool).

The expression evaluator reads variables through get_ref; get is kept as the ergonomic API hosts reach for when they actually want ownership (e.g. runner.storage().get("$hp")). If you can cheaply hand back a reference - most in-memory stores can - override get_ref and you get allocation-free {$var} interpolation for free.

The default: HashMapStorage

use bubbles::{HashMapStorage, Runner, Value, VariableStorage};

let mut storage = HashMapStorage::new();
storage.set("$player_name", Value::Text("Aria".into()));
storage.set("$hp", Value::Number(100.0));

let mut runner = Runner::new(program, storage);

You can access it later:

if let Some(Value::Number(hp)) = runner.storage().get("$hp") {
    hud.update_health(hp);
}

runner.storage_mut().set("$hp", Value::Number(50.0));

With the serde feature, HashMapStorage derives Serialize/Deserialize, so you can serialise it alongside your main save file.

Writing your own storage

When you want Bubbles variables to live inside your data model - say, a component in your ECS, or a row in a save database - implement the trait yourself.

use bubbles::{Value, VariableStorage};

pub struct GameSaveStorage<'a> {
    save: &'a mut GameSave,
}

impl VariableStorage for GameSaveStorage<'_> {
    fn get(&self, name: &str) -> Option<Value> {
        self.save.flags.get(name).cloned().map(|v| match v {
            SaveValue::Int(n) => Value::Number(n as f64),
            SaveValue::Str(s) => Value::Text(s),
            SaveValue::Bool(b) => Value::Bool(b),
        })
    }

    fn set(&mut self, name: &str, value: Value) {
        self.save.flags.insert(name.to_owned(), match value {
            Value::Number(n) => SaveValue::Int(n as i64),
            Value::Text(s) => SaveValue::Str(s),
            Value::Bool(b) => SaveValue::Bool(b),
        });
    }
}

Now dialogue writes land straight in the save file. No synchronisation step, no import/export round-trip.

Tip: You’re free to filter, rename, or project variables in get/set. Want only variables starting with $quest_ to persist? Check the name in set and ignore the rest. Bubbles never peeks at storage outside these two methods.

Overriding get_ref for zero-copy reads

The runner calls get_ref on every {$var} interpolation and every expression that reads a variable. The default implementation calls get and wraps the result in Cow::Owned, which means a clone() on every read.

If your store already owns Value objects you can hand back a borrow instead:

use std::borrow::Cow;
use std::collections::HashMap;
use bubbles::{Value, VariableStorage};

pub struct MyStorage {
    vars: HashMap<String, Value>,
}

impl VariableStorage for MyStorage {
    fn get(&self, name: &str) -> Option<Value> {
        self.vars.get(name).cloned()
    }

    fn set(&mut self, name: &str, value: Value) {
        self.vars.insert(name.to_owned(), value);
    }

    // Override: hand back a reference so the evaluator never clones.
    fn get_ref(&self, name: &str) -> Option<Cow<'_, Value>> {
        self.vars.get(name).map(Cow::Borrowed)
    }
}

For a HashMap<String, Value> this is a one-liner. The payoff is that {$long_text} in a line of dialogue never copies the string - it borrows from your map for the duration of the interpolation.

When the default is fine: If your store does a lookup that already produces owned Values (e.g. a database row, a deserialized field) there is nothing to borrow - keep the default and only override if profiling shows the allocations matter.

Seeding storage from the outside

Before (or during) a conversation, push values in from your game:

runner.storage_mut().set("$time_of_day", Value::Text("evening".into()));
runner.storage_mut().set("$gold", Value::Number(player.gold as f64));

Scripts can then branch on them:

<<if $time_of_day == "evening">>
    Innkeeper: Getting late. One for the road?
<<else>>
    Innkeeper: Morning!
<<endif>>

This is how you bridge dialogue and gameplay: set the state, start the conversation, read the state back when it ends.

Checking declared variables at load time

When building UIs - settings screens, debug inspectors, save-file migrations - it’s often handy to know every variable the script could touch:

for decl in program.variable_declarations() {
    println!("{} (default source: {})", decl.name, decl.default_src);
}

Every <<declare>> across the whole program shows up here, including the textual form of its default. Great for generating a “fresh game” save without running any dialogue.

Things the storage never sees

Some script-internal state lives on the runner, not in storage:

  • Visit counts (visited, visited_count)
  • <<once>> block exhaustion

These are part of the RunnerSnapshot. If you need them to persist, include that snapshot in your save file alongside your storage.


Next: Localisation

Localisation

Bubbles localises through one trait - LineProvider - and one convention: tag your lines with #line:<id>.

The flow

  1. Author in your source language (English, whatever).
  2. Tag every line that needs translating with #line:some_id.
  3. At runtime, install a LineProvider that maps those ids to translated templates.
  4. When Bubbles processes a tagged line, it asks the provider first. If the provider returns a string, that’s the text used - and its {...} expressions are evaluated against the current variables.

This is “translate then format”: translators can reorder interpolations however their language demands, and the expressions still evaluate with the right values.

Tagging your source

Aria: Evening, friend. #line:aria_greet_01
Aria: You have {$gold} gold. #line:aria_gold_report

#line:aria_greet_01 is the stable id. Don’t change it after translation starts - those are the keys translators rely on.

The simplest provider

Bubbles ships HashMapProvider for quick experiments:

use bubbles::HashMapProvider;

let mut provider = HashMapProvider::new();
provider.insert("aria_greet_01", "¡Buenas tardes, amigo!");
provider.insert("aria_gold_report", "Tienes {$gold} monedas de oro.");

runner.set_provider(provider);

That’s it. When Bubbles emits #line:aria_greet_01, the Spanish template wins. {$gold} in the template is evaluated after the lookup, so the count still comes from the runner’s storage.

Loading from files

Most games store translations externally - JSON, YAML, CSV, a .po file, whatever. Wrap your loader in a LineProvider:

use bubbles::LineProvider;
use std::collections::HashMap;

pub struct JsonProvider {
    translations: HashMap<String, String>,
}

impl JsonProvider {
    pub fn load(path: &str) -> std::io::Result<Self> {
        let data = std::fs::read_to_string(path)?;
        Ok(Self {
            translations: serde_json::from_str(&data).unwrap(),
        })
    }
}

impl LineProvider for JsonProvider {
    fn get(&self, line_id: &str) -> Option<String> {
        self.translations.get(line_id).cloned()
    }
}

runner.set_provider(JsonProvider::load("locales/es.json")?);

Swap the provider whenever the player changes language. The dialogue continues; the next tagged line comes back in the new tongue.

Plural and gendered forms

Translation isn’t just replacing words. Bubbles has two built-in functions that help:

  • plural(n, singular, plural) - picks the singular form when |n| == 1, the plural form otherwise.
  • select(key, "k1:text|k2:text|other:fallback") - picks by key, with an other: fallback.
# English source
You found {$n} {plural($n, "gem", "gems")}.

# Spanish template (in the provider)
Encontraste {$n} {plural($n, "gema", "gemas")}.

# German template
Du hast {$n} {plural($n, "Edelstein", "Edelsteine")} gefunden.

Gendered pronouns:

{select($gender, "m:He|f:She|n:They|other:They")} arrived at the tavern.

Translators can reshape these expressions for their language - some languages have more than two plural forms, or different gender structures. Because the expression is evaluated inside the template, they have full control.

Markup in translated strings

Translators can place [markup] tags anywhere in their templates. The tags are stripped from the display text and returned as MarkupSpans with byte offsets computed against the translated, expression-evaluated string:

# English source
[b]Warning![/b] The bridge is out.

# French translation (in your LineProvider)
[b]Attention![/b] Le pont est coupé.

The spans come back referencing the correct byte ranges in the French string. The ordering is: translate, evaluate expressions, compute spans - the same as the source language.

Falling back gracefully

If your provider returns None for an id, Bubbles uses the source text from the script. You won’t get a crash - just untranslated text - which makes shipping partial translations painless.

Tip: Start every translation pass with a “missing keys” report. Run the dialogue in your target language and log every line_id the provider returns None for. Simple, and catches every new line added to the source.

Lines without #line: ids

Only lines with a #line: tag are routed through the provider. Lines without an id always use their source text - so you can leave narrator description, debug lines, or developer-only content untranslated without any fuss.

Keeping ids stable

Two rules to save your future self:

  1. Never reuse an id. Once a translator has worked on it, the id belongs to that line forever. If the line changes meaning substantially, give it a new id.
  2. Never change an id casually. Renaming greeting_01 to greet_01 invalidates every translation in flight.

Keep the ids short but descriptive - scene_speaker_variant is a pattern that scales (tavern_barkeep_greet_first, tavern_barkeep_greet_repeat).


Next: Custom Functions

Custom Functions

Bubbles ships a small built-in library (see Expressions), but the real power is adding your own. Any closure that takes Vec<Value> and returns Result<Value> plugs straight in - faction checks, inventory queries, distance calculations, cooldown lookups, anything your game tracks.

Registering a function

use bubbles::{DialogueError, Value};

runner.library_mut().register("double", |args| {
    let Some(Value::Number(n)) = args.first() else {
        return Err(DialogueError::Function {
            name: "double".into(),
            message: "expected one number argument".into(),
        });
    };
    Ok(Value::Number(n * 2.0))
});

After that, your script can use it anywhere an expression is allowed:

<<set $reward = double($base)>>
Aria: Your haul doubles to {double($coins)}.

Why functions vs commands?

Quick reminder:

  • Function - synchronous, returns a value, usable in <<if>>, <<set>>, {…}.
  • Command - fire-and-forget event for your game to react to.

If the dialogue needs the answer before it can continue, register a function. If it’s dispatching a side effect (sound, VFX, saving), use a command.

Reading game state

The most common case: expose your game’s state to dialogue.

let health = game_state.player_health;

runner.library_mut().register("player_hp", move |_args| {
    Ok(Value::Number(health as f64))
});

Then:

<<if player_hp() < 20>>
    Aria: You don't look well.
<<endif>>

Note: Closures are Send + Sync + 'static. If you need to read shared mutable state, wrap it in Arc<Mutex<…>> or Arc<RwLock<…>> and clone the handle into the closure.

use std::sync::{Arc, Mutex};

let state = Arc::new(Mutex::new(GameState::default()));
let state_for_fn = Arc::clone(&state);

runner.library_mut().register("reputation", move |args| {
    let Some(Value::Text(faction)) = args.first() else {
        return Err(DialogueError::Function {
            name: "reputation".into(),
            message: "expected one string argument (faction name)".into(),
        });
    };
    let s = state_for_fn.lock().unwrap();
    Ok(Value::Number(s.reputation(faction) as f64))
});

Dialogue calls reputation("thieves_guild") and gets a live number back.

Validating arguments

Bubbles gives you the arguments as a Vec<Value>. Validate them up front and fail clearly:

runner.library_mut().register("distance", |args| {
    let [Value::Number(x1), Value::Number(y1),
         Value::Number(x2), Value::Number(y2)] = args.as_slice() else {
        return Err(DialogueError::Function {
            name: "distance".into(),
            message: "expected 4 numbers (x1, y1, x2, y2)".into(),
        });
    };
    let dx = x2 - x1;
    let dy = y2 - y1;
    Ok(Value::Number((dx * dx + dy * dy).sqrt()))
});

The error message surfaces in DialogueError, so cargo run shows a precise complaint when someone typos the arguments.

Shadowing built-ins

You can re-register any name - register replaces an existing function. Handy if you want deterministic dice in tests:

#[cfg(test)]
runner.library_mut().register("dice", |_args| Ok(Value::Number(4.0)));

Your dialogue still writes dice(6, 3), but now it always yields 4. The script doesn’t change; the test becomes deterministic.

A game-sized example

Here’s a has_item function that checks the player’s inventory. The dialogue author doesn’t need to know anything about how items work - they just ask.

let inventory = Arc::clone(&inventory_handle);

runner.library_mut().register("has_item", move |args| {
    let Some(Value::Text(item)) = args.first() else {
        return Err(DialogueError::Function {
            name: "has_item".into(),
            message: "expected one string argument (item id)".into(),
        });
    };
    Ok(Value::Bool(inventory.lock().unwrap().contains(item)))
});

runner.library_mut().register("count", {
    let inventory = Arc::clone(&inventory_handle);
    move |args| {
        let Some(Value::Text(item)) = args.first() else {
            return Err(DialogueError::Function {
                name: "count".into(),
                message: "expected one string argument (item id)".into(),
            });
        };
        Ok(Value::Number(inventory.lock().unwrap().count(item) as f64))
    }
});

And in the dialogue:

<<if has_item("rusty_key")>>
    Aria: The old key might fit this lock.
<<elseif has_item("lockpicks")>>
    Aria: We could pick it.
<<else>>
    Aria: Dead end. No way in.
<<endif>>

Merchant: {count("apple")} apples, is it? That's {count("apple") * 2} gold.

The story branches, counts, and does arithmetic on live game state - all from a .bub file a writer can edit without touching Rust.


Next: Saliency Strategies

Saliency Strategies

A saliency strategy is the rule Bubbles uses to pick one item from a list of eligible options. It fires whenever a line group or node group needs to choose.

Bubbles ships three. You can also write your own.

FirstAvailable (default)

use bubbles::saliency::FirstAvailable;

runner.set_saliency(FirstAvailable);

Picks the first eligible candidate in declaration order. Fully deterministic, zero state, always safe.

Use this when:

  • You want predictable behaviour for tests and replays.
  • Your groups are ranked by priority (most specific first, fallback last).

RandomAvailable (requires rand feature)

use bubbles::saliency::RandomAvailable;

runner.set_saliency(RandomAvailable);

Picks uniformly at random from every eligible candidate. Great for flavour lines where anything goes.

Use this when:

  • Order doesn’t matter and variety does.
  • You don’t mind hearing the same line twice in a row occasionally.

BestLeastRecentlyViewed (BLRV)

use bubbles::saliency::BestLeastRecentlyViewed;

runner.set_saliency(BestLeastRecentlyViewed::new());

Prefers the candidate you’ve seen least recently. Over time, every candidate comes up before any repeats. This is the strategy that makes NPCs feel alive.

Use this when:

  • You have 3–10 variants and want each one to feel fresh.
  • Repetition would break immersion.
  • You care about variety more than randomness.

Tip: BLRV is usually the right default for ambient barks. Set it on the runner and forget it. The visit tracking lives inside the strategy; it persists for the lifetime of the runner.

Writing your own strategy

Implement the SaliencyStrategy trait. You get a slice of Candidates; return the index of the one you want.

use bubbles::{Candidate, SaliencyStrategy};

/// A strategy that picks the candidate with the most `#important` tags.
pub struct MostImportant;

impl SaliencyStrategy for MostImportant {
    fn choose(&mut self, candidates: &[Candidate]) -> Option<usize> {
        candidates.iter()
            .enumerate()
            .max_by_key(|(_, c)| c.tags.iter().filter(|t| t == &"important").count())
            .map(|(i, _)| i)
    }
}

runner.set_saliency(MostImportant);

That’s the whole surface. Ideas worth trying:

  • Weighted random - each variant carries a weight tag like #weight:5.
  • Mood-aware - boost candidates whose tags match the current scene’s mood.
  • Player-preferring - prefer ones tagged #player_class_<x> when the player is that class.
  • Exhaustive - walk every candidate in order, then loop.

The strategy is called synchronously during next_event. Keep it fast - no network calls, no disk reads.

Strategy scope

The strategy on the runner applies to all groups: line groups, node groups, and anything that uses the saliency machinery. If you need per-scene variation (e.g. “BLRV for barks, random for greetings”), the cleanest path is writing a dispatching strategy that reads tags off the candidate and picks a behaviour accordingly.

A complete setup

Most games want something like this:

use bubbles::saliency::BestLeastRecentlyViewed;

let mut runner = Runner::new(program, HashMapStorage::new());
runner.set_saliency(BestLeastRecentlyViewed::new());

Two lines, every line and node group now picks fresh variants. Writers author => lines freely; players hear the world talk without ever clocking the repetition.


Next: Save and Load

Unity and native hosts (C ABI)

bubbles-ffi is a shared library with a C ABI for driving .bub dialogue from C#, C++, or any language that can load a native plugin. Unity’s DllImport, .NET P/Invoke, and Godot .NET all work.

Every symbol is declared in include/bubbles_ffi.h. Events come back as JSON strings; everything else uses UTF-8 byte slices.

Build

cargo build -p bubbles-ffi --release
PlatformFile
Linuxtarget/release/libbubbles_ffi.so
macOStarget/release/libbubbles_ffi.dylib
Windowstarget/release/bubbles_ffi.dll

Copy the output into your Unity project’s Assets/Plugins/ folder (platform subfolders as needed).

The shape of it

Compile a .bub source to a program handle, create a runner from it, then pull events in a loop.

// P/Invoke declarations (mirror whatever you need from the header)
[DllImport("bubbles_ffi")] static extern int  bubbles_compile(nint textPtr, nuint textLen, out nint outProgram);
[DllImport("bubbles_ffi")] static extern int  bubbles_runner_new(nint program, out nint outRunner);
[DllImport("bubbles_ffi")] static extern int  bubbles_runner_start(nint runner, nint nodePtr, nuint nodeLen);
[DllImport("bubbles_ffi")] static extern int  bubbles_runner_next_event(nint runner, out nint outEventJson);
[DllImport("bubbles_ffi")] static extern void bubbles_string_free(nint p);
[DllImport("bubbles_ffi")] static extern void bubbles_runner_free(nint runner);

const int BUBBLES_OK   =  0;
const int BUBBLES_DONE =  1;
const int BUBBLES_ERR  = -1;
byte[] src  = Encoding.UTF8.GetBytes(File.ReadAllText("dialogue.bub"));
byte[] node = Encoding.UTF8.GetBytes("Start");

var srcHandle  = GCHandle.Alloc(src,  GCHandleType.Pinned);
var nodeHandle = GCHandle.Alloc(node, GCHandleType.Pinned);
try {
    if (bubbles_compile(srcHandle.AddrOfPinnedObject(), (nuint)src.Length, out nint program) != BUBBLES_OK)
        throw new Exception(LastError());

    // bubbles_runner_new consumes the program handle; don't free it separately.
    if (bubbles_runner_new(program, out nint runner) != BUBBLES_OK)
        throw new Exception(LastError());

    if (bubbles_runner_start(runner, nodeHandle.AddrOfPinnedObject(), (nuint)node.Length) != BUBBLES_OK)
        throw new Exception(LastError());

    while (true) {
        int rc = bubbles_runner_next_event(runner, out nint eventPtr);
        if (rc == BUBBLES_DONE) break;
        if (rc != BUBBLES_OK)   throw new Exception(LastError());

        string json = Marshal.PtrToStringUTF8(eventPtr)!;
        bubbles_string_free(eventPtr);
        HandleEvent(runner, json);
    }
} finally {
    srcHandle.Free();
    nodeHandle.Free();
}

Parsing events

Each event is a JSON object with a "kind" field:

{ "kind": "Line",     "speaker": "Aria", "text": "Evening, friend.", "tags": [] }
{ "kind": "Options",  "options": [{ "text": "Hello", "tags": [] }, ...] }
{ "kind": "Command",  "name": "play_music", "args": ["tavern_theme"] }
{ "kind": "NodeStarted",     "title": "Intro" }
{ "kind": "NodeComplete",    "title": "Intro" }
{ "kind": "DialogueComplete" }

A typical handler:

[DllImport("bubbles_ffi")] static extern int bubbles_runner_select_option(nint runner, nuint index);

void HandleEvent(nint runner, string json) {
    using var doc = JsonDocument.Parse(json);
    switch (doc.RootElement.GetProperty("kind").GetString()) {
        case "Line":
            string? speaker = doc.RootElement.GetProperty("speaker").GetString();
            string? text    = doc.RootElement.GetProperty("text").GetString();
            ShowLine(speaker, text);
            break;
        case "Options":
            JsonElement opts = doc.RootElement.GetProperty("options");
            int choice = ShowOptions(opts);   // your UI picks an index
            bubbles_runner_select_option(runner, (nuint)choice);
            break;
        case "Command":
            DispatchCommand(doc.RootElement);
            break;
        // "NodeStarted", "NodeComplete", "DialogueComplete", "Unknown": ignore or handle as needed
    }
}

DialogueEvent is #[non_exhaustive] on the Rust side. Unknown future variants arrive as { "kind": "Unknown" } - ignore them and keep looping.

Configuring the runner

Saliency

[DllImport("bubbles_ffi")]
static extern int bubbles_runner_new_with_saliency(nint program, int saliencyKind, out nint outRunner);

const int BUBBLES_SALIENCY_FIRST_AVAILABLE  = 0;  // default
const int BUBBLES_SALIENCY_BLRV             = 1;  // best least-recently viewed
const int BUBBLES_SALIENCY_RANDOM_AVAILABLE = 2;
bubbles_runner_new_with_saliency(program, BUBBLES_SALIENCY_BLRV, out nint runner);

See Saliency Strategies for what each strategy does.

Locale

Pass a JSON object of line_id -> template strings before bubbles_runner_start:

[DllImport("bubbles_ffi")]
static extern int bubbles_runner_set_locale_json(nint runner, nint jsonPtr, nuint jsonLen);
byte[] locale = Encoding.UTF8.GetBytes("""{"aria_greet_01":"Buenas tardes, amigo."}""");
var h = GCHandle.Alloc(locale, GCHandleType.Pinned);
bubbles_runner_set_locale_json(runner, h.AddrOfPinnedObject(), (nuint)locale.Length);
h.Free();

See Localisation for how #line: ids and templates work.

Host functions

Register a C callback for any function called from a .bub script:

[DllImport("bubbles_ffi")]
static extern int bubbles_runner_register_function(
    nint runner, nint namePtr, nuint nameLen,
    delegate* unmanaged[Cdecl]<nint, nint, nuint, nint*, int> cb,
    nint userdata);

[DllImport("bubbles_ffi")] static extern nint bubbles_copy_utf8(nint ptr, nuint len);
[UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
static unsafe int HostAddOne(nint userdata, nint argsPtr, nuint argsLen, nint* outResult) {
    // args arrive as a JSON array of dialogue Values
    string argsJson = Marshal.PtrToStringUTF8(argsPtr, (int)argsLen)!;
    using var doc = JsonDocument.Parse(argsJson);
    double n = doc.RootElement[0].GetDouble();

    // return result as a JSON scalar allocated with bubbles_copy_utf8
    byte[] result = Encoding.UTF8.GetBytes(n + 1);
    var h = GCHandle.Alloc(result, GCHandleType.Pinned);
    *outResult = bubbles_copy_utf8(h.AddrOfPinnedObject(), (nuint)result.Length);
    h.Free();
    return BUBBLES_OK;
}

The runtime frees the result string with bubbles_string_free, so always allocate it with bubbles_copy_utf8. See Custom Functions for the argument and return value conventions.

Variables

[DllImport("bubbles_ffi")]
static extern int bubbles_runner_variable_get_json(
    nint runner, nint namePtr, nuint nameLen, out nint outJson);

[DllImport("bubbles_ffi")]
static extern int bubbles_runner_variable_set_json(
    nint runner, nint namePtr, nuint nameLen, nint valuePtr, nuint valueLen);
// Read $hp - returns a JSON scalar ("100", "true", etc.) or "null" if unset
byte[] name = Encoding.UTF8.GetBytes("$hp");
var h = GCHandle.Alloc(name, GCHandleType.Pinned);
bubbles_runner_variable_get_json(runner, h.AddrOfPinnedObject(), (nuint)name.Length, out nint json);
h.Free();
string valueJson = Marshal.PtrToStringUTF8(json)!;
bubbles_string_free(json);

To write, pin a JSON scalar the same way and pass it to bubbles_runner_variable_set_json. Plain JSON types (42, "sword", true) and tagged objects ({"Number":42}, {"Text":"sword"}, {"Bool":true}) are both accepted.

Save and load

Take two snapshots - session state and variable storage - then restore them in order:

[DllImport("bubbles_ffi")] static extern int bubbles_runner_snapshot_session_json(nint runner, out nint outJson);
[DllImport("bubbles_ffi")] static extern int bubbles_runner_snapshot_storage_json(nint runner, out nint outJson);
[DllImport("bubbles_ffi")] static extern int bubbles_runner_restore_storage_json(nint runner, nint jsonPtr, nuint jsonLen);
[DllImport("bubbles_ffi")] static extern int bubbles_runner_restore_session_json(nint runner, nint jsonPtr, nuint jsonLen);
// Save
bubbles_runner_snapshot_storage_json(runner, out nint storagePtr);
bubbles_runner_snapshot_session_json(runner, out nint sessionPtr);
string storageJson = Marshal.PtrToStringUTF8(storagePtr)!;
string sessionJson = Marshal.PtrToStringUTF8(sessionPtr)!;
bubbles_string_free(storagePtr);
bubbles_string_free(sessionPtr);
WriteSave(storageJson, sessionJson);

// Load: restore storage first, then session
byte[] storageSrc = Encoding.UTF8.GetBytes(storageJson);
byte[] sessionSrc = Encoding.UTF8.GetBytes(sessionJson);
var sh = GCHandle.Alloc(storageSrc, GCHandleType.Pinned);
var eh = GCHandle.Alloc(sessionSrc, GCHandleType.Pinned);
bubbles_runner_restore_storage_json(runner, sh.AddrOfPinnedObject(), (nuint)storageSrc.Length);
bubbles_runner_restore_session_json(runner, eh.AddrOfPinnedObject(), (nuint)sessionSrc.Length);
sh.Free();
eh.Free();

See Save and Load for why order matters and what each snapshot captures.

Practical notes

  • String ownership - strings returned by the library (event JSON, variable JSON, snapshots) are library-owned. Free each one with bubbles_string_free. Do not pass them to Marshal.FreeHGlobal or C free().
  • UTF-8 byte lengths - all string inputs take a pointer plus a byte count as nuint. Use Encoding.UTF8.GetBytes and pass .Length. No trailing NUL required.
  • Threading - the FFI surface is not thread-safe. Call it from one thread; Unity’s main thread is fine.
  • ABI version - call bubbles_abi_version() at startup and assert it equals the version your bindings target (currently 1).
  • Errors - on BUBBLES_ERR, call bubbles_last_error() for a NUL-terminated UTF-8 message valid until the next bubbles_* call on the same thread. Marshal.PtrToStringUTF8 reads it without copying.

Still Rust-only

The C API fixes storage as HashMapStorage, line lookup as HashMapProvider (from JSON), and saliency as one of the three strategies above. A fully custom VariableStorage, LineProvider, or SaliencyStrategy still needs a Rust shim that configures RunnerBuilder and exposes its own FFI.

A .NET smoke app lives in the repo at crates/bubbles-ffi/tests/dotnet_smoke/; CI builds the release library and runs it on Linux.


See also: WebAssembly if you embed dialogue in the browser without a native plugin.

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 = "0.8.0", 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).

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

Multi-file Projects

Small games fit in one .bub file. Real ones don’t. Bubbles handles this with compile_many, which stitches multiple files into a single Program where every node can see every other node.

The API

use bubbles::compile_many;

let program = compile_many(&[
    ("tavern.bub", tavern_src),
    ("merchants.bub", merchants_src),
    ("intro.bub", intro_src),
])?;

Each entry is a (name, source) pair. The name only appears in error messages - it’s how you’ll find the file when something goes wrong. The source is the raw .bub text.

compile_many produces a single Program that sees every node in every file as if they were all written in one big document.

Loading from disk

use std::fs;

fn load_program(dir: &str) -> Result<bubbles::Program, Box<dyn std::error::Error>> {
    let mut files = Vec::new();
    for entry in fs::read_dir(dir)? {
        let path = entry?.path();
        if path.extension().map_or(false, |e| e == "bub") {
            let name = path.file_name().unwrap().to_string_lossy().into_owned();
            let src = fs::read_to_string(&path)?;
            files.push((name, src));
        }
    }

    let refs: Vec<(&str, &str)> = files.iter()
        .map(|(n, s)| (n.as_str(), s.as_str()))
        .collect();

    Ok(bubbles::compile_many(&refs)?)
}

Point it at assets/dialogue/, ship.

Organising files

Bubbles doesn’t care how you split your files. Common patterns:

  • By scene: tavern.bub, market.bub, forest.bub.
  • By character: aria.bub, barkeep.bub, merchant.bub.
  • By chapter: ch1_arrival.bub, ch2_the_king.bub, ch3_the_cave.bub.
  • By concern: story.bub, barks.bub, tutorial.bub.

Pick the split that makes it easy for writers to find what they need. Bubbles itself is flat - jumps and detours don’t care which file the target lives in.

Duplicate titles

If two files define a node with the same title - and neither has a when: header that differentiates them - compile_many returns an error. That’s the “you probably typed the same name twice” check.

Intentional duplicates (node groups) need distinct when: conditions. Bubbles picks that up as a group and everyone is happy.

# greetings.bub
title: Greet
when: $reputation >= 50
---
Baker: Ah, my favourite customer!
===

# greetings.bub
title: Greet
---
Baker: Morning. What'll it be?
===

Two “Greet” nodes in the same file is fine - they form a group. Two without when: at all is the duplicate that trips the error.

Validation

validate(program) runs the same reference-checking pass compile does, but on an existing Program. Handy for tools and editors that want to verify a file without throwing away the result.

use bubbles::validate;

validate(&program)?;

Catches things like:

  • <<jump>>/<<detour>> to an unknown node.
  • References to undeclared variables in expressions (where possible).
  • Duplicate group titles that aren’t when:-differentiated.

Introspection

Once compiled, the Program is queryable:

for title in program.node_titles() {
    let tags = program.node_tags(title).unwrap_or_default();
    println!("{title}  [{}]", tags.join(", "));
}

for decl in program.variable_declarations() {
    println!("{} = {}", decl.name, decl.default_src);
}

if program.node_exists("TavernEntry") {
    runner.start("TavernEntry")?;
}

Useful for:

  • Building editor tooling.
  • Generating a scene index.
  • Validating against external data (achievements, VO manifests).

Hot reloading during development

A nice dev-loop trick: rebuild the program whenever a .bub file changes, and spin up a fresh runner for the current scene.

use notify::{RecursiveMode, Watcher};

let mut watcher = notify::recommended_watcher(move |res| {
    if let Ok(_event) = res {
        match load_program("assets/dialogue/") {
            Ok(prog) => reload_program(prog),
            Err(e) => eprintln!("dialogue compile error: {e}"),
        }
    }
})?;

watcher.watch("assets/dialogue/", RecursiveMode::Recursive)?;

Writers edit a file, save, and the game picks up the new script - often without losing their current scene state if you snapshot before reloading.

Tip: In production, compile once at load time and cache the Program. Recompiling is fast but not free, and writers only need hot reload during development.


Try it: The harbour showcase is a two-file project you can run right now:

cargo run -p bubbles-tui -- examples/harbour/harbour.bub examples/harbour/services.bub

harbour.bub detours into MapSeller, which is defined in services.bub, a cross-file jump working exactly as described above. See The Harbour for a full walkthrough.


Next: WebAssembly

WebAssembly

Bubbles runs on wasm32-unknown-unknown out of the box. No async, no std::thread, no OS-specific deps - just the parts of std that WebAssembly supports.

Every release is built against wasm in CI. If it ever breaks, it breaks the build.

Minimum setup

For a browser or embedded wasm host, use the library in no_default_features mode or turn on only what you need:

[dependencies]
bubbles-dialogue = { version = "0.8.0", default-features = false }

# or with serde for save/load:
bubbles-dialogue = { version = "0.8.0", default-features = false, features = ["serde"] }

The rand feature is on by default and works on wasm (via rand’s getrandom dependency), but you may want to turn it off if you’re shipping a tiny build or using a custom RNG.

Building a wasm library

cargo build --target wasm32-unknown-unknown --release

That gives you a .wasm module. For browser work you’ll usually wrap it with wasm-bindgen or wasm-pack:

wasm-pack build --target web --release

A wasm-friendly driver

Here’s a minimal wasm-bindgen façade. Compile .bub in JS, drive events one at a time from the browser’s main thread.

use wasm_bindgen::prelude::*;
use bubbles::{compile, DialogueEvent, HashMapStorage, Runner};

#[wasm_bindgen]
pub struct WebRunner {
    runner: Runner<HashMapStorage>,
}

#[wasm_bindgen]
impl WebRunner {
    #[wasm_bindgen(constructor)]
    pub fn new(source: &str, start: &str) -> Result<WebRunner, JsValue> {
        let program = compile(source).map_err(|e| JsValue::from_str(&e.to_string()))?;
        let mut runner = Runner::new(program, HashMapStorage::new());
        runner.start(start).map_err(|e| JsValue::from_str(&e.to_string()))?;
        Ok(Self { runner })
    }

    /// Returns the next event as JSON, or null when done.
    pub fn next(&mut self) -> Result<JsValue, JsValue> {
        match self.runner.next_event().map_err(|e| JsValue::from_str(&e.to_string()))? {
            Some(event) => Ok(serde_wasm_bindgen::to_value(&event)?),
            None => Ok(JsValue::NULL),
        }
    }

    pub fn select(&mut self, index: usize) -> Result<(), JsValue> {
        self.runner.select_option(index).map_err(|e| JsValue::from_str(&e.to_string()))
    }
}

That’s under 50 lines, and it’s everything a JS front-end needs to drive a dialogue loop.

Note: DialogueEvent is #[non_exhaustive]. Serialising it with serde_wasm_bindgen requires the serde feature.

Rendering in the browser

From JS:

import init, { WebRunner } from "./pkg/my_game.js";

await init();

const runner = new WebRunner(source, "Start");

function step() {
  const event = runner.next();
  if (!event) {
    console.log("dialogue complete");
    return;
  }
  if (event.Line) {
    render(event.Line.speaker, event.Line.text);
    waitForAdvance().then(step);
  } else if (event.Options) {
    renderOptions(event.Options, (i) => {
      runner.select(i);
      step();
    });
  } else if (event.Command) {
    handleCommand(event.Command);
    step(); // commands don't pause the flow
  } else {
    step();
  }
}

step();

Identical in shape to the Rust version - just a different presentation layer.

Randomness on wasm

The rand feature uses rand which, on wasm32-unknown-unknown, delegates to getrandom. You’ll need:

getrandom = { version = "0.4", features = ["js"] }

If you’re shipping a standalone wasm host (not browser), provide a custom getrandom source or replace the RNG-using builtins yourself.

What’s not supported

  • Threads. Bubbles is single-threaded by design, so this is never an issue.
  • Filesystem. Compile from a &str you loaded through your host’s IO path (fetch, bundler, etc).
  • System clock. Bubbles doesn’t touch it. If you need time-of-day logic, feed it in via a custom function or a variable.

Build-size tips

If binary size matters:

  • default-features = false drops rand (~20 KB).
  • Run wasm-opt -Oz as a post-build step (included with wasm-pack).
  • Compile with lto = "fat" and codegen-units = 1 in your release profile.

On a stripped, optimised build, a Bubbles runtime plus a small .bub script tends to weigh well under 100 KB gzipped - comfortable for a web game.


Next: TUI Runner

TUI Runner

The bubbles-tui crate is a writer-focused terminal UI for iterating on .bub scripts. It drives the same Runner you’d use in a real game and shows exactly what a working integration sees: node markers, lines, options, commands, and runtime errors.

For the full usage guide (panels, keys, r vs R, running your own files), see Using the TUI in Getting Started.

Quick start

Run the harbour showcase:

cargo run -p bubbles-tui -- examples/harbour/harbour.bub examples/harbour/services.bub

Or jump to a focused snippet:

cargo run -p bubbles-tui -- examples/snippets/variables.bub
cargo run -p bubbles-tui -- examples/snippets/once.bub
cargo run -p bubbles-tui -- examples/snippets/saliency.bub

See The Harbour for a feature walkthrough, or Snippets for a full recipe index.

How it’s built

The crate is split so the entire UI can be exercised without a real terminal:

  • AppState owns the compiled program and exposes read-only accessors (current_line, options, transcript, error_overlay, etc.).
  • Intent captures every user-visible command (Advance, FocusNext, SelectOption, Reload, Rerun, StepBack, etc.) decoupled from key codes.
  • render(&AppState, frame) draws the state with ratatui. Tests call it with TestBackend and assert on buffer contents.
  • terminal is the only module that touches raw mode / stdin / stdout, translating crossterm key events into Intents for the loop.

Step-back works by replaying intent history: on StepBack, the session is re-created from the stored source and the log is replayed minus its last entry. No snapshots needed - just a deterministic log.


Next: The Harbour

The Harbour

examples/harbour/ is the main example: a two-file pirate dockside scene that puts most of Bubbles’ language features together in one playable run. Go through it first, then come back and read this walkthrough.

cargo run -p bubbles-tui -- examples/harbour/harbour.bub examples/harbour/services.bub

You arrive at Barnacle Bay and need a travel permit from the cantankerous harbormaster Stumpy McGee. A shady map seller lurks nearby. Try to get out with a permit - or without one.


Follow along

The source for this walkthrough is exactly what you just played. Open examples/harbour/harbour.bub in your editor and follow each section below. When a section suggests a change, make it, press r to reload, and see what happens.


Node tags

The starting node declares what kind of place this is:

title: Start
tags: scene docks outdoor
---

Node tags travel with the NodeStarted event. Your game uses them to pre-load music, trigger ambient systems, or build an editor scene list. Try adding foggy to the tags list, reload, and watch it appear in the TUI transcript when the node starts.

In Rust:

DialogueEvent::NodeStarted(name) => {
    if let Some(tags) = program.node_tags(&name) {
        if tags.iter().any(|t| t == "outdoor") {
            audio.load_ambience("wind_harbour");
        }
    }
}

Variables

<<declare $gold = 25>>

<<declare>> registers the variable once. The second time through (after a bribe that fails or a detour to the map seller), $gold keeps whatever value it has - <<declare>> won’t overwrite it.

Try it: Change 25 to 9. Press r. Now you can’t afford the ten-doubloon permit, but you’re in range for the bribe option. Then change it to 0 - all the money-gated options lock at once.


Line groups for ambient flavour

=> Dockworker: Oi, watch yer step!
=> Dockworker: These crates won't unload themselves, ye know.
=> Dockworker: Smells like low tide and regret out here.
=> Dockworker: Cap'n said the tide turns at noon. Better hurry.

Four lines starting with =>. The runner (using BestLeastRecentlyViewed) picks whichever one you’ve heard least recently. Cycle through the Options node a few times - you’ll hear all four before any repeats.

Try it: Add a fifth bark. Reload. Now you’ve got five lines cycling, still with no repeat until all five have played.


<<once>> for first-visit content

<<once>>
    Stumpy: Word is there's a three-headed sea creature lurking past Dead Man's Reef.
    Stumpy: Whole fleet turned back. Brave sailors, every one of 'em.
<<else>>
    Stumpy: Aye, same tale about the sea creature. Nothing new to report.
<<endonce>>

First time you reach the Options node: Stumpy tells the full sea creature story. After that: the short acknowledgement. No flag variable, no manual tracking.

Try it: Press R (rerun) after completing the scene once. Stumpy’s now in “second visit” mode - you’ll hear the short version immediately. Press r instead to reload and confirm the long version comes back.


Guarded options

-> Pay ten doubloons for a permit. <<if $gold >= 10>>
    <<set $gold = $gold - 10>>
    <<stamp_permit>>
    Stumpy: There she is. Official seal and everything.
    Stumpy: You have {$gold} doubloons left. Don't spend 'em all at once.
    <<jump Depart>>
-> Bribe him with everything you've got. <<if $gold >= 1 && $gold < 10>>
    Stumpy: Hah! {$gold} doubloons? That barely covers the ink.
    <<jump Options>>

Guards on options: <<if $gold >= 10>> after the option text. When the condition is false, the option shows with available: false. The TUI renders locked options with a distinct style. In your game:

DialogueEvent::Options(opts) => {
    for opt in &opts {
        if opt.available {
            ui.show_option(&opt.text);
        } else {
            ui.show_locked_option(&opt.text);
        }
    }
}

{$gold} in the option and line text is interpolation - evaluated and substituted before the event reaches you.


Commands

<<stamp_permit>>

<<stamp_permit>> emits a DialogueEvent::Command { name: "stamp_permit", args: [], .. }. Your game dispatches on the name:

DialogueEvent::Command { name, args, .. } => match name.as_str() {
    "stamp_permit" => {
        player.inventory.add("travel_permit");
        audio.play_sfx("stamp");
    }
    _ => {}
},

The map seller in services.bub fires <<give_map "sunken_galleon">> or <<give_map "rough_sketch">> depending on your gold. Same pattern - args[0] is the map id.


Cross-file detour

-> Ask about the map seller.
    <<detour MapSeller>>
    Stumpy: That old rogue sell ye anything useful?
    <<jump Options>>

MapSeller is defined in services.bub, not harbour.bub. <<detour>> jumps there, services.bub’s node runs and calls <<return>>, and execution comes back to the very next line - Stumpy’s follow-up question.

This is how you split dialogue by concern: one file per scene or character, cross-file detours for shared beats, no duplication.

Try it: Open services.bub. Notice the <<return>> at the bottom of MapSeller. Try removing it - the detour will still return, because a node that runs off the end without <<return>> returns implicitly. <<return>> is just the explicit form.


Your turn

The harbour is under 80 lines across two files. A few ideas to extend it:

  • Add a dockmaster’s assistant in a dockmaster.bub with her own when: node group that changes based on $gold.
  • Register a has_item("rope") custom function and add an option that only appears if the player carries a rope.
  • Add a <<play_voice "stumpy_greet_01">> command to the opening node and handle it in the Command event.
  • Add a fourth dock worker bark and watch BLRV cycle through all five before repeating.

Next: Snippets

Snippets

Six focused recipes, each answering a specific “can Bubbles do X?” question. Every snippet is a self-contained 30-50 line .bub file built around a pirate scenario, and each one runs independently through the TUI.

cargo run -p bubbles-tui -- examples/snippets/<name>.bub

Press r to reload after editing (re-reads from disk, full reset), R to rerun from the start keeping variable values and <<once>> history, b to step back through individual events.


options

You want branching choices with conditions, and some options that lock out when the player doesn’t qualify.

cargo run -p bubbles-tui -- examples/snippets/options.bub

A duelist challenges you to an insult sword-fight. Whether you can land the devastating insult depends on $sword_skill. Try it with the default value, then change the <<declare $sword_skill = ...>> line and press r to reload - different skill levels unlock different options and route to different outcome nodes.

Key lines from the script:

-> Deliver a devastating insult. <<if $sword_skill >= 3>>
    You: Your sword arm is as weak as a soggy biscuit!
    Duelist: ...
    <<jump Victory>>
-> Stall for time.
    <<jump Escaped>>
-> Surrender immediately.
    <<jump Defeated>>

The <<if>> after an option text is a guard. The option stays visible but available: false when the guard is false. Your game sees this in DialogueOption.available.

Remix ideas:

  • Add a fourth option locked behind $has_special_item
  • Add an <<elseif>> chain inside a branch for tiered outcomes
  • Change the guard to visited("Victory") to lock the re-challenge on return

See Options and Conditionals.


variables

You want persistent state that changes during a conversation, with the current values shown in the dialogue text.

cargo run -p bubbles-tui -- examples/snippets/variables.bub

Griselda’s Grog Shack charges more for each mug you buy. The price climbs and your doubloon count drops, both reflected live in the dialogue. The buy option locks itself when you run dry.

Key lines:

<<declare $doubloons = 12>>
<<declare $grogs_bought = 0>>
<<declare $price = 3>>

-> Buy a mug. <<if $doubloons >= $price>>
    <<set $doubloons = $doubloons - $price>>
    <<set $grogs_bought = $grogs_bought + 1>>
    <<set $price = $price + 2>>
    GrogVendor: Pleasure! That'll be {$price} for the next one.

{$price} in the text is interpolation - evaluated and substituted before the event reaches your game. By the time your code sees the line, it already reads "That'll be 5 for the next one.".

Remix ideas:

  • Branch on $grogs_bought >= 3 for a “you’ve had enough” path
  • Show a “running total spent” line using arithmetic in interpolation: {$price - 2} doubloons wasted
  • Add a <<declare $tipsy = false>> flag that flips after two mugs and changes the dialogue options

See Variables, Expressions, and Interpolation.


commands

You want dialogue to trigger sounds, voice-overs, animations, and other engine events. You also want per-line metadata (portrait cues, audio buses, subtitle styles) to travel with each line.

cargo run -p bubbles-tui -- examples/snippets/commands.bub

Prying open a cursed chest fires <<creak_sound>>, <<play_fanfare>>, <<spawn_particles>>, and <<apply_curse>>. Key lines carry tags like #dramatic and #sfx. The TUI shows commands in the transcript pane so you can see exactly what your event loop would receive.

Key lines from the script:

<<play_fanfare "treasure_found">>
<<spawn_particles "gold_coins" 50>>
Gold coins spill across the sand, glittering in the torchlight. #dramatic
<<apply_curse "greedy_pirate">>
Ghostly Voice: That treasure is CURSED, ye fool! #eerie

In your game:

DialogueEvent::Command { name, args, .. } => match name.as_str() {
    "play_fanfare" => audio.play(&args[0]),
    "spawn_particles" => vfx.spawn(&args[0]),
    "apply_curse" => player.add_curse(&args[0]),
    _ => {}
},
DialogueEvent::Line { line_id, tags, .. } => {
    // Voice-over lookup
    if let Some(id) = &line_id {
        audio.play_voice_over(id);
    }
    // Per-line metadata
    for tag in &tags {
        if tag == "dramatic" { ui.set_subtitle_style("dramatic"); }
        if let Some(sfx) = tag.strip_prefix("sfx ") { audio.one_shot(sfx); }
    }
},

Remix ideas:

  • Add #line:chest_open_01 to the fanfare line and look up a VO clip from line_id
  • Add #portrait shocked and wire it to a portrait swap in your Line handler
  • Add a <<save_checkpoint>> command after the discovery and handle it in Rust

See Commands and Tags and Metadata.


once

You want lines that only play on the first visit, with different content on every return - without managing a flag variable.

cargo run -p bubbles-tui -- examples/snippets/once.bub

Old Barnacle Pete has several legendary tales. Each <<once>> block plays in full on the first visit; every return gets a brief acknowledgement. No $kraken_told variable in sight.

Key lines:

<<once>>
    Pete: Thirty years ago I wrestled a kraken with me bare hands.
    Pete: Eight tentacles, each one thicker than a ship's mast.
    Pete: The kraken wept. Haven't seen one since.
<<else>>
    Pete: As I told ye before. Wrestled a kraken, bit its tentacle, it wept.
<<endonce>>

Multiple <<once>> blocks can be chained in sequence - each tracks its own “seen” state independently. The “once-seen” state is stored with the runner and survives save/load. Press R (rerun) to see the second-visit lines without resetting that history. Press r (reload) to start completely fresh.

Remix ideas:

  • Try <<once if $has_bought_a_drink>> to delay a story until the player’s bought a round
  • Add a <<once>> for a one-off ambient detail that never repeats
  • Add a fourth tale in AnotherTale to extend the chain

See Once Blocks.


saliency

You want ambient dialogue that never repeats immediately, and an NPC whose behaviour changes based on the time of day (or any game state).

cargo run -p bubbles-tui -- examples/snippets/saliency.bub

The dockside has two layers:

Line groups (=>): five dock worker barks cycle with BestLeastRecentlyViewed. Each visit picks the one seen least recently, so the same line never plays twice in a row.

=> Dockworker: Watch yer step on those wet planks!
=> Dockworker: Tide's coming in. Move them crates!
=> Dockworker: I've unloaded seventeen ships today. Seventeen!
=> Dockworker: Cap'n said we're done at sundown. Sundown was an hour ago.
=> Dockworker: Me back's killing me. Should've been a blacksmith.

Node groups: the Storyteller NPC has four nodes with the same title. when: $time_of_day == "..." picks the right one. Change the <<declare $time_of_day = "evening">> line at the top and press r to reload - different time, different story.

title: Storyteller
when: $time_of_day == "morning"
---
Storyteller: Ah, a fresh morning at the docks! Best time for a tale.
===

title: Storyteller
---
Storyteller: Hmm. Can't think of a story right now. Come back later.
===

Remix ideas:

  • Add a sixth bark to the line group and watch BLRV cycle through all six
  • Add a when: visited_count("Storyteller") >= 3 node for a “heard it before” variant
  • Swap BestLeastRecentlyViewed for RandomAvailable in the Rust setup and notice the difference

See Line Groups and Node Groups and Saliency.


markup

You want to annotate a range of text inside a line without changing the display text: bold, italic, coloured, animated, or anything else your renderer can do.

cargo run -p bubbles-tui -- examples/snippets/markup.bub

Tags are stripped from the text before the event arrives. Alongside the clean text you get a spans list that names each annotation and gives its byte range. The terminal UI renders the five built-in tag names it knows: [b], [i], [u], [dim], and [color value=NAME].

Key lines from the script:

Narrator: [b]Bold[/b], [i]italic[/i], and [u]underline[/u] - the essentials.
Narrator: [color value=red]Danger![/color] [color value=green]All clear.[/color]
-> [b]Excited![/b] Let's go.
-> [dim]Cautiously optimistic...[/dim]

In your game:

DialogueEvent::Line { text, spans, .. } => {
    // text  = "Bold, italic, and underline - the essentials."
    // spans = [
    //   MarkupSpan { name: "b",         start: 0,  length: 4  },
    //   MarkupSpan { name: "italic",    start: 6,  length: 6  },
    //   MarkupSpan { name: "underline", start: 18, length: 9  },
    // ]
    for span in &spans {
        match span.name.as_str() {
            "b" => renderer.bold(span.start, span.length),
            "i" => renderer.italic(span.start, span.length),
            "color" => {
                let color = span.properties.iter()
                    .find(|(k, _)| k == "value")
                    .map(|(_, v)| v.as_str())
                    .unwrap_or("white");
                renderer.color(span.start, span.length, color);
            }
            _ => {}
        }
    }
}

The TUI preview shows the markup applied live: bold options, coloured lines, underlined phrases.

Remix ideas:

  • Add [wave] or [shake] tags and handle them in a custom renderer
  • Add [sfx name=coin_drop /] (self-closing) and dispatch audio from the span properties
  • Combine markup with {$variable} interpolation: [b]{$name}[/b] works as expected

See Markup.


Next: API Reference

API Reference

Every public type, trait, and function has full rustdoc. Two places to read it:

  • This site, under /api/bubbles/ - built from the same commit as the guide you’re reading.
  • docs.rs - rebuilt on every crate release (crates.io package bubbles-dialogue, documented API crate bubbles).

Start there for the authoritative signatures, trait definitions, and error types. The guide points into specific pages as you go.

High-traffic items

A quick index of what you’ll look up most often:

What you wantWhere to look
Compile a scriptcompile, compile_many
Drive a dialogueRunner, DialogueEvent
Store variablesVariableStorage, HashMapStorage
Localise linesLineProvider, HashMapProvider
Register host functionsFunctionLibrary
Pick variantsSaliencyStrategy, FirstAvailable, BestLeastRecentlyViewed
Inline markup spansMarkupSpan
Save / loadRunnerSnapshot, Runner::snapshot / restore (serde only for Serialize/Deserialize on the snapshot)
Handle errorsDialogueError
Unity / C# / C shared libraryNot on docs.rs: see the guide chapter and the C header

Feature flags

FlagDefaultEnables
randonrandom, random_range, dice, RandomAvailable
serdeoffSerialize/Deserialize on Value, HashMapStorage, RunnerSnapshot
fulloffBoth rand and serde together

Still not sure?

  • Search the guide (top-right) for keywords like “once”, “option”, “localisation”.
  • Jump into the examples - they cover most of the API in under 200 lines each.
  • Open an issue if something’s unclear. Documentation gaps are bugs.