Welcome back. In the previous lesson, you defined immutable dialogue nodes and choices: choices have stable IDs, localized labels, and target nodes, but no runtime-dependent behavior yet.
This lesson adds that missing layer. A choice such as “Ask Mira about the lighthouse” should be displayed only when its authored requirements hold, without allowing UI code or predicate evaluation to mutate the game. You will build a closed, serializable predicate language and evaluate it through a narrow, read-only game-state interface.
This keeps the engine deterministic and presentation-neutral: Godot asks Rust for the currently available choices; Rust evaluates definitions against state; Godot never decides whether a narrative rule is true.
Two kinds of immutability at the choice boundary
A choice definition remains immutable compiled content. Its availability predicate is also immutable:
“This choice requires that
mira.metis true andmira.affinityis at least 3.”
The answer to that predicate changes during play, but the rule itself does not.
The predicate therefore reads from an immutable view of game state. It must not:
- change a variable while checking it;
- grant an item, start a quest, or advance time;
- access Godot nodes, input state, or scene resources;
- cache availability inside the definition;
- use random numbers or wall-clock time.
This separation matters in a sandbox VN. Rendering a dialogue menu twice should not secretly increase an affinity score or alter which choices are available. A predicate is a query; state changes will remain the responsibility of explicit effects and commands in later lessons.

