Create your own
Lesson illustration

Defining Stable Typed Identifiers for Narrative Elements

Welcome. This begins the deterministic narrative-runtime module: the part of the engine that turns authored Rust content and player actions into reproducible state changes. Before defining dialogue or variables, we need a dependable way to name things.

A narrative node, a character, and an item may all happen to be represented by strings, but they are not interchangeable concepts. This lesson establishes stable, typed identifiers that remain meaningful across refactors, content revisions, save/load operations, and the eventual Godot presentation bridge—while keeping the entire identity system in the pure Rust engine crate.


Identity is domain data, not an implementation detail

Consider this tempting early design:

fn start_node(id: String) { /* ... */ }

fn give_item(id: String) { /* ... */ }

It compiles, but it permits a category of errors that should be impossible:

let mira = "mira".to_owned();
start_node(mira); // Compiles, but "mira" is a character ID, not a node ID.

The issue is not that strings are inherently bad. The issue is that a bare String says nothing about what it identifies. In a sandbox VN, identifiers become long-lived references used by:

  • dialogue choices pointing to destination nodes;
  • quests referring to characters, locations, and reward items;
  • game state recording the current location or active quest;
  • save snapshots that must still load after source-level refactors;
  • tests and diagnostics that need meaningful, deterministic names.

Treat an authored identifier as a stable semantic key. It is neither:

  • localized player-facing text;
  • a Godot NodePath, resource path, or scene node name;
  • a Rust module path or function name;
  • a runtime-generated UUID;
  • an array index that changes when content is reordered.

For example, these are reasonable authored IDs:

Domain conceptRust typeExample value
Dialogue or narrative nodeNarrativeNodeId"prologue.arrival"
Player-selectable choiceChoiceId"ask_mira_about_storm"
CharacterCharacterId"mira"
LocationLocationId"harbor.square"
QuestQuestId"find_missing_badge"
Item definitionItemId"rusty_badge"

The same string may be valid in two domains. CharacterId("mira") and ItemId("mira") are still distinct at compile time. Content registries will later enforce uniqueness within each domain.

For choices, use a semantic, globally unique ChoiceId, rather than the choice's position in a node. An index such as “choice 2” changes when an author inserts another option. A choice ID such as "ask_mira_about_storm" can survive reordering, rewriting its visible text, or moving it to another node.


Let the compiler distinguish the domains

Rust’s newtype pattern wraps one type in a small tuple struct, making a genuinely new type with the same underlying representation. That makes it the right baseline for engine IDs.

Rust Newtype Pattern | Safety Without Runtime Cost

Watch “Rust Newtype Pattern | Safety Without Runtime Cost” by Semicolon for a concise refresher on why a tuple struct is different from a type alias.

Watch the basic pattern to review how a wrapper creates a distinct type. Then watch compile time safety, where passing one logical ID type in place of another produces a compiler error. Finish with attached behavior and the alias distinction; the latter is particularly important because type CharacterId = String does not provide protection.

Type safety - Rust API Guidelines

Read “Type safety” from the Rust API Guidelines. It gives the API-design rationale for making distinctions such as CharacterId versus ItemId visible to the type system.

In the subsection “Newtypes provide static distinctions (C-NEWTYPE),” read the newtype rationale. Then continue to “Arguments convey meaning through types, not bool or Option (C-CUSTOM-TYPE)” and read the custom type guidance. Relate the examples to preventing an item reference from reaching a character or narrative API.

A type alias only gives a second name to the original type:

type CharacterId = String;
type ItemId = String;

This still compiles:

let item: ItemId = "rusty_badge".to_owned();
let character: CharacterId = item;

A newtype does not:

pub struct CharacterId(String);
pub struct ItemId(String);

Now the compiler knows these values play different roles. That is valuable even before the engine has a large amount of content: the type boundary prevents invalid wiring from spreading as systems become interconnected.

There is no need to involve Godot here. Put these types in the pure engine crate, for example crates/vn_engine/src/id.rs. They should not import godot, use GString, or depend on presentation resources.


Define one canonical identifier format

Type safety answers which kind of object a value identifies. We also need a policy for whether the value itself is acceptable.

Use a deliberately modest canonical grammar:

identifier = segment ( "." segment )*
segment    = lowercase_letter ( lowercase_letter | digit | "_" )*

This permits values such as:

prologue.arrival
harbor.square
find_missing_badge
chapter2.ending_a

It rejects:

