Create your own
Lesson illustration

Serializable, Type-Safe Model for Mutable Narrative Variables

Good to see you again. In the previous lesson, you established stable, domain-specific identifiers such as NarrativeNodeId, CharacterId, and ItemId. Those types prevent the engine from confusing one kind of reference with another and keep saved references stable across refactors.

Now we need the mutable half of narrative state: facts that can change while the player explores the sandbox. Examples include whether the player has met Mira, an affinity score, a route unlocked by a quest, or a player-provided name. The goal is not a loose dictionary of JSON values. It is a closed, serializable Rust model that rejects type mistakes at the engine boundary and remains independent of Godot.

By the end of this lesson, you will have a design in which:

  • each variable has a stable VariableId;
  • every stored value belongs to an explicit supported type;
  • compiled content declares each variable’s default and therefore its type;
  • runtime writes are checked against that declaration;
  • save data can be deserialized and validated against the current compiled schema.

A variable is a named, typed fact

A tempting implementation is a map such as this:

use std::collections::HashMap;

let mut variables: HashMap<String, serde_json::Value> = HashMap::new();

It is flexible, but flexibility is doing the wrong job here. This permits all of the following:

  • "mira_affinity" is an integer in one code path and a string in another;
  • "met_mira" is misspelled in an effect, silently creating a second variable;
  • a condition assumes a boolean but receives a number;
  • saved values have no explicit model-level contract beyond “some JSON happened to parse.”

serde_json::Value is useful at the format boundary, such as inspecting arbitrary diagnostic JSON in a tool. It should not be the engine’s narrative-state representation. Once it enters ordinary gameplay logic, the compiler cannot help you with the value’s shape.

Instead, treat variables as a small typed store:

Variable IDMeaningValue typeInitial value
story.met_miraWhether the introduction occurredBooleanfalse
mira.affinityRelationship scoreInteger0
harbor.permit_grantedWhether a route is availableBooleanfalse
player.chosen_namePlayer-entered nameText""

The value model should be deliberately closed. New variants are an intentional engine and save-schema decision, not something introduced accidentally because a content author needed a one-off shape.

For this engine’s initial narrative variables, three types are sufficient:

  1. Boolean for flags and binary state.
  2. Integer for counters, affinities, thresholds, and bounded scores.
  3. Text for runtime-entered text, such as a player name.

Do not store authored dialogue in Text. Authored player-facing prose will become localization keys in a later module. A runtime text variable represents data, not translatable narrative content.

Use a new stable ID type alongside the IDs from the last lesson:

// In crates/vn_engine/src/id.rs, beside the existing ID definitions.
define_stable_id!(VariableId);

That gives variables the same guarantees as narrative nodes and items: validated spelling, stable scalar serialization, and compile-time separation from other identifier domains.


Model values with a Rust enum, not a dynamically typed tree

A Rust enum represents a sum type: each NarrativeValue is exactly one of a known set of variants. That gives the runtime a concrete answer to “what kind of value is this?” without inspecting arbitrary JSON at every use site.

use serde::{Deserialize, Serialize};

#[derive(
    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize,
)]
#[serde(rename_all = "snake_case")]
pub enum NarrativeValueType {
    Boolean,
    Integer,
    Text,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
pub enum NarrativeValue {
    Boolean(bool),
    Integer(i32),
    Text(String),
}

impl NarrativeValue {
    #[must_use]
    pub const fn value_type(&self) -> NarrativeValueType {
        match self {
            Self::Boolean(_) => NarrativeValueType::Boolean,
            Self::Integer(_) => NarrativeValueType::Integer,
            Self::Text(_) => NarrativeValueType::Text,
        }
    }

    #[must_use]
    pub const fn as_boolean(&self) -> Option<bool> {
        match self {
            Self::Boolean(value) => Some(*value),
            _ => None,
        }
    }

    #[must_use]
    pub const fn as_integer(&self) -> Option<i32> {
        match self {
            Self::Integer(value) => Some(*value),
            _ => None,
        }
    }

