Create your own
Lesson illustration

Implementing Narrative Effects with Controlled State Mutation

Good to see you again. Previously, you made choice availability a pure read of immutable state: predicates answer whether a choice is currently visible, but cannot alter the world. That separation is essential, because rendering a menu must never change the game.

Now we add the complementary write side: narrative effects. An effect is immutable, compiled Rust content that describes one allowed state change. The runtime applies it only through a narrow mutation boundary. This gives your engine a reliable answer to questions such as: “Where can mira.affinity change?” and “What happens if an authored effect targets a missing or incorrectly typed variable?”

Plan for roughly 35–40 minutes: a short Rust refresher, then implement the effect type, the mutation boundary, and tests proving that failed effects leave state unchanged.


Effects are authored instructions, not arbitrary code

A choice predicate was deliberately represented as a closed enum rather than a closure or trait object. Effects deserve the same treatment.

Avoid authoring content such as:

// Do not do this.
effect: Box::new(|state: &mut GameState| {
    state.narrative_variables.insert(/* ... */);
}),

That approach is flexible in the least useful way. It makes effects difficult to serialize for diagnostics, inspect during content validation, fingerprint deterministically, and constrain to the engine’s invariants. It also gives compiled content direct access to mutable internal state.

Instead, define a small declarative vocabulary. For the initial runtime, two effects are enough to support common VN branching:

  • Set a variable to a value of its established type.
  • Add an amount to an integer variable.
// crates/vn_engine/src/narrative/effect.rs

use serde::Serialize;

use crate::id::NarrativeVariableId;
use crate::narrative::state::NarrativeValue;

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case", tag = "kind")]
pub enum NarrativeEffect {
    SetVariable {
        variable: NarrativeVariableId,
        value: NarrativeValue,
    },

    AddInteger {
        variable: NarrativeVariableId,
        amount: i64,
    },
}

impl NarrativeEffect {
    #[must_use]
    pub fn set(
        variable: NarrativeVariableId,
        value: NarrativeValue,
    ) -> Self {
        Self::SetVariable { variable, value }
    }

    #[must_use]
    pub fn add_integer(
        variable: NarrativeVariableId,
        amount: i64,
    ) -> Self {
        Self::AddInteger { variable, amount }
    }
}

This enum belongs in the pure Rust engine crate. Your compiled-content crate may construct NarrativeEffect values, because content depends on engine types. The reverse dependency must not exist: the engine should not import authored content modules, and neither component should import Godot APIs.

The distinction between an effect and its application is important:

ConcernResponsibility
“Set mira.met to true”Immutable authored NarrativeEffect
“Is this effect valid for the current state?”Engine mutation boundary
“Actually replace the stored value”Engine mutation boundary
“Show a portrait or animate a label”Godot presentation, later
“When does a selected choice apply its effect?”Narrative command transition, next lesson

An effect describes what may change. The runtime decides when it is permitted to change.


Make the state boundary the only writable route

Your GameStateView from the previous lesson grants predicates read-only access. Effects require the opposite capability: exclusive mutable access to the concrete runtime state.

Do not add a public API such as this:

// Do not expose this.
pub fn narrative_variables_mut(
    &mut self,
) -> &mut BTreeMap<NarrativeVariableId, NarrativeValue> {
    &mut self.narrative_variables
}

Even if only Rust code can call it, that API makes every caller responsible for type checks, missing-variable handling, integer overflow, and diagnostic quality. Over time, those rules will drift.

Instead, keep the variable store private to the state module and expose one controlled operation:

impl GameState {
    pub(crate) fn apply_narrative_effect(
        &mut self,
        effect: &NarrativeEffect,
    ) -> Result<(), NarrativeEffectError> {
        // The only approved mutation route for narrative effects.
    }
}

This is not merely encapsulation for its own sake. It establishes a useful invariant:

Every narrative-variable change is either a validated NarrativeEffect application or initialization/loading logic that is itself validated.

That invariant will later make command tracing, replay, save migration, content diagnostics, quests, and inventory effects much easier to reason about.

For a short refresher on how matching through &mut self gives mutable references to data inside an enum without consuming it, watch this segment.

RustCurious 6: Enums and Polymorphism

Watch “RustCurious 6: Enums and Polymorphism” by RustCurious for the borrowing pattern used by the mutation function.