Prologue.Arrival       # uppercase creates accidental aliases
harbor square          # whitespace is not portable
harbor..square         # empty segment
.harbor                # empty first segment
rusty-badge            # outside this project's chosen canonical grammar

There is no universal best grammar. The important production properties are:

  1. Canonicality: one spelling for one ID. Do not silently lowercase or normalize input.
  2. Portability: ASCII lowercase IDs are safe in save data, logs, URLs, filenames, and web storage.
  3. Readability: IDs appear in test failures and content-validation diagnostics.
  4. Stability: changing a Rust symbol, source file, scene layout, or localized line should not change an ID.

Do not encode transient structure merely because it is currently convenient. For example, "chapter1.scene3.choice2" may be a poor ChoiceId: it describes the authoring layout rather than the choice’s narrative meaning. If the choice is moved during a rewrite, the old ID can remain valid and slightly imperfectly named; that is usually safer than breaking persisted references.


A reusable implementation

The following implementation keeps the inner String private, validates construction, supports map keys and deterministic ordering, and serializes each ID as a plain string.

use std::fmt;
use std::str::FromStr;

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum StableIdError {
    #[error("identifier must not be empty")]
    Empty,

    #[error("identifier may contain at most 128 bytes, got {length}")]
    TooLong { length: usize },

    #[error("identifier contains an empty dot-separated segment")]
    EmptySegment,

    #[error(
        "identifier segment {segment:?} must begin with a lowercase ASCII letter \
         and otherwise contain only lowercase ASCII letters, digits, or underscores"
    )]
    InvalidSegment { segment: String },
}

fn validate_stable_id(value: &str) -> Result<(), StableIdError> {
    if value.is_empty() {
        return Err(StableIdError::Empty);
    }

    if value.len() > 128 {
        return Err(StableIdError::TooLong {
            length: value.len(),
        });
    }

    for segment in value.split('.') {
        if segment.is_empty() {
            return Err(StableIdError::EmptySegment);
        }

        let bytes = segment.as_bytes();
        if !bytes[0].is_ascii_lowercase() {
            return Err(StableIdError::InvalidSegment {
                segment: segment.to_owned(),
            });
        }

        if !bytes[1..]
            .iter()
            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'_')
        {
            return Err(StableIdError::InvalidSegment {
                segment: segment.to_owned(),
            });
        }
    }

    Ok(())
}

macro_rules! define_stable_id {
    ($name:ident) => {
        #[derive(
            Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
        )]
        #[serde(try_from = "String", into = "String")]
        pub struct $name(String);

        impl $name {
            pub fn new(value: impl AsRef<str>) -> Result<Self, StableIdError> {
                let value = value.as_ref();
                validate_stable_id(value)?;
                Ok(Self(value.to_owned()))
            }

            #[must_use]
            pub fn as_str(&self) -> &str {
                &self.0
            }
        }

        impl TryFrom<String> for $name {
            type Error = StableIdError;

            fn try_from(value: String) -> Result<Self, Self::Error> {
                validate_stable_id(&value)?;
                Ok(Self(value))
            }
        }

        impl TryFrom<&str> for $name {
            type Error = StableIdError;

            fn try_from(value: &str) -> Result<Self, Self::Error> {
                Self::new(value)
            }
        }

        impl From<$name> for String {
            fn from(value: $name) -> Self {
                value.0
            }
        }

        impl FromStr for $name {
            type Err = StableIdError;

            fn from_str(value: &str) -> Result<Self, Self::Err> {
                Self::new(value)
            }
        }

        impl fmt::Display for $name {
            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
                formatter.write_str(&self.0)
            }
        }
    };
}

define_stable_id!(NarrativeNodeId);
define_stable_id!(ChoiceId);
define_stable_id!(CharacterId);
define_stable_id!(LocationId);
define_stable_id!(QuestId);
define_stable_id!(ItemId);

A few design choices matter here:

  • Private field: callers cannot create ItemId("bad value".to_owned()) without validation.
  • No Default implementation: there is no meaningful “empty” node, quest, or item. A default ID would manufacture an invalid state.
  • Eq, Hash, and Ord: these IDs can be used in HashMap, HashSet, BTreeMap, and BTreeSet. The derived ordering is lexical, which is useful when you explicitly need a deterministic canonical order.
  • A shared internal macro: the macro removes mechanical duplication while preserving six concrete public types. This is infrastructure code, not an author-facing dialogue macro; richer content-authoring helpers come later.
  • Owned String representation: save data, runtime state, and authored registries can all use the same type without lifetime parameters. It is a pragmatic choice; profile before replacing it with an interning scheme or Arc<str>.

