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:
- Getting Started - go from zero to a running dialogue in ten lines of Rust.
- Tutorial - build a real NPC scene from scratch, one feature at a time.
- The .bub Language - every piece of the script format, one concept per page.
- Integrating with Your Engine - wiring Bubbles into rendering, input, and save systems, including Unity and other native hosts via the C ABI.
- Advanced - snapshots, multi-file projects, WebAssembly.
- Examples - annotated walkthroughs of the demos shipped with the crate.
- API Reference - the full rustdoc, generated fresh for every release.
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:
- The script. A
.bubnode calledStart, with one line of narration. Every node starts withtitle:, a---header separator, the body, and a closing===. - The compile step.
compileparses and validates the source, returning aProgram. Do this once at load time. - The runner.
Runner::newtakes theProgramand a variable storage backend.HashMapStorageis the default in-memory one. We callstartwith the node name, then pull events out withnext_eventuntil it hands backNone.
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:
| Event | When | What to do |
|---|---|---|
Line | A speaker has something to say | Render it; wait for the player to advance |
Options | Branching choice | Show the choices; call select_option(i) |
Command | A host directive like <<play_sound bell>> | Dispatch to your engine |
NodeStarted / NodeComplete | A node begins or ends | Analytics, transitions, scene fades |
DialogueComplete | The whole conversation is done | Clean 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:
DialogueEventis 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
DialogueEventcome from? (Your call torunner.next_event().) - What stops the loop? (Returning
None, usually paired withDialogueEvent::DialogueCompleteon the step before.) - What do you do with an
Optionsevent? (Show the choices, then callselect_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
| Key | What it does |
|---|---|
Enter / Space | Advance; confirm the focused option |
↑ / k, ↓ / j | Navigate options or scroll the transcript |
1 … 9 | Pick an option directly by number |
Tab | Swap focus between the options list and transcript |
b / Backspace | Step back one event |
r | Reload: re-read files from disk, reset everything |
R (Shift+r) | Rerun: restart from the beginning, keeping variables and <<once>> history |
q / Esc | Quit |
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.tomlanduse 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.bubsomewhere 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 = 5and buy once? What does$goldbecome? - 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:
- Every node starts with
title: SomeName. ---separates the header from the body.===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_optionon 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:
| Type | Example values |
|---|---|
Number | 42, 3.14, -1 |
Text | "Aria", "Welcome!" |
Bool | true, 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
HashMapStorageisn’t enough - maybe you want variables to live in your game’s save system - implement theVariableStoragetrait. 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):
| Category | Operators |
|---|---|
| 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
| Function | Returns |
|---|---|
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
| Function | Returns |
|---|---|
int(x) | number truncated to integer |
string(x) | value formatted as Text |
Random (the rand feature, on by default)
| Function | Returns |
|---|---|
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
| Function | Returns |
|---|---|
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:
<<detour PourAle>>pushes the current position and jumps toPourAle.PourAleruns, finishing with<<return>>.- 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
LineorOptionsevent 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 pressR(rerun) to see the second-visit lines without losing the once history. Pressbto 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 as3.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:
| Field | Type | Meaning |
|---|---|---|
name | String | Tag name, e.g. wave |
start | usize | Byte offset into text where the span begins |
length | usize | Byte length of the spanned text. Zero for self-closing tags. |
properties | Vec<(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 nametags: 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
| Strategy | Behaviour |
|---|---|
FirstAvailable | Always the first eligible line (default; deterministic) |
RandomAvailable | Uniformly random (needs the rand feature) |
BestLeastRecentlyViewed | Prefers 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 picks | The player | The saliency strategy |
| Event emitted | Options | Line |
| Intent | Branching story | Variant / 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 fourStorytellernodes wherewhen: $time_of_day == "..."picks the right one. Change the<<declare>>value and pressrto 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
Programper scripting asset, and spin up a freshRunnerwhenever you start a conversation. Runners are cheap; programs are the expensive thing you compiled once.
Configure (optional)
Before starting, you can swap defaults:
use bubbles::saliency::BestLeastRecentlyViewed;
use bubbles::HashMapProvider;
runner.set_saliency(BestLeastRecentlyViewed::new()); // variant picking
runner.set_provider(HashMapProvider::new()); // localisation
runner.library_mut().register("faction", |args| { /* … */ Ok(todo!()) });
These all return the runner (or &mut to it) so you can chain or call them one after the other.
Start
runner.start("Intro")?;
start validates the node exists and primes the runner. Calling it a second time resets execution - handy for “replay this scene” and for restoring from a snapshot.
An error here is almost always a typo in the node name. Bubbles tells you which one.
Pump events
while let Some(event) = runner.next_event()? {
match event {
DialogueEvent::Line { .. } => { /* render */ }
DialogueEvent::Options(opts) => {
runner.select_option(choose(opts))?;
}
DialogueEvent::Command { .. } => { /* dispatch */ }
_ => {}
}
}
next_event returns None when dialogue completes. Until then, it either:
- Returns the next event, or
- Returns an error (runtime type mismatch, bad option index, etc).
If you call next_event again after an Options event without first calling select_option, you get DialogueError::ProtocolViolation. Bubbles won’t guess which option you wanted.
Completion
You’ll see DialogueEvent::DialogueComplete on the last meaningful step, and then None on the next call. Both are fine to treat as “we’re done” - pick whichever fits your loop.
match runner.next_event()? {
Some(DialogueEvent::DialogueComplete) | None => break,
Some(other) => handle(other),
}
Inspecting state mid-run
Mid-run, you can read (and write) state directly:
let storage = runner.storage(); // &S
let storage_mut = runner.storage_mut(); // &mut S
Same for the function library:
runner.library_mut().register("reroll", |_| Ok(/* … */));
This is useful for late-binding custom functions (e.g. when a menu unlocks new capabilities) or for sneaking a variable in from outside:
use bubbles::{Value, VariableStorage};
runner.storage_mut().set("$player_name", Value::Text(player.name.clone()));
For debug HUDs, cheats, or tests you can also read through the runner without touching storage generics:
let _all = runner.all_variables(); // Vec<(String, Value)>; empty unless storage overrides `all_variables`
let _one = runner.variable("$gold");
let _borrowed = runner.variable_ref("$name"); // Cow<Value>, avoids cloning strings when using HashMapStorage
all_variables is implemented for HashMapStorage and lists every key. Custom VariableStorage types get a default empty list unless you override VariableStorage::all_variables.
Threading
Runner is Send when its storage is - that’s true for HashMapStorage. Run dialogue on any thread you like; just don’t share a single runner across threads without synchronisation. The pull-based API is designed to slot into whatever update scheme your engine uses (single thread, job system, task pool).
Next: Handling Events
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>- theSpeaker:prefix, orNonefor 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#tagon the line. Portrait cues, audio buses, subtitle styles, plus#narration/#debugif you used them forline_mode.line_mode: LineMode-Normal,Narration(from a#narrationtag), orDebug(from#debug; wins if both tags are present). Same tags still appear intagsif you need them.spans: Vec<MarkupSpan>- inline markup spans overtext, in source order. Empty when the line has no markup tags. Each span carries aname, bytestart, bytelength, and optionalproperties. 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.falsemeans 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 overtext, same shape as onLine.
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 insetand 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
- Author in your source language (English, whatever).
- Tag every line that needs translating with
#line:some_id. - At runtime, install a
LineProviderthat maps those ids to translated templates. - 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 another: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_idthe provider returnsNonefor. 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:
- 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.
- Never change an id casually. Renaming
greeting_01togreet_01invalidates 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 inArc<Mutex<…>>orArc<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
| Platform | File |
|---|---|
| Linux | target/release/libbubbles_ffi.so |
| macOS | target/release/libbubbles_ffi.dylib |
| Windows | target/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 toMarshal.FreeHGlobalor Cfree(). - UTF-8 byte lengths - all string inputs take a pointer plus a byte count as
nuint. UseEncoding.UTF8.GetBytesand 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 (currently1). - Errors - on
BUBBLES_ERR, callbubbles_last_error()for a NUL-terminated UTF-8 message valid until the nextbubbles_*call on the same thread.Marshal.PtrToStringUTF8reads 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:
- The runner’s internal state - current node, visit counts, which
<<once>>blocks have fired. - Your variable storage - all the
$variablesthe dialogue has touched.
Runner::snapshot and Runner::restore work on a RunnerSnapshot without any feature flags. That covers in-memory bookmarks and anything you can clone yourself.
To write a snapshot or HashMapStorage to JSON, bincode, or your save format, enable the serde feature so those types derive Serialize / Deserialize:
[dependencies]
bubbles-dialogue = { version = "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::UnknownNodefromrestore. Catch it and start a safe fallback node ("MainMenu","RecoveryScene"). - Missing variables → just missing. Your
VariableStorage::getreturnsNone; the script’s<<declare>>reinitialises it. This is why you should almost always use<<declare>>for saved state: it survives script updates gracefully. - Stale
<<once>>ids → harmless. Bubbles doesn’t re-fire once blocks it didn’t save for, but new ones will work normally.
The pattern is: try restore, fall back gracefully, lose as little as possible.
A complete save/load cycle
use bubbles::{compile, DialogueEvent, HashMapStorage, Runner};
fn save(runner: &Runner<HashMapStorage>) -> std::io::Result<()> {
let snap = runner.snapshot();
let bundle = SaveBundle {
snapshot: snap,
storage: runner.storage().clone(),
};
let json = serde_json::to_string(&bundle)?;
std::fs::write("save.json", json)
}
fn load(program: bubbles::Program) -> std::io::Result<Runner<HashMapStorage>> {
let json = std::fs::read_to_string("save.json")?;
let bundle: SaveBundle = serde_json::from_str(&json)?;
let mut runner = Runner::new(program, bundle.storage);
runner.restore(bundle.snapshot).map_err(|e| {
std::io::Error::new(std::io::ErrorKind::Other, e.to_string())
})?;
Ok(runner)
}
#[derive(serde::Serialize, serde::Deserialize)]
struct SaveBundle {
snapshot: bubbles::RunnerSnapshot,
storage: HashMapStorage,
}
One bundle, round-trips cleanly, easy to extend.
When not to use snapshots
If your game uses Bubbles for quick, self-contained conversations (think: short barks, UI flavour text), you probably don’t need snapshotting. The cost of serialising nothing is nothing - but the complexity of adding it might not be worth it.
A good signal: if a conversation can plausibly be interrupted by the player saving, snapshot it. If it runs to completion every time, don’t bother.
Next: Multi-file Projects
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.bubdetours intoMapSeller, which is defined inservices.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:
DialogueEventis#[non_exhaustive]. Serialising it withserde_wasm_bindgenrequires theserdefeature.
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
&stryou 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 = falsedropsrand(~20 KB).- Run
wasm-opt -Ozas a post-build step (included withwasm-pack). - Compile with
lto = "fat"andcodegen-units = 1in 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:
AppStateowns the compiled program and exposes read-only accessors (current_line,options,transcript,error_overlay, etc.).Intentcaptures 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 withTestBackendand assert on buffer contents.terminalis the only module that touches raw mode / stdin / stdout, translating crossterm key events intoIntents 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.bubwith her ownwhen: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 theCommandevent. - 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 >= 3for 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_01to the fanfare line and look up a VO clip fromline_id - Add
#portrait shockedand wire it to a portrait swap in yourLinehandler - 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
AnotherTaleto 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") >= 3node for a “heard it before” variant - Swap
BestLeastRecentlyViewedforRandomAvailablein 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 want | Where to look |
|---|---|
| Compile a script | compile, compile_many |
| Drive a dialogue | Runner, DialogueEvent |
| Store variables | VariableStorage, HashMapStorage |
| Localise lines | LineProvider, HashMapProvider |
| Register host functions | FunctionLibrary |
| Pick variants | SaliencyStrategy, FirstAvailable, BestLeastRecentlyViewed |
| Inline markup spans | MarkupSpan |
| Save / load | RunnerSnapshot, Runner::snapshot / restore (serde only for Serialize/Deserialize on the snapshot) |
| Handle errors | DialogueError |
| Unity / C# / C shared library | Not on docs.rs: see the guide chapter and the C header |
Feature flags
| Flag | Default | Enables |
|---|---|---|
rand | on | random, random_range, dice, RandomAvailable |
serde | off | Serialize/Deserialize on Value, HashMapStorage, RunnerSnapshot |
full | off | Both 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.