Watch mutable matching. Focus on the distinction between matching an owned enum, a shared reference, and a mutable reference; the final portion shows why fields matched through &mut self can be updated in place.

In this engine, effects are not typestates. A loaded sandbox game can legitimately be in many combinations of variable values, locations, active quests, and time. Encoding the entire global state in Rust types would be impractical. Instead, types constrain the shape of one requested mutation, while the mutation function validates the runtime preconditions.


Model failure explicitly and preserve the old state

A failed effect must not panic and must not quietly become a no-op. It should return a structured error to the narrative runtime, which will later decide whether to surface it as a development diagnostic, halt a broken transition, or record it in a trace.

For this initial effect vocabulary, three failures matter:

// crates/vn_engine/src/narrative/effect.rs

use thiserror::Error;

use crate::id::NarrativeVariableId;
use crate::narrative::state::NarrativeValueKind;

#[derive(Debug, Error, PartialEq, Eq)]
pub enum NarrativeEffectError {
    #[error("narrative effect references missing variable {variable}")]
    MissingVariable {
        variable: NarrativeVariableId,
    },

    #[error(
        "cannot set variable {variable}: expected {expected:?}, \
         but effect provides {actual:?}"
    )]
    SetValueTypeMismatch {
        variable: NarrativeVariableId,
        expected: NarrativeValueKind,
        actual: NarrativeValueKind,
    },

    #[error(
        "cannot add to variable {variable}: expected an integer, \
         but state contains {actual:?}"
    )]
    AddRequiresInteger {
        variable: NarrativeVariableId,
        actual: NarrativeValueKind,
    },

    #[error(
        "integer effect overflows variable {variable} by applying amount {amount}"
    )]
    IntegerOverflow {
        variable: NarrativeVariableId,
        amount: i64,
    },
}

These errors deliberately report identifiers and value kinds, rather than potentially sensitive runtime values such as player-entered text. They are structured engine diagnostics, not localized player-facing strings.

Read the Rust Book’s discussion of propagating errors. The important design point is that the mutation function reports failure to its caller rather than choosing a UI response or panicking itself.

Recoverable Errors with Result

Read the Rust Book’s explanation of error propagation. It supports the boundary design here: apply_narrative_effect should return a precise failure to the runtime rather than hiding it or deciding locally how the game should react.

In the “Propagating Errors” subsection, begin at the paragraph introducing Listing 9-6 and read the propagation example. Follow the distinction between detecting a failure in a lower-level function and deciding how to respond at the calling layer.

For a single effect, require an all-or-nothing rule:

  • If validation succeeds, the intended mutation occurs.
  • If validation fails, the relevant state is exactly as it was before the call.

The setter must check type compatibility before assigning. The integer operation must calculate its next value with checked_add before replacing the old one. This makes effect application safe even if invalid state reaches the engine through a malformed save, a future migration bug, or incorrect authored content.


Implement the controlled mutation function

Assume the typed runtime values established earlier:

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub enum NarrativeValue {
    Bool(bool),
    Integer(i64),
    Text(String),
}

#[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 exact location of your variable map can differ. The key requirement is that no external module receives unrestricted mutable access to it. Put the helper and application function beside the private state representation:

// crates/vn_engine/src/state.rs

use crate::id::NarrativeVariableId;
use crate::narrative::effect::{
    NarrativeEffect,
    NarrativeEffectError,
};
use crate::narrative::state::{
    NarrativeValue,
    NarrativeValueKind,
};

impl GameState {
    fn required_variable_mut(
        &mut self,
        variable: &NarrativeVariableId,
    ) -> Result<&mut NarrativeValue, NarrativeEffectError> {
        self.narrative_variables
            .get_mut(variable)
            .ok_or_else(|| NarrativeEffectError::MissingVariable {
                variable: variable.clone(),
            })
    }