A small domain API now communicates its expectations directly:

fn find_character(id: &CharacterId) -> Option<CharacterDefinition> {
    // Registry lookup, implemented later.
    todo!()
}

fn enter_node(id: NarrativeNodeId) {
    // Narrative transition, implemented later.
}

let mira = CharacterId::new("mira")?;
let arrival = NarrativeNodeId::new("prologue.arrival")?;

let _character = find_character(&mira);
enter_node(arrival);

// find_character(&arrival); // Does not compile.
// enter_node(mira);         // Does not compile.

The type system catches category mistakes, while registry validation will later catch unknown values such as CharacterId("not_registered").


Preserve validity at the serialization boundary

Identifiers will appear in save snapshots, so deserialization must not create invalid IDs. The serde conversion attributes in the implementation ensure that Serde deserializes a raw string and then invokes TryFrom<String>, which runs validate_stable_id.

Container attributes

Read the try_from and into conversion attributes in Serde’s “Container attributes” reference. These attributes let a serialized primitive remain simple while the Rust domain type enforces its invariants.

In “Container attributes,” locate the subsection beginning with #[serde(try_from = "FromType")]. Read the conversion contract, including the requirements on TryFrom and Deserialize. In the ID implementation, String is the raw persistence representation and each typed ID is the validated domain representation.

The resulting JSON remains intentionally unremarkable:

"prologue.arrival"

It does not expose a Rust struct layout such as:

{ "0": "prologue.arrival" }

Nor does it depend on the Rust type name. The serialized value is the authored stable key.

Add focused tests beside id.rs:

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

    #[test]
    fn valid_identifier_is_preserved() {
        let id = NarrativeNodeId::new("prologue.arrival").unwrap();

        assert_eq!(id.as_str(), "prologue.arrival");
        assert_eq!(id.to_string(), "prologue.arrival");
    }

    #[test]
    fn invalid_identifier_forms_are_rejected() {
        for value in [
            "",
            "Prologue.Arrival",
            "harbor square",
            "harbor..square",
            ".harbor",
            "rusty-badge",
        ] {
            assert!(
                NarrativeNodeId::new(value).is_err(),
                "expected {value:?} to be rejected"
            );
        }
    }

    #[test]
    fn serde_round_trip_uses_a_scalar_and_revalidates_input() {
        let item = ItemId::new("rusty_badge").unwrap();

        let json = serde_json::to_string(&item).unwrap();
        assert_eq!(json, "\"rusty_badge\"");

        let restored: ItemId = serde_json::from_str(&json).unwrap();
        assert_eq!(restored, item);

        let invalid: Result<ItemId, _> =
            serde_json::from_str("\"Rusty Badge\"");
        assert!(invalid.is_err());
    }
}

The serialization assertion is more than cosmetic. It becomes a compatibility contract: existing saves contain "rusty_badge", so a refactor of ItemId’s internal Rust implementation should not change that representation.

Do not confuse deterministic ID ordering with authored narrative order. If a registry needs stable traversal, use a BTreeMap keyed by the typed ID or explicitly sort IDs. But do not use alphabetical identifier order as the intended order of dialogue choices, events, or gameplay effects unless that is explicitly part of the game rule.


Adoption rules for authored content

Use the following rules consistently as dialogue, quests, inventory, and the sandbox world are added:

  • Construct IDs at authored-content registration time and treat an error there as an authoring defect.
  • Once an ID ships in content or a save format, never reuse it for a different semantic entity.
  • Keep an ID when only display text, localization, source location, or Godot presentation changes.
  • Use a fresh ID when the underlying entity’s meaning changes materially.
  • Keep localized names separate. "mira" is a stable CharacterId; “Mira,” “Mira Valen,” and their translations are presentation resolved from localization keys later.
  • Pass the most specific type required by an API. Prefer fn start(id: NarrativeNodeId) over fn start(id: impl AsRef<str>).
  • Never accept a raw string at an engine boundary when a domain-specific ID can be accepted instead.

At this stage, the engine has a compact but important invariant: every cross-reference names its target with a validated stable value, and Rust prevents it from being confused with a different domain.


You now have six distinct identity types, a canonical textual format, validation at construction and deserialization, and a persistence-friendly representation. These are the foreign keys of the narrative runtime, expressed without a database and without any Godot dependency.

Next, we will define the mutable narrative-variable model: a serializable, type-checked representation for values such as flags, counters, relationships, and other state that dialogue conditions and effects will read and change.

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

Sign up