Good to see you again. In the previous lesson, you built stable identifiers and a typed, serializable store for mutable narrative variables. That gives the runtime a safe way to represent changing facts such as affinity, flags, and player-entered text.
Now we turn to the authored side of the narrative system: dialogue nodes and the choices a player can make from them. These are compiled content definitions, not runtime state. A node describing Mira’s introduction should remain exactly the same throughout a playthrough; the runtime only records which node is active and which choices have been selected.
By the end of this lesson, you will have immutable Rust structures that represent:
- a dialogue line, optionally spoken by a character;
- a terminal node that closes the current conversation;
- a branching node containing an ordered set of player choices;
- stable, presentation-neutral references to text and destination nodes;
- structural validation for malformed local choice sets.
The next lesson will extend choices with availability predicates evaluated against the variable state you have just built.
Content definitions are not game state
A sandbox visual novel has two different kinds of narrative data.
Definitions are authored once in Rust source and compiled into the game:
- “Mira says this line.”
- “This choice is labelled ‘Ask about the lighthouse.’”
- “Selecting that choice leads to this node.”
- “This conversation ends here.”
Runtime state changes during play:
- the currently active node;
- the values of narrative variables;
- quest, inventory, time, and character state;
- an event trace or save snapshot.
Keeping these distinct prevents a subtle but damaging architectural drift: treating authored dialogue as mutable data that the UI or gameplay code can rewrite. The engine should be able to ask, “What does node mira.first_meeting define?” and receive the same answer on every run with the same compiled content.
A dialogue node therefore must not contain:
- Godot
Node,Texture2D,GString, or resource references; - mutable variable values;
- callbacks or UI signals;
- a cached “currently available choices” list;
- presentation layout data such as pixel positions.
It may contain semantic references: a CharacterId identifying the speaker, a localization key identifying the line, and NarrativeNodeId values identifying possible destinations. The Godot adapter will eventually turn a presentation-neutral view model into labels, portraits, and buttons, but it must not become the owner of narrative content.
For player-facing authored text, introduce a stable key type now rather than storing prose directly in dialogue definitions:
// crates/vn_engine/src/id.rs
define_stable_id!(LocalizationKey);
This does not implement localization yet. It establishes the important boundary: dialogue content refers to stable message identities such as dialogue.mira.first_meeting.line, rather than embedding English strings that would later have to be extracted and replaced.
Model the valid node shapes explicitly
A dialogue node has a shared core:
- its stable ID;
- an optional speaker;
- a localization key for the displayed line.
Its ending is one of two mutually exclusive possibilities:
- The player is presented with one or more choices.
- The dialogue interaction ends.
This is exactly the kind of “one of several valid forms” that Rust enums model well. An enum makes the terminal-versus-branching distinction explicit, rather than relying on an ambiguous convention such as “an empty Vec means the conversation is over.”
Rust Data Modelling Without Classes
Watch “Rust Data Modelling Without Classes” by No Boilerplate for a concise refresher on Rust enums as algebraic sum types and on exhaustive pattern matching. The examples are introductory, but the design principle is directly useful here: represent valid alternatives in the type model rather than encoding them as loosely related flags.
Watch enums and matching. Focus on the distinction between a conventional label enum and an enum whose variants carry different data, and on how exhaustive match expressions force later runtime code to handle every node ending deliberately.
For the narrative engine, define an explicit exit enum:
// crates/vn_engine/src/narrative/definition.rs
use std::collections::BTreeSet;
use serde::Serialize;
use crate::id::{
CharacterId,
ChoiceId,
LocalizationKey,
NarrativeNodeId,
};
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum DialogueExit {
Choices(Vec<ChoiceDefinition>),
End,
}
impl DialogueExit {
#[must_use]
pub fn choices(&self) -> Option<&[ChoiceDefinition]> {
match self {
Self::Choices(choices) => Some(choices),
Self::End => None,
}
}
#[must_use]
pub const fn is_terminal(&self) -> bool {
matches!(self, Self::End)
}
}
DialogueExit::Choices owns a Vec, because the node’s choices are authored in a meaningful display order. The first choice should remain first when rendered, included in a deterministic content fingerprint, or recorded in diagnostics.
Do not use a HashMap<ChoiceId, ChoiceDefinition> for the displayed set. A map is useful later for lookup in a registry, but it is the wrong primary representation for an authored menu:
- a map does not express intended UI order;
- a hash map’s traversal order is not a gameplay contract;
- content authors should be able to see the exact order of choices in source.
End means that the dialogue interaction has reached a terminal node. It does not necessarily mean that the entire game has ended; it may simply close a conversation and return the player to sandbox interaction selection.
Define choices as immutable authored edges
A player choice is the authored edge between one node and another. At this stage, it needs only three facts:
| Field | Meaning | Why it is not presentation state |
|---|---|---|
id | Stable identity of this choice | Useful for logs, event traces, and validation |
label_key | Localized label to show the player | The key is content; resolved text comes later |
target_node | Node entered if selected | A semantic graph reference, not a Godot scene path |
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ChoiceDefinition {
id: ChoiceId,
label_key: LocalizationKey,
target_node: NarrativeNodeId,
}
impl ChoiceDefinition {
#[must_use]
pub fn new(
id: ChoiceId,
label_key: LocalizationKey,
target_node: NarrativeNodeId,
) -> Self {
Self {
id,
label_key,
target_node,
}
}
#[must_use]
pub fn id(&self) -> &ChoiceId {
&self.id
}
#[must_use]
pub fn label_key(&self) -> &LocalizationKey {
&self.label_key
}
#[must_use]
pub fn target_node(&self) -> &NarrativeNodeId {
&self.target_node
}
}
The fields are intentionally private. Content crates construct a choice through new; consumers inspect it through shared references. No public method exposes &mut ChoiceDefinition, and no caller can reach inside a node to mutate its stored Vec.
Clone does not weaken this boundary. Cloning creates an independent owned copy; changing that copy does not change the compiled definition held by the content registry. The immutable API surface matters because the engine’s runtime can safely share references to definitions without worrying that a UI callback or gameplay command has silently altered authored narrative data.
The choice ID should be stable and descriptive, for example:
mira.first_meeting.greet
mira.first_meeting.leave
Later validation will detect duplicate choice IDs across the entire registered content graph. In this lesson, the node constructor will already reject duplicate IDs within one local menu, where they would make selection ambiguous immediately.
Notice what is deliberately absent:
- availability conditions: the next lesson adds them;
- effects: a later lesson defines controlled state mutations;
- raw text: localization keys replace prose;
- Godot button references: those belong exclusively in the Rust presentation bridge;
- a target scene path: narrative routing is not Godot scene routing.
Encapsulate a complete dialogue node
With the exit and choice structures in place, the node definition is straightforward. Use constructors to make malformed node shapes difficult to create.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct DialogueNodeDefinition {
id: NarrativeNodeId,
speaker: Option<CharacterId>,
line_key: LocalizationKey,
exit: DialogueExit,
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum DialogueDefinitionError {
#[error("branching node {node_id} must contain at least one choice")]
EmptyChoiceSet {
node_id: NarrativeNodeId,
},
#[error(
"branching node {node_id} defines choice {choice_id} more than once"
)]
DuplicateChoiceId {
node_id: NarrativeNodeId,
choice_id: ChoiceId,
},
}
impl DialogueNodeDefinition {
pub fn branching(
id: NarrativeNodeId,
speaker: Option<CharacterId>,
line_key: LocalizationKey,
choices: impl IntoIterator<Item = ChoiceDefinition>,
) -> Result<Self, DialogueDefinitionError> {
let choices: Vec<_> = choices.into_iter().collect();
if choices.is_empty() {
return Err(DialogueDefinitionError::EmptyChoiceSet {
node_id: id,
});
}
let mut seen_ids = BTreeSet::new();
for choice in &choices {
if !seen_ids.insert(choice.id().clone()) {
return Err(DialogueDefinitionError::DuplicateChoiceId {
node_id: id,
choice_id: choice.id().clone(),
});
}
}
Ok(Self {
id,
speaker,
line_key,
exit: DialogueExit::Choices(choices),
})
}
#[must_use]
pub fn terminal(
id: NarrativeNodeId,
speaker: Option<CharacterId>,
line_key: LocalizationKey,
) -> Self {
Self {
id,
speaker,
line_key,
exit: DialogueExit::End,
}
}
#[must_use]
pub fn id(&self) -> &NarrativeNodeId {
&self.id
}
#[must_use]
pub fn speaker(&self) -> Option<&CharacterId> {
self.speaker.as_ref()
}
#[must_use]
pub fn line_key(&self) -> &LocalizationKey {
&self.line_key
}
#[must_use]
pub fn choices(&self) -> Option<&[ChoiceDefinition]> {
self.exit.choices()
}
#[must_use]
pub const fn is_terminal(&self) -> bool {
self.exit.is_terminal()
}
}
The API enforces several useful rules:
- A branching node cannot accidentally contain zero choices.
- A terminal node cannot accidentally carry choices.
- Choices retain their authored order.
- A choice ID cannot be duplicated within a node.
- External code can inspect a node but cannot mutate it in place.
The constructor intentionally does not check whether each target_node exists. A node constructor only sees one node at a time; resolving references requires the complete set of nodes, characters, quests, items, and locations. That is a content-registry responsibility, covered in the content validation module.
Likewise, a choice targeting its own node is not automatically invalid. It might represent a deliberate loop, such as revisiting a topic after a response. Determining whether cycles are intentional requires graph-level validation and authoring policy, not a local constructor rule.
The Serialize derives are useful for later content fingerprinting and developer diagnostics. Do not derive Deserialize merely because these definitions are serializable. Your project’s authoring contract is compiled Rust source, not externally supplied dialogue JSON. Save files should serialize mutable runtime state, not become an alternate route for loading arbitrary authored content.
Author definitions in the compiled content crate
The engine crate owns the general types and structural invariants. The content crate uses those types to define the game’s actual narrative.
For example, a small part of Mira’s opening conversation could live in a Rust content module:
// crates/vn_content/src/dialogue/mira.rs
use vn_engine::id::{
CharacterId,
ChoiceId,
LocalizationKey,
NarrativeNodeId,
};
use vn_engine::narrative::definition::{
ChoiceDefinition,
DialogueDefinitionError,
DialogueNodeDefinition,
};
pub fn first_meeting() -> Result<DialogueNodeDefinition, DialogueDefinitionError> {
DialogueNodeDefinition::branching(
NarrativeNodeId::new("mira.first_meeting")?,
Some(CharacterId::new("mira")?),
LocalizationKey::new("dialogue.mira.first_meeting.line")?,
[
ChoiceDefinition::new(
ChoiceId::new("mira.first_meeting.greet")?,
LocalizationKey::new(
"choice.mira.first_meeting.greet",
)?,
NarrativeNodeId::new(
"mira.first_meeting.greet_response",
)?,
),
ChoiceDefinition::new(
ChoiceId::new("mira.first_meeting.leave")?,
LocalizationKey::new(
"choice.mira.first_meeting.leave",
)?,
NarrativeNodeId::new(
"mira.first_meeting.goodbye",
)?,
),
],
)
}
pub fn goodbye() -> Result<DialogueNodeDefinition, DialogueDefinitionError> {
Ok(DialogueNodeDefinition::terminal(
NarrativeNodeId::new("mira.first_meeting.goodbye")?,
Some(CharacterId::new("mira")?),
LocalizationKey::new("dialogue.mira.first_meeting.goodbye")?,
))
}
This source defines a tiny directed graph:
mira.first_meetingdisplays a line and presents two choices.- Each choice identifies a target node by stable ID.
mira.first_meeting.goodbyedisplays a final line and ends the interaction.
The content itself contains no English dialogue, portraits, Button objects, or Godot resource paths. That preserves the dependency direction established earlier:
| Component | May know about | Must not know about |
|---|---|---|
vn_engine | IDs, definitions, runtime state, commands, events | Godot APIs and presentation assets |
vn_content | Engine authoring types and compiled definitions | Godot APIs and presentation assets |
| Godot adapter | Engine commands, events, view models, Godot nodes | Direct mutation of content or runtime internals |
| Presentation assets | Scenes, themes, portraits, audio | Narrative rules and mutable game state |
At runtime, the engine will look up NarrativeNodeId("mira.first_meeting") in its content registry. The eventual Godot bridge will receive a presentation view model derived from that active node, not the node itself as a mutable UI object.
Test structural invariants at the definition boundary
These data structures are simple, but they form the foundation for the narrative graph. Test the invariants that keep malformed authored content from reaching runtime.
#[cfg(test)]
mod tests {
use super::*;
fn node_id(value: &str) -> NarrativeNodeId {
NarrativeNodeId::new(value).unwrap()
}
fn choice_id(value: &str) -> ChoiceId {
ChoiceId::new(value).unwrap()
}
fn key(value: &str) -> LocalizationKey {
LocalizationKey::new(value).unwrap()
}
fn choice(
id: &str,
label_key: &str,
target: &str,
) -> ChoiceDefinition {
ChoiceDefinition::new(
choice_id(id),
key(label_key),
node_id(target),
)
}
#[test]
fn branching_node_preserves_authored_choice_order() {
let greet = choice(
"mira.intro.greet",
"choice.mira.intro.greet",
"mira.greet_response",
);
let leave = choice(
"mira.intro.leave",
"choice.mira.intro.leave",
"mira.goodbye",
);
let node = DialogueNodeDefinition::branching(
node_id("mira.intro"),
None,
key("dialogue.mira.intro.line"),
[greet.clone(), leave.clone()],
)
.unwrap();
let choices = node.choices().unwrap();
assert_eq!(choices.len(), 2);
assert_eq!(choices[0].id(), greet.id());
assert_eq!(choices[1].id(), leave.id());
assert!(!node.is_terminal());
}
#[test]
fn terminal_node_has_no_choices() {
let node = DialogueNodeDefinition::terminal(
node_id("mira.goodbye"),
Some(CharacterId::new("mira").unwrap()),
key("dialogue.mira.goodbye"),
);
assert!(node.is_terminal());
assert_eq!(node.choices(), None);
}
#[test]
fn branching_node_rejects_an_empty_choice_set() {
let result = DialogueNodeDefinition::branching(
node_id("mira.intro"),
None,
key("dialogue.mira.intro.line"),
[],
);
assert!(matches!(
result,
Err(DialogueDefinitionError::EmptyChoiceSet { .. })
));
}
#[test]
fn branching_node_rejects_duplicate_local_choice_ids() {
let result = DialogueNodeDefinition::branching(
node_id("mira.intro"),
None,
key("dialogue.mira.intro.line"),
[
choice(
"mira.intro.ask",
"choice.mira.intro.ask",
"mira.answer_one",
),
choice(
"mira.intro.ask",
"choice.mira.intro.ask_again",
"mira.answer_two",
),
],
);
assert!(matches!(
result,
Err(DialogueDefinitionError::DuplicateChoiceId { .. })
));
}
}
These tests do not replace later whole-graph validation. They establish the local contract:
- a node has exactly one valid exit shape;
- a branching node has at least one choice;
- each local menu has unambiguous choice IDs;
- choice order is stable;
- terminal nodes are represented explicitly.
You now have a pure-Rust, compiled-content model for dialogue nodes and player choices. A node is immutable authored data; a choice is an ordered, stable, presentation-neutral edge to another node; and terminal conversations are explicit rather than inferred from an empty collection.
Next, you will add choice-availability predicates. That will let the engine evaluate whether a choice should be offered using an immutable view of the typed variable state, while keeping the definitions themselves just as immutable.
Can't find a good explanation? Sign up and we'll make it for you
Sign up