    pub(crate) fn apply_narrative_effect(
        &mut self,
        effect: &NarrativeEffect,
    ) -> Result<(), NarrativeEffectError> {
        match effect {
            NarrativeEffect::SetVariable { variable, value } => {
                let current = self.required_variable_mut(variable)?;
                let expected = current.kind();
                let actual = value.kind();

                if expected != actual {
                    return Err(
                        NarrativeEffectError::SetValueTypeMismatch {
                            variable: variable.clone(),
                            expected,
                            actual,
                        },
                    );
                }

                *current = value.clone();
                Ok(())
            }

            NarrativeEffect::AddInteger { variable, amount } => {
                let current = self.required_variable_mut(variable)?;

                let NarrativeValue::Integer(current_value) = current
                else {
                    return Err(
                        NarrativeEffectError::AddRequiresInteger {
                            variable: variable.clone(),
                            actual: current.kind(),
                        },
                    );
                };

                let next_value = current_value.checked_add(*amount).ok_or_else(|| {
                    NarrativeEffectError::IntegerOverflow {
                        variable: variable.clone(),
                        amount: *amount,
                    }
                })?;

                *current_value = next_value;
                Ok(())
            }
        }
    }
}

Several details here protect the engine’s future behavior.

The effect enum is exhaustively interpreted

Each NarrativeEffect variant has one defined state-transition rule. If you add a future variant such as GrantItem or StartQuest, Rust will require an explicit decision in this match.

That is better than a default branch, because a newly authored effect can never silently exist without an implementation.

The value type cannot change accidentally

If mira.affinity was initialized as an integer variable, this effect is rejected:

NarrativeEffect::set(
    variable_id("mira.affinity"),
    NarrativeValue::Text(String::from("close friend")),
)

But this effect is valid:

NarrativeEffect::add_integer(
    variable_id("mira.affinity"),
    1,
)

A variable’s initialized type therefore functions as a runtime schema. Later content validation should ensure that every variable reference exists, while future save loading will validate that persisted values still conform to this schema. The mutation boundary remains the runtime backstop.

Integer overflow is rejected rather than wrapped

A debug build might panic on overflow and a release build could otherwise wrap depending on the operation and compiler settings. Neither behavior is acceptable as narrative semantics.

checked_add produces a valid next value only when the result fits in i64. Otherwise, the function returns IntegerOverflow without changing the stored variable.

The function returns no presentation result

The return type is intentionally:

Result<(), NarrativeEffectError>

It does not return Godot nodes, label text, audio instructions, or an ad hoc UI message. A successful effect means only that core state was validly mutated. In the next lesson, the deterministic narrative transition will decide which typed runtime events follow a successful choice selection.


Attach at most one effect to a choice for now

A choice can now own one optional effect. None is meaningful: plenty of choices simply navigate to a different dialogue node without changing state.

// Relevant additions to ChoiceDefinition.

use crate::narrative::effect::NarrativeEffect;

#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct ChoiceDefinition {
    id: ChoiceId,
    label_key: LocalizationKey,
    target_node: NarrativeNodeId,
    availability: ChoiceAvailability,
    effect: Option<NarrativeEffect>,
}

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(),
            effect: None,
        }
    }

    #[must_use]
    pub fn with_effect(
        mut self,
        effect: NarrativeEffect,
    ) -> Self {
        self.effect = Some(effect);
        self
    }

    #[must_use]
    pub fn effect(&self) -> Option<&NarrativeEffect> {
        self.effect.as_ref()
    }

    // Keep the existing availability, ID, label, and target accessors.
}

For now, deliberately model zero or one effect per choice. This gives every individual mutation clear atomic semantics. A collection of effects raises a distinct design question: should all effects succeed together, or can earlier effects remain applied if a later one fails? That is a transaction policy, and it should be introduced explicitly rather than appearing accidentally through a Vec loop.

A compiled-content definition can now express a familiar scene change:

ChoiceDefinition::new(
    choice_id("mira.first_meeting.introduce_self"),
    key("choice.mira.first_meeting.introduce_self"),
    node_id("mira.first_meeting.after_introduction"),
)
.with_effect(NarrativeEffect::set(
    variable_id("mira.met"),
    NarrativeValue::Bool(true),
))

Or an affinity-changing interaction:

ChoiceDefinition::new(
    choice_id("mira.first_meeting.return_notebook"),
    key("choice.mira.first_meeting.return_notebook"),
    node_id("mira.first_meeting.grateful"),
)
.with_effect(NarrativeEffect::add_integer(
    variable_id("mira.affinity"),
    1,
))

The choice definition is still immutable after construction. A Godot button does not receive access to the effect or the mutable game state. When the player presses the button, the Rust runtime will later locate the choice by stable ID, re-check availability, then invoke this controlled function if appropriate.