    #[must_use]
    pub fn as_text(&self) -> Option<&str> {
        match self {
            Self::Text(value) => Some(value),
            _ => None,
        }
    }
}

The i32 choice is intentional. Narrative counters, affinity scores, and thresholds rarely need a larger range, and i32 values are represented exactly by JSON numbers and JavaScript numbers. That is a useful property for a desktop-and-web engine. If a future subsystem needs currency, percentages, or large quantities, give that domain its own fixed-point or validated type rather than adding floating-point values to this general narrative store.

Avoid f32 and f64 here. Floating-point arithmetic introduces rounding behavior that can complicate deterministic replay, and values such as NaN do not have a standard JSON representation.

The Serde attributes define the serialized enum shape. A boolean value is encoded conceptually as:

{
  "kind": "boolean",
  "value": true
}

An integer value is encoded as:

{
  "kind": "integer",
  "value": 3
}

This is an adjacently tagged representation: one field identifies the enum variant and another holds its payload. It is clear in a saved file and, unlike an untagged representation, does not make deserialization guess whether 3 means an affinity score, an item quantity, or some future numeric variant.

Enum representations

Read Serde's “Enum representations” reference to understand why the value model uses explicit tag and content fields rather than an untagged enum.

In the “Adjacently tagged” subsection, begin at the sentence the adjacent-tag explanation. Focus on the distinction between the tag that identifies a variant and the content that holds its data. Relate the documented tag and content attributes to kind and value in NarrativeValue.

The exact field names, variant names, and representation are part of the persistence contract. Renaming Integer to Number, changing "kind" to "type", or moving to a different enum layout can break existing saves. Later, the persistence module will define explicit schema versions and migrations; for now, choose a compact stable representation and keep it stable.

For a useful conceptual view of the boundary Serde provides between Rust types and storage formats, watch this short segment:

Decrusting the serde crate

Jon Gjengset’s “Decrusting the serde crate” explains the separation between your Rust data types, Serde’s data model, and a concrete encoding such as JSON. That separation is why the engine can own NarrativeValue without becoming coupled to a particular save format.

Watch the Serde boundary. Focus on the distinction between a Rust type and a data format: NarrativeValue is the former, while JSON is only one possible representation used at a persistence boundary.


A schema gives every variable a stable type

The enum prevents an individual value from being malformed, but it does not answer a more important question:

Is mira.affinity allowed to change from an integer into a boolean?

The answer must be no. That requires a schema built from compiled Rust content. Each definition supplies a default value, and that default establishes the variable’s type.

use std::collections::BTreeMap;

#[derive(Debug, Clone)]
pub struct NarrativeVariableDefinition {
    id: VariableId,
    value_type: NarrativeValueType,
    default: NarrativeValue,
}

impl NarrativeVariableDefinition {
    #[must_use]
    pub fn new(id: VariableId, default: NarrativeValue) -> Self {
        Self {
            id,
            value_type: default.value_type(),
            default,
        }
    }

    #[must_use]
    pub fn id(&self) -> &VariableId {
        &self.id
    }

    #[must_use]
    pub const fn value_type(&self) -> NarrativeValueType {
        self.value_type
    }

    #[must_use]
    pub fn default(&self) -> &NarrativeValue {
        &self.default
    }
}

#[derive(Debug, Clone)]
pub struct NarrativeVariableSchema {
    definitions: BTreeMap<VariableId, NarrativeVariableDefinition>,
}

The constructor for NarrativeVariableDefinition deliberately derives value_type from default. This makes a definition such as “declared integer, boolean default” impossible to construct.

An initial set of compiled definitions may look like this:

fn narrative_variable_schema() -> Result<NarrativeVariableSchema, VariableModelError> {
    NarrativeVariableSchema::new([
        NarrativeVariableDefinition::new(
            VariableId::new("story.met_mira")?,
            NarrativeValue::Boolean(false),
        ),
        NarrativeVariableDefinition::new(
            VariableId::new("mira.affinity")?,
            NarrativeValue::Integer(0),
        ),
        NarrativeVariableDefinition::new(
            VariableId::new("player.chosen_name")?,
            NarrativeValue::Text(String::new()),
        ),
    ])
}

The BTreeMap is intentional. It provides a stable lexical order when you traverse variables for diagnostics, fingerprints, or serialized snapshots. That stable order is not narrative order; it is merely a deterministic representation of a key-value store.

A schema builder should reject duplicate IDs. In this module, a simple error type is enough:

#[derive(Debug, thiserror::Error)]
pub enum VariableModelError {
    #[error("variable {id} is defined more than once")]
    DuplicateDefinition { id: VariableId },

    #[error("variable {id} is not defined by the current content")]
    UnknownVariable { id: VariableId },

    #[error(
        "variable {id} expects {expected:?}, but received {actual:?}"
    )]
    TypeMismatch {
        id: VariableId,
        expected: NarrativeValueType,
        actual: NarrativeValueType,
    },

    #[error("save data does not contain a value for variable {id}")]
    MissingSavedValue { id: VariableId },
}

A compact schema constructor can then make duplicate definitions impossible to ignore:

