Introduction
Replique is a dialogue system written in Rust, with first-class support for Bevy. Dialogues are written in plain text files, usually with the .rep extension, that look like this:
:= start
Alice: Hi! How are you?
Bob: Great, and you? Did you prepare the afternoon meeting?
Alice: Not at all, I was too tired yesterday. I'll get to it right away.
-> Good luck then!
Alice: See you later!
-> I can help if you want.
Alice: Thanks, I'd like that. Meet at the break?
Bob: Perfect, see you!
=> meeting
---
:= meeting
Caroline: Hello everyone, now that we are all here we can start.
Caroline: Alice... You can start...
---
This book describes the language those files are written in, then how to play them in a Bevy game.
Where to go from here
The chapters of Writing dialogues go through the language one piece at a time, and the cheat sheet holds all of it on one page. They are meant for whoever writes the dialogues, and need no Rust.
Using Replique with Bevy is for whoever wires those dialogues into a game: loading a file, showing its lines and choices, and giving it commands and functions of the game to call.
The API of each crate, and the tools around the format, are documented with the crates:
| Crate | What it is |
|---|---|
replique | The engine-agnostic parser and runtime |
bevy_replique | The Bevy plugin built on top of replique |
replique-cli | replique check, to lint dialogue files |
replique-lsp | Language server, for diagnostics as you type |
Cheat sheet
Statements
| Syntax | Meaning |
|---|---|
:= name | Start a node named name |
--- | End the current node |
=> name | Jump to the node name |
<=> name | Jump to the node name, and come back after it |
=> RETURN | Come back to the last <=>, or end the dialogue |
=> END | End the dialogue |
Speaker: text | A line of dialogue |
text | A line with no speaker |
-> text | A choice offered to the player |
-> text [if cond] | A choice only available under a condition |
>> command(arg1, arg2) | Run a command of the game |
>> await command() | Run a command and wait for it to be over |
[let $var = value] | Set a variable |
[let $dict.key = value] | Set a key of a dict |
[if cond], [elif cond], [else] | Play a block only under a condition |
[while cond] | Repeat a block, with [break] and [continue] |
// comment | A comment, up to the end of the line |
Blocks are indented with four spaces.
Inside a line or a choice
| Syntax | Meaning |
|---|---|
[$var], [$a + 1] | Insert the value of an expression |
#name, #name:value | A tag, at the start or the end of the text |
\#word | A word that starts with a # |
Expressions
| Syntax | Meaning |
|---|---|
12, 0.5, "text", word | Integer, float, string, bare-word string |
true, false | Booleans |
{name: "Alice", hp: 7} | A dict |
$var, $dict.key | A variable, a key of a dict |
+, -, *, / | Arithmetic |
==, !=, <, <=, >, >= | Comparison |
and, or, not | Logic, also written &&, ||, ! |
$dict has key | Whether a dict holds a key |
Alice has Angry | Whether a subject has a feature, asked of the game |
name(arg1, arg2) | Call a function |
Builtin functions: upper, lower, capitalize, len, has, abs, min, max, round, floor, ceil. See Commands and functions.
Nodes and jumps
Nodes
A file is a list of nodes. A node is a named piece of dialogue: it starts with := followed by its name, and ends with ---.
:= start
Alice: Hi!
---
:= shop
Merchant: Welcome.
---
A name is made of letters, digits and _, and does not start with a digit. Two nodes of a file cannot share a name, and END and RETURN are reserved.
Neither := nor --- is indented, and nothing but blank lines and comments is written outside of a node.
Jumps
=> leaves the current node for another one. The target can be declared further down the file.
:= start
Alice: Follow me.
=> shop
---
:= shop
Merchant: Welcome.
---
A node that reaches its --- without jumping ends the dialogue. => END ends it from anywhere, which is handy inside a choice or a condition:
:= start
Alice: Are you coming?
-> Yes
Alice: Let's go.
-> No
Alice: Too bad.
=> END
Alice: It is not far.
---
Jumping and coming back
<=> jumps to another node like => does, but comes back once that node is over: the dialogue carries on with the line that follows the <=>.
:= start
Alice: Let me ask the merchant.
<=> haggle
Alice: Right, where were we?
---
:= haggle
Merchant: Ten coins.
Alice: Five.
Merchant: Eight, and that is my last word.
---
Here Alice says Right, where were we? after the merchant’s last line. This is the way to share a piece of dialogue between several places: each of them reaches it with a <=>, and gets the dialogue back afterwards.
The node that is reached does not need to know how it was reached. Its --- comes back when a <=> led to it, and ends the dialogue otherwise.
<=> nest: a node reached with a <=> can use one itself, and each comes back to its own place. A plain => inside such a node moves on to another node without coming back to the one it left, and the <=> still waits: the dialogue comes back when that other node is over.
=> RETURN and => END
=> RETURN does what the --- of the node does, from anywhere in it: it comes back to the last <=>, or ends the dialogue when no <=> led here.
=> END ends the whole dialogue, whatever <=> are waiting.
:= start
Alice: Let me ask the merchant.
<=> haggle
Alice: Right, where were we?
---
:= haggle
Merchant: Ten coins.
-> Never mind
=> RETURN
-> I am leaving
Merchant: Suit yourself.
=> END
-> Five
Merchant: Eight, and that is my last word.
Merchant: A pleasure doing business.
---
With Never mind, the dialogue comes back to Alice right away. With I am leaving, it ends there and Alice says nothing more. With Five, the merchant finishes and the --- comes back to Alice.
Neither END nor RETURN is a node, so <=> END and <=> RETURN are refused.
Indentation
Blocks are nested with indentation: what belongs to a choice, an [if] or a [while] is written one level deeper than it. A level is four spaces. Tabs are refused.
Comments
// starts a comment that runs to the end of the line. A comment can stand on a line of its own or follow a statement.
:= start
// Alice is the first to speak.
Alice: Hi! // said with a smile
---
Lines
A line is what a character says. It is written as the name of the speaker, a :, and the text:
:= start
Alice: Hi! How are you?
Bob: Great, and you?
---
The speaker is whatever comes before the first : of the line, so a name can hold spaces: Old man: Who goes there?.
Lines without a speaker
A line with no : has no speaker. It suits a narrator, or a description:
:= start
The door creaks open.
Alice: Who is there?
---
Since whatever comes before the first : is read as the speaker, a line without a speaker cannot hold a : in its text.
Inserting a value
An expression between [ and ] is evaluated when the line is reached, and replaced by its value:
:= start
[let $name = "Alice"]
[let $gold = 3]
Bob: Hello [$name], you owe me [$gold * 100] cents.
---
Anything described in Expressions can stand between the brackets. A line can also carry tags.
Choices
-> offers a choice to the player. The -> that follow each other at the same indentation form one group, shown together:
:= start
Alice: Are you coming to the meeting?
-> Yes
Bob: With pleasure.
-> No
Bob: Another time.
Alice: Fine.
---
What is indented under a choice is played when the player picks it. The dialogue then carries on after the group: here Alice says Fine. whatever the answer. A choice with nothing under it goes straight there.
Choices nest: a choice written under another one opens a new group, offered once the first one is picked.
:= start
Alice: Something to drink?
-> Yes
Alice: Tea or coffee?
-> Tea
Alice: Right away.
-> Coffee
Alice: I am out of coffee.
-> No
Alice: As you wish.
---
The text of a choice takes inline expressions like a line does, and can carry tags:
:= start
[let $price = 10]
-> Pay [$price] coins
-> Leave
---
Conditional choices
A choice that ends with [if <condition>] is only available when the condition holds:
:= start
[let $is_sick = false]
Alice: How are you?
-> Fine
-> Getting by
-> I am sick [if $is_sick]
---
A choice that is not available is still handed to the game with the others, marked as such: it is up to the game to hide it or to show it greyed out. It cannot be picked either way.
The condition is the last thing on the line. Tags, if any, come before it:
:= start
[let $gold = 3]
-> #hostile Attack
-> Bribe the guard #cost:3 [if $gold >= 3]
---
At least one choice of a group has to be available when the group is reached: a group nobody can answer stops the dialogue with an error. Keep one choice without a condition when in doubt.
Variables and values
Variables
[let] assigns a variable. A variable is written with a $, and exists from its first assignment until the end of the dialogue, whichever node set it.
:= start
[let $gold = 12]
[let $gold = $gold - 2]
Alice: I have [$gold] coins left.
---
Reading a variable that was never assigned stops the dialogue with an error.
Values
| Type | Written as |
|---|---|
| Integer | 12, -3 |
| Float | 0.5, 3.0 |
| String | "Alice", or a bare word Alice |
| Boolean | true, false |
| Dict | {name: "Alice", hp: 7} |
A string is written between double quotes. Inside it, \" is a quote and \\ a backslash. A single word that is neither true, false nor an operator can be written without quotes: Alice and "Alice" are the same string.
Dicts
A dict groups values under keys. Keys are bare words, and a value can be any expression, another dict included:
:= start
[let $player = {name: "Alice", hp: 7, stats: {strength: 3}}]
Alice: I am [$player.name], with [$player.stats.strength] in strength.
[let $player.hp = $player.hp - 4]
[let $player.stats.strength = 4]
---
A . reads the value under a key, and [let] writes one the same way. Reading a key the dict does not hold stops the dialogue with an error: use has to ask first.
{} is an empty dict.
Expressions
An expression is anything that gives a value: the right-hand side of a [let], a condition, an argument, or what stands between [ and ] in the middle of a line.
Operators
From the one that binds the loosest to the one that binds the tightest:
| Operators | Meaning |
|---|---|
or, || | Either side is true |
and, && | Both sides are true |
==, !=, <, <=, >, >=, has | Comparison, and has |
+, - | Addition, subtraction |
*, / | Multiplication, division |
not, !, - | Negation, of a boolean or a number |
Parentheses group: ($a + 1) * 2.
:= start
[let $gold = 12]
[let $rich = $gold > 10 and not ($gold == 13)]
Alice: Half of it is [$gold / 2].
---
Arithmetic works on numbers. An operation between two integers gives an integer, except a division, which always gives a float: 7 / 2 is 3.5. Dividing by zero stops the dialogue with an error.
== and != compare two values of the same type, dicts included.
The types are checked when the file is read wherever they are known: "a" + 1 or [if 3] is reported before the dialogue ever runs. What depends on a variable or on a function is checked when it is evaluated.
has
has asks whether something holds something else. What it means depends on its left-hand side.
On a dict, it tells whether the dict holds a key, whatever the value under it. The key is a bare word, a string or a variable:
:= start
[let $stats = {strength: 12, agility: 0}]
[if $stats has strength]
Alice: I can lift that.
[if not ($stats has magic)]
Alice: No spell from me, though.
[let $key = "agility"]
[if $stats has $key]
Alice: An agility of zero is still an agility.
---
Only the dict itself is searched, not the ones nested in it.
On a string, the question goes to the game: Alice has Angry asks it whether the subject Alice has the feature Angry. What a subject and a feature are is up to the game.
:= start
[if Alice has Angry]
Alice: Give it back!
[else]
Alice: Could I have it back?
---
not binds tighter than has: write not ($stats has magic), with the parentheses.
Functions
A function is called by its name followed by its arguments between parentheses, the ( touching the name: max($a, $b). See Commands and functions.
Conditions and loops
[if], [elif], [else]
[if] plays the block indented under it only when its condition is true. [elif] gives another condition to try when the ones before it failed, and [else] is played when none of them held.
:= shop
[let $gold = 12]
[if $gold >= 10]
Merchant: The lantern is yours.
[let $gold = $gold - 10]
[elif $gold > 0]
Merchant: Not quite enough, come back later.
[else]
Merchant: Come back when you can pay.
Merchant: Goodbye.
---
[elif] and [else] are written at the indentation of the [if] they belong to, and [else] comes last.
A condition has to be a boolean: [if $gold] is an error when $gold is a number, write [if $gold > 0].
[while]
[while] repeats its block as long as its condition is true.
:= start
[let $gold = 0]
[while $gold < 3]
[let $gold = $gold + 1]
Alice: I have [$gold] coins.
Alice: That will do.
---
Inside the block, [break] leaves the loop and [continue] goes straight to the next turn. Both act on the innermost [while], and can be nested in a condition or in a choice:
:= start
[let $turn = 0]
[while true]
[let $turn = $turn + 1]
Alice: Turn [$turn]. One more?
-> Yes
[continue]
-> No
[break]
Alice: Done.
---
A dialogue that runs for too long without anything to show is stopped with an error, so a loop that never ends cannot freeze the game.
Commands and functions
A dialogue reaches into the game in two ways: a command does something, a function answers something.
Commands
>> runs a command of the game: play a sound, move a character, set a flag.
:= start
>> fade_in()
Alice: Do you hear that?
>> play("bell", 0.5, true)
>> set_flag(heard)
---
A command is a name followed by its arguments between parentheses. The parentheses are required even when there is no argument, and the ( touches the name. Each argument is an expression.
Which commands exist, and what they take, is defined by the game.
await
await asks the game to wait until the command is over before the dialogue moves on. Without it, the game is free to carry on right away:
:= start
>> await play_anim("wave")
Alice: Hello!
>> await fade_out()
---
Functions
A function gives a value back, and is called inside an expression:
:= start
[if gold() >= 10]
Merchant: You have [gold()] coins, the lantern is yours.
---
A function never makes the dialogue wait. Like commands, the functions a dialogue can call are defined by the game, on top of the builtins below.
Builtins
These functions are available to every dialogue:
| Function | Gives |
|---|---|
upper(s) | The string in upper case: upper("bell") is "BELL" |
lower(s) | The string in lower case: lower("Bell") is "bell" |
capitalize(s) | The string with a capital first letter |
len(x) | The number of characters of a string, or of keys of a dict |
has(dict, key) | Whether the dict holds the key, as the has operator |
abs(n) | The absolute value, of the type it is given: abs(-3) is 3 |
min(a, b, ...) | The smallest of two numbers or more, all of one type |
max(a, b, ...) | The largest of two numbers or more, all of one type |
round(f), round(f, digits) | The float rounded, to digits decimals when given: round(3.551, 2) is 3.55 |
floor(f) | The largest whole float under it: floor(3.7) is 3.0 |
ceil(f) | The smallest whole float over it: ceil(3.2) is 4.0 |
:= start
[let $name = "alice"]
[let $hp = 7]
Narrator: [capitalize($name)] has [max($hp, 0)] hit points.
---
Tags
A tag is what a line says about itself rather than to the player: a mood, a sound to play, an id. It is written #name or #name:value, and reaches the game apart from the text.
:= start
Alice: #angry Give it back! #sound:alice_01
---
Here the player reads Give it back!, and the game receives the tags angry and sound, the second one with the value alice_01.
A name is made of letters, digits and _, and starts with a letter or _. A value can also hold . and -, and start with a digit: #volume:0.5, #voice:alice-01.ogg.
Where a tag goes
Tags are written at the start or at the end of the text, as many as needed. A tag in the middle of a sentence is not read: it stays in the text, with a warning. Giving the same name twice on a line is a warning too.
A line can be made of tags alone:
:= start
Alice: #silence
---
On a choice
A choice takes tags the same way. When it has a condition, the tags come before it:
:= start
[let $gold = 3]
-> #hostile Attack
-> Bribe the guard #cost:3 [if $gold >= 3]
---
Writing a #
A # only opens a tag after a blank and before a letter, so C# and #1 are plain text. To write a word that starts with a #, put a \ in front of it:
:= start
Agent #2: We are #1 in C#.
Alice: \#angry is trending.
---
Getting started
bevy_replique is the Bevy plugin for Replique. A .rep file is an asset, a running dialogue is an entity, and the game talks to it with messages.
Installing
cargo add bevy_replique
Which version of Bevy each release works with is listed in the README.
Everything used in this part of the book comes from the prelude:
#![allow(unused)]
fn main() {
use bevy_replique::prelude::*;
}
A first dialogue
Save this as assets/dialogue/hello.rep:
:= start
Alice: Hi! How are you?
Bob: Great, and you?
---
Three things are needed to play it: the plugin, a runner holding the dialogue, and a message telling the runner where to start.
use bevy::prelude::*;
use bevy_replique::prelude::*;
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_plugins(RepliquePlugin)
.add_systems(Startup, setup)
.add_systems(Update, show_line)
.run();
}
fn setup(
mut commands: Commands,
assets: Res<AssetServer>,
mut start: MessageWriter<StartDialogue>,
) {
let dialogue: Handle<RepliqueDialogue> = assets.load("dialogue/hello.rep");
let runner = commands.spawn(DialogueRunner::new(dialogue)).id();
start.write(StartDialogue {
runner,
node: "start".into(),
});
}
/// Prints every line, then asks the runner to carry on.
fn show_line(
mut lines: MessageReader<DialogueLine>,
mut resume: MessageWriter<ResumeDialogue>,
) {
for line in lines.read() {
match &line.speaker {
Some(speaker) => println!("{speaker}: {}", line.text),
None => println!("{}", line.text),
}
resume.write(ResumeDialogue {
token: line.token,
input: ResumeInput::Advance,
});
}
}
RepliquePlugin registers the asset loader for .rep and .replique files, the messages, and the systems that run the dialogues.
DialogueRunner is a component: the entity that holds it is the running dialogue. It can be started before its asset is loaded, the start is kept pending until the file is there.
StartDialogue names the runner and the node to enter. A runner can be started again once its dialogue is finished, on the same node or on another one.
When a file has errors
A file with an error does not load: the asset fails, and the error Bevy logs holds the full report, with the line and the reason of each problem.
error[jump-unknown-node]: jump to unknown node `nope`
--> dialogue/hello.rep:3:1
|
3 | => nope
| ^^^^^^^
The same report is given by replique check from the command line and by the language server in the editor, before the game is ever run.
Examples
The repository has five runnable examples, from a minimal UI to custom commands, functions, features and several dialogues at once:
cargo run -p bevy_replique --example bevy_simple
Showing a dialogue
A runner plays its dialogue until it needs the game, then stops and says why with a message. The game answers with a ResumeDialogue, and the runner carries on to the next stop.
| The runner sends | When it reaches | The game answers with |
|---|---|---|
DialogueLine | A line | ResumeInput::Advance |
DialogueChoices | A group of choices | ResumeInput::Select |
DialogueCommand | A >> command | Depends if it is awaited, see Commands |
DialogueFinished | The end of the dialogue | Nothing |
Lines
A DialogueLine holds the speaker, if there is one, the text with its inline expressions already replaced by their values, and the tags of the line.
A game usually shows the line, then waits for the player before moving on. The message has to be kept until then, or at least its token:
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use bevy_replique::prelude::*;
/// The line on screen, waiting for the player.
#[derive(Resource, Default)]
struct CurrentLine(Option<DialogueToken>);
fn show_line(mut lines: MessageReader<DialogueLine>, mut current: ResMut<CurrentLine>) {
for line in lines.read() {
match &line.speaker {
Some(speaker) => println!("{speaker}: {}", line.text),
None => println!("{}", line.text),
}
current.0 = Some(line.token);
}
}
fn next_line(
keys: Res<ButtonInput<KeyCode>>,
mut current: ResMut<CurrentLine>,
mut resume: MessageWriter<ResumeDialogue>,
) {
if !keys.just_pressed(KeyCode::Space) {
return;
}
if let Some(token) = current.0.take() {
resume.write(ResumeDialogue {
token,
input: ResumeInput::Advance,
});
}
}
let mut app = App::new();
app.init_resource::<CurrentLine>().add_systems(Update, (show_line, next_line));
}
Tokens
Every message of a runner carries a DialogueToken, and a ResumeDialogue has to hand that same token back. A token is good for one stop only: the runner makes a new one each time it moves.
An answer that arrives late is therefore dropped instead of advancing whatever the dialogue has reached in the meantime. Pressing the key twice on a line does not skip the next one, and an animation that ends after the player skipped it does nothing.
token.runner() gives the entity of the runner, which is how several dialogues running at once are told apart.
Choices
A DialogueChoices holds the whole group. Each DialogueChoice has its text, its tags, its index in the group, and whether it is available.
:= start
[let $gold = 3]
Guard: Halt!
-> Talk your way in
Guard: Fine, go ahead.
-> Bribe the guard [if $gold >= 10]
Guard: I saw nothing.
---
A choice whose condition does not hold is still in the list, with available set to false: the game decides whether to hide it or to show it greyed out. Its index is the one to send back, whatever is shown.
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use bevy_replique::prelude::*;
/// The choices on screen, waiting for the player.
#[derive(Resource, Default)]
struct CurrentChoices(Option<DialogueChoices>);
fn show_choices(mut groups: MessageReader<DialogueChoices>, mut current: ResMut<CurrentChoices>) {
for group in groups.read() {
for choice in &group.choices {
if choice.available {
println!("[{}] {}", choice.index + 1, choice.text);
} else {
println!("[-] {} (unavailable)", choice.text);
}
}
current.0 = Some(group.clone());
}
}
fn pick_choice(
keys: Res<ButtonInput<KeyCode>>,
mut current: ResMut<CurrentChoices>,
mut resume: MessageWriter<ResumeDialogue>,
) {
const KEYS: [KeyCode; 3] = [KeyCode::Digit1, KeyCode::Digit2, KeyCode::Digit3];
let Some(group) = ¤t.0 else {
return;
};
// Only an available choice can be picked.
let picked = group
.choices
.iter()
.find(|choice| choice.available && KEYS.get(choice.index).is_some_and(|key| keys.just_pressed(*key)));
if let Some(choice) = picked {
resume.write(ResumeDialogue {
token: group.token,
input: ResumeInput::Select(choice.index),
});
current.0 = None;
}
}
let mut app = App::new();
app.init_resource::<CurrentChoices>().add_systems(Update, (show_choices, pick_choice));
}
Selecting a choice that is not available, or an index outside of the group, is an error: only ever send the index of an available choice.
End
DialogueFinished is sent when the dialogue reaches the end of a node with no <=> to come back to, or a => END. It carries the entity of the runner rather than a token, since there is nothing left to resume.
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use bevy_replique::prelude::*;
fn close_dialogue(mut finished: MessageReader<DialogueFinished>, mut commands: Commands) {
for message in finished.read() {
// Done with this dialogue: the runner can go.
commands.entity(message.runner).despawn();
}
}
let mut app = App::new();
app.add_systems(Update, close_dialogue);
}
Despawning the runner is not required. A finished runner can be given a new StartDialogue instead, and it keeps the variables the dialogue has set.
Schedule
The runners move in PostUpdate, in the DialogueSystem::Runner set, and the commands run right after in DialogueSystem::Commands. A ResumeDialogue written during Update is therefore answered in the same frame.
Commands
A command is how a dialogue does something in the game. In Bevy, a command is a system.
:= start
Alice: Take this.
>> give("lantern", 1)
>> set_flag(met_alice)
---
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use bevy::platform::collections::{HashMap, HashSet};
use bevy_replique::prelude::*;
#[derive(Resource, Default)]
struct Inventory(HashMap<String, i64>);
#[derive(Resource, Default)]
struct Flags(HashSet<String>);
/// `>> give("lantern", 1)`
#[replique_command]
fn give(In((item, count)): In<(String, i64)>, mut inventory: ResMut<Inventory>) {
*inventory.0.entry(item).or_default() += count;
}
/// `>> set_flag(met_alice)`
#[replique_command]
fn set_flag(In(flag): In<String>, mut flags: ResMut<Flags>) {
flags.0.insert(flag);
}
let mut app = App::new();
app.init_resource::<Inventory>().init_resource::<Flags>();
app.add_dialogue_command(give)
.add_dialogue_command(set_flag);
}
#[replique_command] declares the system as a command, under the name of the function. add_dialogue_command registers it. Apart from its first parameter, a command is an ordinary system: it takes resources, queries and Commands like any other.
The dialogue moves on by itself once the command has run, there is no ResumeDialogue to send.
To give a command another name than the one of its function, write #[replique_command(name = "set_flag")].
The name becomes a type
#[replique_command] replaces the function by a type of the same name, which is what add_dialogue_command(give) is handed. A side effect is that this name can no longer be given to a variable or to a parameter, wherever the command is in scope.
It happens more often than it sounds, because a command tends to be named after the thing it is about, and so does the resource holding that thing. Here >> inventory() opens the inventory screen, and give can no longer call its own parameter inventory:
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use bevy::platform::collections::HashMap;
use bevy_replique::prelude::*;
#[derive(Resource, Default)]
struct Inventory(HashMap<String, i64>);
#[derive(Resource, Default)]
struct InventoryScreen { open: bool }
/// `>> inventory()`
#[replique_command]
fn inventory(In(()): In<()>, mut screen: ResMut<InventoryScreen>) {
screen.open = true;
}
/// `>> give("lantern", 1)`
#[replique_command]
fn give(In((item, count)): In<(String, i64)>, mut inventory: ResMut<Inventory>) {
*inventory.0.entry(item).or_default() += count;
}
}
error[E0530]: function parameters cannot shadow unit structs
|
2 | #[replique_command]
| ------------------- the unit struct `inventory` is defined here
...
10 | fn give(In((item, count)): In<(String, i64)>, mut inventory: ResMut<Inventory>) {
| ^^^^^^^^^ cannot be named the same as a unit struct
The same goes for a let inventory = ... or for a field taken out of a struct under that name. There are two ways out.
Rename what is bound. The command keeps the name the dialogue uses, and the parameter takes another one:
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use bevy::platform::collections::HashMap;
use bevy_replique::prelude::*;
#[derive(Resource, Default)]
struct Inventory(HashMap<String, i64>);
#[derive(Resource, Default)]
struct InventoryScreen { open: bool }
#[replique_command]
fn inventory(In(()): In<()>, mut screen: ResMut<InventoryScreen>) {
screen.open = true;
}
/// `>> give("lantern", 1)`
#[replique_command]
fn give(In((item, count)): In<(String, i64)>, mut bag: ResMut<Inventory>) {
*bag.0.entry(item).or_default() += count;
}
}
Or rename the function, and give the name of the command in the attribute. A prefix keeps the Rust names out of the way, and reads well in a file that holds many commands:
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use bevy::platform::collections::HashMap;
use bevy_replique::prelude::*;
#[derive(Resource, Default)]
struct Inventory(HashMap<String, i64>);
#[derive(Resource, Default)]
struct InventoryScreen { open: bool }
/// `>> inventory()`
#[replique_command(name = "inventory")]
fn cmd_inventory(In(()): In<()>, mut screen: ResMut<InventoryScreen>) {
screen.open = true;
}
/// `>> give("lantern", 1)`
#[replique_command(name = "give")]
fn cmd_give(In((item, count)): In<(String, i64)>, mut inventory: ResMut<Inventory>) {
*inventory.0.entry(item).or_default() += count;
}
let mut app = App::new();
app.init_resource::<Inventory>().init_resource::<InventoryScreen>();
app.add_dialogue_command(cmd_inventory)
.add_dialogue_command(cmd_give);
}
Arguments
What the dialogue writes between the parentheses arrives through In, already converted to the types the system asks for.
| The system asks for | The dialogue writes |
|---|---|
In<()> | >> fade_in() |
In<String> | >> set_flag(met_alice) |
In<(String, i64)> | >> give("lantern", 1) |
In<(String, Option<f32>)> | >> play("bell") or >> play("bell", 0.5) |
In<DialogueArgs> | Any number of arguments, as raw Value |
A tuple takes up to eight arguments. A trailing Option makes the last ones optional.
The types are matched as the dialogue writes them:
| Value in the dialogue | Rust type |
|---|---|
12 | i8 to i64, u8 to u64, isize, usize |
0.5 | f32, f64 |
"text", or a bare word | String |
true, false | bool |
| Anything | Value |
An integer is not converted to a float: a command that takes an f32 has to be written >> play("bell", 1.0), not >> play("bell", 1).
A call that does not fit is logged and skipped, and the dialogue carries on. The log is worded for whoever wrote the line: argument 1: expected float, got int. A command the game never registered is logged the same way.
Waiting for a command
Some commands take time: an animation, a fade, a pause. The dialogue waits for one when two things agree, the line says await and the command answers CommandFlow::Blocking.
:= start
Alice: Wait for it...
>> await pause(2.0)
Alice: Now!
---
The command takes the token of the call from the DialogueCall parameter, and keeps it for whatever will end the wait. Here a timer:
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use bevy_replique::prelude::*;
#[derive(Component)]
struct Pause {
timer: Timer,
token: DialogueToken,
}
/// `>> await pause(2.0)`
#[replique_command]
fn pause(In(seconds): In<f32>, call: DialogueCall, mut commands: Commands) -> CommandFlow {
commands.spawn(Pause {
timer: Timer::from_seconds(seconds, TimerMode::Once),
token: call.token(),
});
CommandFlow::Blocking
}
/// Resumes the dialogue of every pause that is over.
fn end_pauses(
time: Res<Time>,
mut pauses: Query<(Entity, &mut Pause)>,
mut resume: MessageWriter<ResumeDialogue>,
mut commands: Commands,
) {
for (entity, mut waiting) in &mut pauses {
if waiting.timer.tick(time.delta()).is_finished() {
resume.write(ResumeDialogue {
token: waiting.token,
input: ResumeInput::Advance,
});
commands.entity(entity).despawn();
}
}
}
let mut app = App::new();
app.add_dialogue_command(pause)
.add_systems(Update, end_pauses);
}
Without the await, the dialogue does not wait even if the command answers Blocking: the line did not ask for it. The pause still happens, the dialogue simply carries on, and a warning is logged.
call.runner() gives the entity of the runner that called the command, for a command that acts on its own dialogue.
Types of your own
#[derive(RepliqueValue)] lets a type of the game be written in a dialogue. An enum without data is written as a word, a struct with named fields as a dict:
:= start
>> face(Alice, left)
>> spawn({name: "Bob", hp: 12})
---
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use bevy_replique::prelude::*;
#[derive(RepliqueValue, Debug)]
enum Direction {
#[replique(alias = "left")]
Left,
#[replique(alias = "right")]
Right,
}
#[derive(RepliqueValue, Debug)]
struct Stats {
name: String,
#[replique(alias = "hp")]
health: i64,
}
/// `>> face(Alice, left)`
#[replique_command]
fn face(In((who, direction)): In<(String, Direction)>) {
info!("{who} now faces {direction:?}");
}
/// `>> spawn({name: "Bob", hp: 12})`
#[replique_command]
fn spawn(In((stats,)): In<(Stats,)>) {
info!("spawning {stats:?}");
}
let mut app = App::new();
app.add_dialogue_command(face)
.add_dialogue_command(spawn);
}
A variant is matched as it is spelled, Left here: alias gives it the spelling the dialogue uses. On a field, alias is the key of the dict.
A word the enum does not know, or a key the dict lacks, refuses the call with a message that names it: argument 1: `up` is not a `Direction` , argument 0: key `hp`: missing.
A type of your own is taken inside a tuple, (Stats,) for a single argument.
Commands with several shapes
#[derive(RepliqueArgs)] reads the whole call instead of one argument, for the calls a tuple cannot describe.
An enum picks its variant from the first word, so that one command covers several shapes:
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use bevy_replique::prelude::*;
/// `>> camera(shake)` or `>> camera(move, 120.0, 40.0)`
#[derive(RepliqueArgs, Debug)]
enum CameraOrder {
#[replique(alias = "shake")]
Shake,
#[replique(alias = "move")]
MoveTo { x: f32, y: f32 },
}
#[replique_command]
fn camera(In(order): In<CameraOrder>) {
info!("camera: {order:?}");
}
let mut app = App::new();
app.add_dialogue_command(camera);
}
A field marked #[variadic] collects every argument that is left:
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use bevy_replique::prelude::*;
/// `>> add_scene(Alice)` or `>> add_scene(Alice, Bob, Caroline)`
#[derive(RepliqueArgs, Debug)]
struct Cast(#[variadic] Vec<String>);
#[replique_command]
fn add_scene(In(Cast(characters)): In<Cast>) {
info!("on stage: {characters:?}");
}
let mut app = App::new();
app.add_dialogue_command(add_scene);
}
Reading the commands yourself
Every command also goes out as a DialogueCommand message, with its name, its args and whether it is awaited. A game can read it to log or to observe what a dialogue does. The registered commands remain the way to act on them: a command with no system registered under its name is reported as an error.
Functions
A function is how a dialogue asks the game something. Like a command, it is a system; unlike a command, it gives a value back and the dialogue never waits for it.
:= shop
Merchant: Welcome! You have [gold()] coins.
[if gold() >= price_of("lantern")]
Merchant: The lantern is yours.
[else]
Merchant: Come back when you can pay.
---
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use bevy::platform::collections::HashMap;
use bevy_replique::prelude::*;
#[derive(Resource)]
struct Gold(i64);
#[derive(Resource, Default)]
struct Prices(HashMap<String, i64>);
/// `[gold()]`
#[replique_function]
fn gold(In(()): In<()>, purse: Res<Gold>) -> i64 {
purse.0
}
/// `[price_of("lantern")]`
#[replique_function]
fn price_of(In(item): In<String>, prices: Res<Prices>) -> Result<i64, String> {
prices
.0
.get(&item)
.copied()
.ok_or_else(|| format!("nothing called `{item}` is for sale"))
}
let mut app = App::new();
app.insert_resource(Gold(12)).init_resource::<Prices>();
app.add_dialogue_function(gold)
.add_dialogue_function(price_of);
}
#[replique_function] and add_dialogue_function work as their command counterparts do, name = "..." included. The arguments are read the same way, see Arguments. The name of the function becomes a type here too, which is why the resource above is not bound to a parameter called gold.
What a function gives back
| Rust type | Value in the dialogue |
|---|---|
| Integers | An integer |
f32, f64 | A float |
String | A string |
bool | A boolean |
HashMap<String, Value> | A dict |
A type deriving RepliqueValue | A word or a dict, as it is written |
Value | Itself |
A struct that derives RepliqueValue comes back as a dict, which the dialogue reads key by key:
:= start
[let $stats = stats_of(Alice)]
[if $stats.hp > 10]
Alice: I feel fine, with [$stats.hp] hit points.
---
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use bevy_replique::prelude::*;
#[derive(Component)]
struct Character {
name: String,
hp: i64,
gold: i64,
}
#[derive(RepliqueValue)]
struct Stats {
hp: i64,
gold: i64,
}
/// `[let $stats = stats_of(Alice)]`
#[replique_function]
fn stats_of(In(who): In<String>, characters: Query<&Character>) -> Result<Stats, String> {
characters
.iter()
.find(|character| character.name == who)
.map(|character| Stats {
hp: character.hp,
gold: character.gold,
})
.ok_or_else(|| format!("{who} is not on the scene"))
}
let mut app = App::new();
app.add_dialogue_function(stats_of);
}
When there is no answer
A function that cannot answer returns a Result. On an Err, the dialogue stops where it is and the error is logged: there is no value to carry on with.
Calling a function the game never registered stops the dialogue the same way.
A function only reads
A function can read anything a system can, but cannot change the world: registering one that takes a ResMut, a mutable query or Commands does not compile. A dialogue evaluates its expressions whenever it needs to, and a function that changed something would do it at times nobody chose. Changing the game is what a command is for.
The builtins
The builtin functions, upper, max and the others, are always there and need no registration.
Subjects and features
has asks the game a yes-or-no question without calling anything: Alice has Angry asks whether the entity named Alice holds the component named Angry.
:= start
[if Alice has Angry]
Alice: Give it back!
[else]
Alice: Could I have it back?
---
Neither name means anything to Bevy, so the game gives both.
A subject is an entity with a name, given by the RepliqueSubject component. A feature is a component with a name, given by register_replique_feature:
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use bevy_replique::prelude::*;
#[derive(Component)]
struct Angry;
fn spawn_alice(mut commands: Commands) {
commands.spawn((RepliqueSubject::new("Alice"), Angry));
}
let mut app = App::new();
app.register_replique_feature::<Angry>("Angry")
.add_systems(Startup, spawn_alice);
}
From there Alice has Angry is true exactly while that entity holds an Angry. Only the presence of the component is looked at, so a feature follows the game without any code: remove the component and the dialogue takes the other branch.
Names are matched as they are written, case included.
Reading the value of a feature
A feature can also hold a value, read with a . after the name of the subject:
:= start
[if Alice has Mana and Alice.Mana >= 10.0]
Alice: I have [Alice.Mana] mana, enough for a spell.
[else]
Alice: I am out of mana.
---
Such a feature is registered with register_replique_feature_value, which asks the component to be Clone and IntoValue. #[derive(RepliqueValue)] already implement IntoValue:
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use bevy_replique::prelude::*;
#[derive(Component, Clone, RepliqueValue)]
struct Mana(f64);
fn spawn_alice(mut commands: Commands) {
commands.spawn((RepliqueSubject::new("Alice"), Mana(12.0)));
}
let mut app = App::new();
app.register_replique_feature_value::<Mana>("Mana")
.add_systems(Startup, spawn_alice);
}
The value is read off the component each time the dialogue asks, so it follows the game. A struct with named fields comes as a dict, read further with another .: Alice.Stats.hp.
Reading the feature of a subject that does not hold it stops the dialogue with an error.
Unknown names
A subject or a feature the game was never given is a mistake in the dialogue, not a false: it stops the dialogue with an error. A typo therefore does not quietly send the player down the [else].
The same name in two dialogues
A subject is visible to every dialogue. only_for restricts it to the dialogue of one runner, which lets two dialogues running side by side each have their own Guard:
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use bevy_replique::prelude::*;
#[derive(Component)]
struct Angry;
fn spawn_guards(mut commands: Commands, assets: Res<AssetServer>) {
let dialogue: Handle<RepliqueDialogue> = assets.load("dialogue/gate.rep");
let north_gate = commands.spawn(DialogueRunner::new(dialogue.clone())).id();
let south_gate = commands.spawn(DialogueRunner::new(dialogue)).id();
commands.spawn((RepliqueSubject::new("Guard").only_for(north_gate), Angry));
commands.spawn(RepliqueSubject::new("Guard").only_for(south_gate));
}
let mut app = App::new();
app.add_systems(Startup, spawn_guards);
}
In the dialogue of the north gate, Guard has Angry is true; in the one of the south gate, it is false.
A subject restricted to the runner that asks comes before one visible to all, so a dialogue can also have its own Alice in place of the usual one. Two subjects of the same name at the same level are ambiguous, and asking about them is an error.
Tags
The tags of a line or of a choice reach the game apart from the text, in the tags field of DialogueLine and of DialogueChoice. What they mean is up to the game.
:= start
Alice: #angry Give it back! #sound:alice_01
-> #hostile Refuse
-> Give it back #cost:3
---
A Tag has a name, and a value when one is written after the :. The value is kept as text, whatever it looks like: #volume:0.5 holds the string 0.5.
On a line
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use bevy_replique::prelude::*;
fn react_to_tags(mut lines: MessageReader<DialogueLine>) {
for line in lines.read() {
for tag in &line.tags {
match (tag.name.as_str(), tag.value.as_deref()) {
// `#angry`
("angry", None) => info!("{:?} is angry", line.speaker),
// `#sound:alice_01`
("sound", Some(sound)) => info!("playing {sound}"),
// `#volume:0.5`
("volume", Some(volume)) => match volume.parse::<f32>() {
Ok(volume) => info!("volume set to {volume}"),
Err(_) => warn!("`{volume}` is not a volume"),
},
_ => {}
}
}
}
}
let mut app = App::new();
app.add_systems(Update, react_to_tags);
}
The text of the line comes without its tags: here line.text is Give it back!.
On a choice
Each choice carries its own tags, which suits what the interface needs to know to draw it: an icon, a cost, a colour.
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use bevy_replique::prelude::*;
fn show_choices(mut groups: MessageReader<DialogueChoices>) {
for group in groups.read() {
for choice in &group.choices {
let hostile = choice.tags.iter().any(|tag| tag.name == "hostile");
let cost = choice
.tags
.iter()
.find(|tag| tag.name == "cost")
.and_then(|tag| tag.value.as_deref());
match (hostile, cost) {
(true, _) => println!("[!] {}", choice.text),
(false, Some(cost)) => println!("{} ({cost} coins)", choice.text),
(false, None) => println!("{}", choice.text),
}
}
}
}
let mut app = App::new();
app.add_systems(Update, show_choices);
}
Tags or commands?
A tag describes the line it is on, and arrives with it: a mood, a voice-over file, a portrait to show. A command is a step of its own, played between two lines, and the dialogue can wait for it. A sound that goes with a line is a tag; a door that opens before the next line is a command.
Checking the tags
A typo in a tag is silent: #angy is a valid tag that the game ignores. Declaring the tags of the project in a replique.toml lets replique check and the editor report the ones the game does not know. See the replique-cli README.