Test successful mutation and failed-mutation atomicity

These tests belong in the pure engine crate. They should run without loading Godot, a scene, an extension library, or presentation assets.

Use a test-only constructor or helper that creates valid initial variables. Do not make the production variable map public merely to simplify tests.

#[cfg(test)]
mod tests {
    use super::*;

    fn state(
        variables: impl IntoIterator<Item = (&'static str, NarrativeValue)>,
    ) -> GameState {
        GameState::from_narrative_variables_for_test(
            variables
                .into_iter()
                .map(|(id, value)| (variable_id(id), value)),
        )
    }

    fn variable(
        state: &GameState,
        id: &'static str,
    ) -> Option<&NarrativeValue> {
        state.variable_value(&variable_id(id))
    }

    #[test]
    fn set_effect_replaces_a_value_of_the_same_type() {
        let mut current = state([(
            "mira.met",
            NarrativeValue::Bool(false),
        )]);

        let effect = NarrativeEffect::set(
            variable_id("mira.met"),
            NarrativeValue::Bool(true),
        );

        assert_eq!(current.apply_narrative_effect(&effect), Ok(()));
        assert_eq!(
            variable(&current, "mira.met"),
            Some(&NarrativeValue::Bool(true)),
        );
    }

    #[test]
    fn incompatible_set_does_not_change_the_variable() {
        let mut current = state([(
            "mira.affinity",
            NarrativeValue::Integer(2),
        )]);

        let effect = NarrativeEffect::set(
            variable_id("mira.affinity"),
            NarrativeValue::Text(String::from("trusted")),
        );

        assert!(matches!(
            current.apply_narrative_effect(&effect),
            Err(NarrativeEffectError::SetValueTypeMismatch {
                expected: NarrativeValueKind::Integer,
                actual: NarrativeValueKind::Text,
                ..
            })
        ));

        assert_eq!(
            variable(&current, "mira.affinity"),
            Some(&NarrativeValue::Integer(2)),
        );
    }

    #[test]
    fn overflowing_increment_does_not_change_the_variable() {
        let mut current = state([(
            "mira.affinity",
            NarrativeValue::Integer(i64::MAX),
        )]);

        let effect = NarrativeEffect::add_integer(
            variable_id("mira.affinity"),
            1,
        );

        assert!(matches!(
            current.apply_narrative_effect(&effect),
            Err(NarrativeEffectError::IntegerOverflow { .. })
        ));

        assert_eq!(
            variable(&current, "mira.affinity"),
            Some(&NarrativeValue::Integer(i64::MAX)),
        );
    }

    #[test]
    fn integer_effect_rejects_a_non_integer_target() {
        let mut current = state([(
            "mira.met",
            NarrativeValue::Bool(true),
        )]);

        let effect = NarrativeEffect::add_integer(
            variable_id("mira.met"),
            1,
        );

        assert!(matches!(
            current.apply_narrative_effect(&effect),
            Err(NarrativeEffectError::AddRequiresInteger {
                actual: NarrativeValueKind::Bool,
                ..
            })
        ));

        assert_eq!(
            variable(&current, "mira.met"),
            Some(&NarrativeValue::Bool(true)),
        );
    }
}

The first test proves the intended state transition. The remaining tests are equally important: they prove that bad effects are visible as structured errors and leave the existing state intact.

That property becomes especially valuable once saves, migrations, quest rewards, item use, and mini-games all share this mutation mechanism. A caller can trust that Err(...) means “no partial mutation from this one effect.”


You now have a controlled write boundary for the narrative runtime:

  • NarrativeEffect is a closed, serializable, presentation-neutral authored-data enum.
  • Mutable narrative variables remain private inside GameState.
  • apply_narrative_effect is the narrow engine-owned route for applying an effect.
  • Missing variables, type mismatches, and integer overflow are explicit errors.
  • Each individual effect either succeeds completely or preserves the prior state.
  • Choices may carry one optional effect, while Godot remains unable to mutate narrative state directly.

Next, you will combine immutable dialogue definitions, availability checks, selected-choice validation, and this mutation boundary into a deterministic command-to-events narrative transition.

Can't find a good explanation? Sign up and we'll make it for you

Sign up