impl NarrativeVariableSchema {
    pub fn new(
        definitions: impl IntoIterator<Item = NarrativeVariableDefinition>,
    ) -> Result<Self, VariableModelError> {
        let mut by_id = BTreeMap::new();

        for definition in definitions {
            let id = definition.id().clone();

            if by_id.insert(id.clone(), definition).is_some() {
                return Err(VariableModelError::DuplicateDefinition { id });
            }
        }

        Ok(Self {
            definitions: by_id,
        })
    }

    fn definition(
        &self,
        id: &VariableId,
    ) -> Result<&NarrativeVariableDefinition, VariableModelError> {
        self.definitions
            .get(id)
            .ok_or_else(|| VariableModelError::UnknownVariable {
                id: id.clone(),
            })
    }
}

Later, the content registry and validation system will report duplicate definitions with source-oriented diagnostics across independent content modules. For now, this establishes the essential engine invariant: every runtime variable refers to a known compiled definition with one fixed value type.


Separate validated runtime state from its serialized snapshot

A common persistence mistake is deriving Deserialize directly on every runtime type and assuming that successful parsing proves the data is valid. Parsing only proves that the data has the right shape. It does not prove that its keys exist in the current content schema or that a value still has the type declared for that key.

Keep a serializable data-transfer object for the save boundary, then construct validated runtime state from it.

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct NarrativeVariablesSnapshot {
    values: BTreeMap<VariableId, NarrativeValue>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NarrativeVariables {
    values: BTreeMap<VariableId, NarrativeValue>,
}

NarrativeVariablesSnapshot has no Godot types, node references, callbacks, presentation assets, or transient caches. It is plain serializable state. The runtime NarrativeVariables keeps its map private, so normal engine code cannot bypass validation by mutating the map directly.

impl NarrativeVariables {
    #[must_use]
    pub fn from_defaults(schema: &NarrativeVariableSchema) -> Self {
        let values = schema
            .definitions
            .iter()
            .map(|(id, definition)| (id.clone(), definition.default().clone()))
            .collect();

        Self { values }
    }

    #[must_use]
    pub fn get(&self, id: &VariableId) -> Option<&NarrativeValue> {
        self.values.get(id)
    }

    pub fn boolean(&self, id: &VariableId) -> Result<bool, VariableModelError> {
        let value = self
            .get(id)
            .ok_or_else(|| VariableModelError::UnknownVariable {
                id: id.clone(),
            })?;

        match value {
            NarrativeValue::Boolean(value) => Ok(*value),
            other => Err(VariableModelError::TypeMismatch {
                id: id.clone(),
                expected: NarrativeValueType::Boolean,
                actual: other.value_type(),
            }),
        }
    }

    pub fn integer(&self, id: &VariableId) -> Result<i32, VariableModelError> {
        let value = self
            .get(id)
            .ok_or_else(|| VariableModelError::UnknownVariable {
                id: id.clone(),
            })?;

        match value {
            NarrativeValue::Integer(value) => Ok(*value),
            other => Err(VariableModelError::TypeMismatch {
                id: id.clone(),
                expected: NarrativeValueType::Integer,
                actual: other.value_type(),
            }),
        }
    }

    pub fn set(
        &mut self,
        schema: &NarrativeVariableSchema,
        id: &VariableId,
        value: NarrativeValue,
    ) -> Result<(), VariableModelError> {
        let definition = schema.definition(id)?;

        if definition.value_type() != value.value_type() {
            return Err(VariableModelError::TypeMismatch {
                id: id.clone(),
                expected: definition.value_type(),
                actual: value.value_type(),
            });
        }

        self.values.insert(id.clone(), value);
        Ok(())
    }

    #[must_use]
    pub fn snapshot(&self) -> NarrativeVariablesSnapshot {
        NarrativeVariablesSnapshot {
            values: self.values.clone(),
        }
    }

    pub fn from_snapshot(
        schema: &NarrativeVariableSchema,
        snapshot: NarrativeVariablesSnapshot,
    ) -> Result<Self, VariableModelError> {
        for (id, value) in &snapshot.values {
            let definition = schema.definition(id)?;

            if definition.value_type() != value.value_type() {
                return Err(VariableModelError::TypeMismatch {
                    id: id.clone(),
                    expected: definition.value_type(),
                    actual: value.value_type(),
                });
            }
        }

        for id in schema.definitions.keys() {
            if !snapshot.values.contains_key(id) {
                return Err(VariableModelError::MissingSavedValue {
                    id: id.clone(),
                });
            }
        }

        Ok(Self {
            values: snapshot.values,
        })
    }
}

The validation has two directions:

  1. Every saved key must be defined in the currently loaded content.
  2. Every currently defined variable must be present in this snapshot and have the declared type.

That strictness is appropriate for the current schema. Once versioned persistence is added, old save DTOs will be migrated to the current snapshot form before this validation occurs. Do not weaken the runtime invariant merely to make future migrations easier.

A normal mutation now has an explicit checked boundary:

let affinity = VariableId::new("mira.affinity")?;

variables.set(
    &schema,
    &affinity,
    NarrativeValue::Integer(5),
)?;

This fails instead of silently changing the variable’s semantic type:

let result = variables.set(
    &schema,
    &affinity,
    NarrativeValue::Boolean(true),
);

// result is Err(VariableModelError::TypeMismatch { .. })

At this point, set is a low-level state operation. The next lessons will ensure that dialogue effects and player actions invoke it through controlled command and effect processing, rather than allowing arbitrary presentation code to call it.


Test the contracts that matter

The important tests are not merely that Serde can serialize an enum. They should confirm the contracts that protect the engine: explicit representation, rejected malformed values, stable defaults, and rejected type changes.

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

    #[test]
    fn narrative_value_serializes_with_explicit_kind_and_value() {
        let encoded = serde_json::to_value(NarrativeValue::Integer(7)).unwrap();

        assert_eq!(encoded["kind"], "integer");
        assert_eq!(encoded["value"], 7);

        let restored: NarrativeValue = serde_json::from_value(encoded).unwrap();
        assert_eq!(restored, NarrativeValue::Integer(7));
    }

    #[test]
    fn serde_rejects_an_integer_with_text_payload() {
        let result: Result<NarrativeValue, _> =
            serde_json::from_str(r#"{"kind":"integer","value":"seven"}"#);

        assert!(result.is_err());
    }

    #[test]
    fn schema_rejects_a_runtime_type_change() {
        let affinity = VariableId::new("mira.affinity").unwrap();

        let schema = NarrativeVariableSchema::new([
            NarrativeVariableDefinition::new(
                affinity.clone(),
                NarrativeValue::Integer(0),
            ),
        ])
        .unwrap();

        let mut variables = NarrativeVariables::from_defaults(&schema);

        let result = variables.set(
            &schema,
            &affinity,
            NarrativeValue::Boolean(true),
        );

        assert!(matches!(
            result,
            Err(VariableModelError::TypeMismatch {
                expected: NarrativeValueType::Integer,
                actual: NarrativeValueType::Boolean,
                ..
            })
        ));
    }

    #[test]
    fn snapshot_rejects_unknown_variable_ids() {
        let known = VariableId::new("story.met_mira").unwrap();
        let unknown = VariableId::new("debug.force_weather").unwrap();

        let schema = NarrativeVariableSchema::new([
            NarrativeVariableDefinition::new(
                known.clone(),
                NarrativeValue::Boolean(false),
            ),
        ])
        .unwrap();

        let snapshot = NarrativeVariablesSnapshot {
            values: BTreeMap::from([
                (known, NarrativeValue::Boolean(true)),
                (unknown, NarrativeValue::Boolean(true)),
            ]),
        };

        assert!(matches!(
            NarrativeVariables::from_snapshot(&schema, snapshot),
            Err(VariableModelError::UnknownVariable { .. })
        ));
    }
}

Keep the distinction between these layers clear:

ConcernResponsibility
Is "story.met_mira" a valid identifier string?VariableId validation
Is {"kind":"integer","value":"seven"} structurally valid?Serde deserialization
Is mira.affinity known to current compiled content?NarrativeVariableSchema
Does mira.affinity still contain an integer?Schema-checked runtime mutation and snapshot validation
Is an integer within a gameplay-specific range?The relevant condition or effect policy

The final row matters. This generic variable model can establish that mira.affinity is an integer; it should not hard-code that every integer must be between 0 and 100. Some counters may be negative, some may count days, and some may represent route stages. Domain-specific constraints belong in authored definitions and effect validation, not in an arbitrary global range.

Also avoid adding a generic Null variant merely to represent “not set.” In this design, every declared variable has a default, and a missing variable is a meaningful validation error. If a future feature genuinely needs an optional value, model that optionality explicitly in the variable’s own domain rather than giving all conditions vague null semantics.


You now have a pure-Rust mutable variable system built from stable IDs, a closed typed value enum, compiled definitions, deterministic storage, and validated save snapshots. The model remains presentation-neutral: Godot never owns these values, and no GDScript is needed to interpret or mutate them.

Next, you will use these pieces while defining immutable dialogue nodes and player choices. Those authored structures will read variable state through conditions and eventually request controlled changes through narrative effects.

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

Sign up