The important borrowing consequence is straightforward:
- During availability evaluation, the evaluator receives
&GameState-like access. - While that shared borrow exists, Rust prevents code from simultaneously obtaining incompatible mutable access to the same state.
- A later command handler can obtain
&mut GameStateto apply an effect, but predicate evaluation itself never does.
For a quick refresher on matching enum data through shared references, watch this short segment.
RustCurious 6: Enums and Polymorphism
“RustCurious 6: Enums and Polymorphism” by RustCurious explains how matching against a shared reference preserves ownership while still allowing inspection of enum contents. That is the exact access pattern used by the predicate evaluator.
Watch matching references. Focus on the distinction between matching a value by ownership and matching &self: in the latter case, bindings refer to the contained data rather than moving it out. The brief contrast with &mut self clarifies why an availability check must use shared access only.
Give predicates a deliberately closed vocabulary
It may be tempting to define a trait such as ChoicePredicate and put arbitrary implementations behind Box<dyn ChoicePredicate>. Do not start there.
Your game’s authored choice conditions need to be:
- compiled Rust content;
- serializable for diagnostics and content fingerprinting;
- inspectable by content validation;
- deterministic;
- exhaustively evaluable;
- understandable in an error report.
A closed enum is a better fit than open-ended runtime polymorphism. Adding a new predicate kind becomes an intentional engine change: add one enum variant, update the evaluator, update validation, and let Rust identify every non-exhaustive match.
For the initial narrative runtime, a compact predicate language covers the common cases:
| Predicate | Meaning |
|---|---|
Always | The choice is always available. |
VariableEquals | A typed variable equals an expected typed value. |
IntegerAtLeast | An integer variable meets a minimum. |
All | Every nested predicate must hold. |
Any | At least one nested predicate must hold. |
Not | The nested predicate must not hold. |
The exact names of your typed variable ID and value types may differ from the previous lesson. The examples below use NarrativeVariableId and NarrativeValue; substitute your established names if needed.
// crates/vn_engine/src/narrative/availability.rs
use serde::Serialize;
use crate::id::NarrativeVariableId;
use crate::narrative::state::NarrativeValue;
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "snake_case", tag = "kind")]
pub enum ChoiceAvailability {
Always,
VariableEquals {
variable: NarrativeVariableId,
expected: NarrativeValue,
},
IntegerAtLeast {
variable: NarrativeVariableId,
minimum: i64,
},
All(Vec<ChoiceAvailability>),
Any(Vec<ChoiceAvailability>),
Not(Box<ChoiceAvailability>),
}
impl ChoiceAvailability {
#[must_use]
pub const fn always() -> Self {
Self::Always
}
#[must_use]
pub fn equals(
variable: NarrativeVariableId,
expected: NarrativeValue,
) -> Self {
Self::VariableEquals { variable, expected }
}
#[must_use]
pub fn integer_at_least(
variable: NarrativeVariableId,
minimum: i64,
) -> Self {
Self::IntegerAtLeast { variable, minimum }
}
#[must_use]
pub fn all(
predicates: impl IntoIterator<Item = ChoiceAvailability>,
) -> Self {
Self::All(predicates.into_iter().collect())
}
#[must_use]
pub fn any(
predicates: impl IntoIterator<Item = ChoiceAvailability>,
) -> Self {
Self::Any(predicates.into_iter().collect())
}
#[must_use]
pub fn not(predicate: ChoiceAvailability) -> Self {
Self::Not(Box::new(predicate))
}
}
Box in Not does not imply dynamic dispatch. It simply gives the recursive enum a fixed size: without it, Not(ChoiceAvailability) would require an infinitely large ChoiceAvailability.
The recursive forms make definitions expressive without requiring closures or callbacks. For example:
can be represented using All containing one equality predicate and one nested Any predicate.
The explicit semantics of empty groups are useful to establish now:
All([])evaluates totrue: there is no unmet requirement.Any([])evaluates tofalse: no alternative can be satisfied.
These are standard logical identities. In practice, an empty authored All or Any is usually suspicious, so the later content-validation module should report it as an authoring warning or error even though evaluation has a well-defined result.
Expose only the state a predicate may read
The evaluator should not receive direct mutable access to your full runtime state. Instead, define a small read-only interface representing the capability predicates currently need.
// crates/vn_engine/src/narrative/availability.rs
use crate::id::NarrativeVariableId;
use crate::narrative::state::NarrativeValue;
pub trait GameStateView {
fn variable_value(
&self,
id: &NarrativeVariableId,
) -> Option<&NarrativeValue>;
}
At this stage, the view exposes only narrative variables. It does not expose Godot resources, mutable collections, save-file machinery, or presentation data.
Your engine’s concrete state can implement this trait by delegating to the typed variable store built previously:
// crates/vn_engine/src/state.rs
use crate::id::NarrativeVariableId;
use crate::narrative::availability::GameStateView;
use crate::narrative::state::NarrativeValue;
impl GameStateView for GameState {
fn variable_value(
&self,
id: &NarrativeVariableId,
) -> Option<&NarrativeValue> {
self.narrative_variables.get(id)
}
}
The implementation returns Option<&NarrativeValue>, rather than cloning a value. A predicate only needs to inspect its input; it does not need ownership. This is especially useful once variables can contain strings or other non-Copy values.
The trait is an access boundary, not a predicate-extension system. ChoiceAvailability remains the closed authored vocabulary. GameStateView simply says: “a predicate evaluator may ask this question of its state.”
Later modules can expand the view deliberately when conditions need to query quests, inventory, locations, or world time. They should still expose semantic queries such as quest_state() or has_item(), not internal mutable collections.
Evaluate normal unavailability differently from invalid data
There are two distinct outcomes when evaluating a choice:
-
The predicate is valid and evaluates to
false.
This is ordinary gameplay: the player has not met Mira, has too little affinity, or lacks a required item. -
The predicate cannot be evaluated because content and state disagree.
For example, an authored condition expects an integer but the variable currently holds text. This is an engine/content defect, not an unavailable choice.
Treating both cases as simply false would hide authoring failures. The evaluator should therefore return Result<bool, PredicateEvaluationError>.
Your prior typed value model should expose a non-sensitive type classification method. For example:
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NarrativeValueKind {
Bool,
Integer,
Text,
}
impl NarrativeValue {
#[must_use]
pub const fn kind(&self) -> NarrativeValueKind {
match self {
Self::Bool(_) => NarrativeValueKind::Bool,
Self::Integer(_) => NarrativeValueKind::Integer,
Self::Text(_) => NarrativeValueKind::Text,
}
}
}
The evaluator can report kinds without putting a player’s text or other runtime values into a diagnostic message.
use thiserror::Error;
use crate::id::NarrativeVariableId;
use crate::narrative::state::{NarrativeValue, NarrativeValueKind};
#[derive(Debug, Error, PartialEq, Eq)]
pub enum PredicateEvaluationError {
#[error("choice condition reads missing variable {variable}")]
MissingVariable {
variable: NarrativeVariableId,
},
#[error(
"choice condition for {variable} expected {expected:?}, \
but state contains {actual:?}"
)]
ValueTypeMismatch {
variable: NarrativeVariableId,
expected: NarrativeValueKind,
actual: NarrativeValueKind,
},
}
fn required_value<'a>(
state: &'a dyn GameStateView,
variable: &NarrativeVariableId,
) -> Result<&'a NarrativeValue, PredicateEvaluationError> {
state.variable_value(variable).ok_or_else(|| {
PredicateEvaluationError::MissingVariable {
variable: variable.clone(),
}
})
}
A missing variable is not the same as an intentionally false boolean. During startup, the engine should initialize every runtime variable required by registered content. In the content-validation module, you will also verify that predicates reference defined variables. This runtime error remains valuable as a defensive boundary when an invalid save, a migration bug, or an engine defect violates that contract.
Implement recursive, read-only evaluation
Now implement the evaluator directly on the predicate enum.
impl ChoiceAvailability {
pub fn evaluate(
&self,
state: &dyn GameStateView,
) -> Result<bool, PredicateEvaluationError> {
match self {
Self::Always => Ok(true),
Self::VariableEquals { variable, expected } => {
let actual = required_value(state, variable)?;
if actual.kind() != expected.kind() {
return Err(
PredicateEvaluationError::ValueTypeMismatch {
variable: variable.clone(),
expected: expected.kind(),
actual: actual.kind(),
},
);
}
Ok(actual == expected)
}
Self::IntegerAtLeast { variable, minimum } => {
let actual = required_value(state, variable)?;
match actual {
NarrativeValue::Integer(current) => {
Ok(*current >= *minimum)
}
other => Err(
PredicateEvaluationError::ValueTypeMismatch {
variable: variable.clone(),
expected: NarrativeValueKind::Integer,
actual: other.kind(),
},
),
}
}
Self::All(predicates) => {
for predicate in predicates {
if !predicate.evaluate(state)? {
return Ok(false);
}
}
Ok(true)
}
Self::Any(predicates) => {
for predicate in predicates {
if predicate.evaluate(state)? {
return Ok(true);
}
}
Ok(false)
}
Self::Not(predicate) => Ok(!predicate.evaluate(state)?),
}
}
}
There are several design decisions worth noticing.
match expresses the complete rule language
Every predicate variant has an explicit evaluation rule. If you later add:
QuestIsActive { quest: QuestId }
Rust will require you to update this match. That is useful: an authored predicate must never exist without an evaluation meaning.
This Rust Book section is a concise reference for the pattern matching techniques used above, including a related technique—match guards—that is useful when a branch needs a further condition after a structural match.
Pattern Syntax - The Rust Programming Language
Read the Rust Book’s “Pattern Syntax” material to reinforce destructuring enums through patterns and the role of a conditional guard after a successful match. The evaluator above uses enum matching for its type-specific integer comparison.
In “Destructuring to Break Apart Values,” read the explanation of struct and enum patterns, especially the matching walkthrough. Then read “Adding Conditionals with Match Guards” through Listing 19-26. You do not need match guards for the evaluator as written, but notice the distinction: patterns establish a value’s shape, while conditions such as numeric comparisons establish a further rule.
All and Any short-circuit in stable authored order
All stops as soon as it finds an unmet requirement. Any stops as soon as it finds a satisfied alternative. This is ordinary boolean evaluation and is deterministic because the predicates are stored in an authored Vec, not an unordered map.
For example, this condition:
ChoiceAvailability::all([
ChoiceAvailability::equals(
variable_id("mira.met"),
NarrativeValue::Bool(true),
),
ChoiceAvailability::integer_at_least(
variable_id("mira.affinity"),
3,
),
])
is false immediately when mira.met is false. The affinity value need not be inspected.
One consequence is that a malformed later condition may not be reached during a particular playthrough. That is why runtime evaluation is not a substitute for whole-content validation. The later validation pass should traverse every predicate leaf, independently of the current game state.
Type mismatch is an error; unequal values are gameplay
Suppose mira.affinity is an integer variable with value 2, and the condition expects Integer(3). The condition is valid and evaluates to false.
Suppose the same condition encounters Text("high"). The data model is inconsistent, so evaluation returns ValueTypeMismatch.
This distinction keeps bad authored content and bad migrated state from quietly becoming invisible dialogue options.
Attach availability to immutable choice definitions
Extend the ChoiceDefinition from the previous lesson with a predicate field. Preserve the existing constructor as the convenient “always available” case.
use crate::narrative::availability::{
ChoiceAvailability,
GameStateView,
PredicateEvaluationError,
};
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct ChoiceDefinition {
id: ChoiceId,
label_key: LocalizationKey,
target_node: NarrativeNodeId,
availability: ChoiceAvailability,
}
impl ChoiceDefinition {
#[must_use]
pub fn new(
id: ChoiceId,
label_key: LocalizationKey,
target_node: NarrativeNodeId,
) -> Self {
Self {
id,
label_key,
target_node,
availability: ChoiceAvailability::always(),
}
}
#[must_use]
pub fn with_availability(
mut self,
availability: ChoiceAvailability,
) -> Self {
self.availability = availability;
self
}
#[must_use]
pub fn availability(&self) -> &ChoiceAvailability {
&self.availability
}
pub fn is_available(
&self,
state: &dyn GameStateView,
) -> Result<bool, PredicateEvaluationError> {
self.availability.evaluate(state)
}
// Keep the id, label_key, and target_node accessors
// from the previous lesson.
}
with_availability mutates an owned value only while the content module is constructing it. Once a definition is registered, external code can inspect it through shared references but cannot rewrite its predicate.
An authored choice can now express its narrative requirements directly:
ChoiceDefinition::new(
choice_id("mira.first_meeting.ask_lighthouse"),
key("choice.mira.first_meeting.ask_lighthouse"),
node_id("mira.first_meeting.lighthouse"),
)
.with_availability(ChoiceAvailability::all([
ChoiceAvailability::equals(
variable_id("mira.met"),
NarrativeValue::Bool(true),
),
ChoiceAvailability::integer_at_least(
variable_id("mira.affinity"),
3,
),
]))
This is still compiled Rust content. There is no GDScript conditional, no Godot signal callback that decides availability, and no external dialogue file that can redefine engine behavior at runtime.
Filter choices without losing authored order
When the active dialogue node is branching, the engine can evaluate its choices in source order and produce the available subset.
pub fn available_choices<'a>(
choices: &'a [ChoiceDefinition],
state: &dyn GameStateView,
) -> Result<Vec<&'a ChoiceDefinition>, PredicateEvaluationError> {
let mut available = Vec::new();
for choice in choices {
if choice.is_available(state)? {
available.push(choice);
}
}
Ok(available)
}
This function has three useful properties:
- It preserves the authored order established in the prior lesson.
- It returns references to immutable definitions rather than copies.
- It propagates invalid-data errors instead of silently treating them as hidden options.
Eventually, a dialogue view-model projection will turn these references into presentation-neutral rows containing choice IDs and localization keys. Godot will render those rows into buttons, but it will not run the predicates itself.
One subtle rule is worth establishing now:
Availability is not authorization.
A choice rendered as available may be selected slightly later, after another command has changed state. When you implement deterministic command handling, the engine must re-check the selected choice’s availability before advancing to its target. The UI’s visible button list is an observation of state, not a permission system.
Test predicates using a minimal immutable state view
Because the evaluator depends only on GameStateView, unit tests do not need Godot or a complete GameState. A small map-backed test view is enough.
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::*;
struct TestState {
variables: BTreeMap<NarrativeVariableId, NarrativeValue>,
}
impl GameStateView for TestState {
fn variable_value(
&self,
id: &NarrativeVariableId,
) -> Option<&NarrativeValue> {
self.variables.get(id)
}
}
fn state(
entries: impl IntoIterator<Item = (&'static str, NarrativeValue)>,
) -> TestState {
TestState {
variables: entries
.into_iter()
.map(|(id, value)| (variable_id(id), value))
.collect(),
}
}
#[test]
fn all_requires_every_nested_predicate() {
let predicate = ChoiceAvailability::all([
ChoiceAvailability::equals(
variable_id("mira.met"),
NarrativeValue::Bool(true),
),
ChoiceAvailability::integer_at_least(
variable_id("mira.affinity"),
3,
),
]);
let unavailable = state([
("mira.met", NarrativeValue::Bool(true)),
("mira.affinity", NarrativeValue::Integer(2)),
]);
let available = state([
("mira.met", NarrativeValue::Bool(true)),
("mira.affinity", NarrativeValue::Integer(3)),
]);
assert_eq!(predicate.evaluate(&unavailable), Ok(false));
assert_eq!(predicate.evaluate(&available), Ok(true));
}
#[test]
fn any_accepts_one_satisfied_alternative() {
let predicate = ChoiceAvailability::any([
ChoiceAvailability::equals(
variable_id("has.library_pass"),
NarrativeValue::Bool(true),
),
ChoiceAvailability::integer_at_least(
variable_id("mira.affinity"),
5,
),
]);
let current = state([
("has.library_pass", NarrativeValue::Bool(false)),
("mira.affinity", NarrativeValue::Integer(5)),
]);
assert_eq!(predicate.evaluate(¤t), Ok(true));
}
#[test]
fn numeric_predicate_rejects_the_wrong_value_type() {
let predicate = ChoiceAvailability::integer_at_least(
variable_id("mira.affinity"),
3,
);
let current = state([(
"mira.affinity",
NarrativeValue::Text(String::from("trusted")),
)]);
assert!(matches!(
predicate.evaluate(¤t),
Err(PredicateEvaluationError::ValueTypeMismatch {
expected: NarrativeValueKind::Integer,
actual: NarrativeValueKind::Text,
..
})
));
}
}
These tests cover three different claims:
- combined conditions correctly distinguish unmet and met requirements;
- alternatives work without special UI logic;
- a data-model defect is reported rather than turned into an ordinary unavailable choice.
Keep the test state intentionally small. It demonstrates that predicate evaluation is pure Rust domain logic, independently testable without loading a Godot scene or extension library.
You now have a deterministic availability layer for dialogue choices:
ChoiceAvailabilityis immutable, compiled, serializable authored content.- Evaluation reads only through
GameStateView. falserepresents normal gameplay unavailability.- missing variables and type mismatches remain explicit engine/content errors.
- nested
All,Any, andNotrules support useful branching without callbacks or Godot dependencies. - available choices retain their authored order and must later be re-checked when selected.
Next, you will define controlled narrative effects: the explicit, auditable state mutations that happen after a valid choice is selected.
Can't find a good explanation? Sign up and we'll make it for you
Sign up