Documentation menu

Godot integration

Description: Export a Narratyr project into a Godot 4 project and drive dialogues and quests from GDScript.

Prerelease. The Godot integration comes with Narratyr rather than from the Asset Library. The exporter writes the addon into your project for you. Details here may change before release.

The Godot exporter adds a GDScript addon and your Narratyr data to your game project. Your game uses the addon's autoload singletons to manage data and flow players to run dialogues.

This guide covers setup and the GDScript API. See Exporting for the shared export workflow.

Before you start

  • A Godot 4 project. The runtime is GDScript and works in both GDScript and C# Godot projects, but the API shown here is GDScript.
  • A saved Narratyr project. The exporter copies assets from the saved project's asset directory.
  • Your own dialogue UI. Narratyr runs the graph and supplies the current line or choices. Your game displays them.

Setting the export path

Open the Export view in Narratyr and pick the Godot tab. Point Godot Project Path at the project root, the folder containing project.godot.

MyGame/
  project.godot       <-- choose this folder
  addons/
  scenes/
  scripts/

For a project at /home/me/games/MyGame/project.godot, the export path is /home/me/games/MyGame.

Choosing the wrong folder puts the exported files outside your Godot project. Narratyr remembers the path for each project and engine.

Source Locale is the language code for the text currently authored in Narratyr, such as en, en_GB, or ja. It becomes the locale column in Godot's translation CSV and the source-language value in the translator .xlf file.

Install the add-on and export data

Install Addon Export Data
Writes Runtime and editor GDScript under addons/narratyr_data/ Project-specific files under narratyr_data/, copied assets, and translator files
Run it when Once at setup, and after a Narratyr update changes the addon Whenever authored content or schema changes

Install Addon writes the graph walker, expression evaluator, variable and quest managers, registries, save helper, and editor tooling. You enable the plugin and register the autoloads in Godot's project settings.

Export Data writes the current variables, enumerations, entities, graphs, quests, translation source, generated quest-id constants, and binary assets. After a schema change, export again so the runtime can read the updated JSON.

First-time setup

Do these steps once for a new Godot project:

  1. Choose the Godot project path in Narratyr. Select Install Addon.
  2. Select Export Data.
  3. Open the Godot project. Under Project → Project Settings → Plugins, enable Narratyr.
  4. Under Project → Project Settings → Autoload, register the variable manager, graph registry, quest manager, and entity registry as described below.
  5. Under Project → Project Settings → Localization → Translations, add the imported source translation, normally res://narratyr_data/translations.en.translation for an en source locale.

Godot imports the exported CSV into one .translation resource per locale column. If the resource doesn't appear immediately, let the filesystem scan finish or reimport narratyr_data/translations.csv before adding it.

Registering the autoloads

Add these four scripts on the Autoload page:

Suggested name Path Purpose
VariableManager res://addons/narratyr_data/narratyr_variable_manager.gd Runtime variable values, expression functions, interpolation
GraphRegistry res://addons/narratyr_data/narratyr_graph_registry.gd Lazy graph loading and cross-graph resolution
QuestManager res://addons/narratyr_data/narratyr_quest_manager.gd Quest instances, objectives, and quest-owned flows
EntityRegistry res://addons/narratyr_data/narratyr_entity_registry.gd Entity lookup by stable Narratyr id or game id

Keep Enable turned on for each one.

Don't name an autoload NarratyrVariableManager, NarratyrGraphRegistry, NarratyrQuestManager, or NarratyrEntityRegistry. Those are the scripts' class_name values, and Godot doesn't allow an autoload global to use the same name. The suggested short names are used throughout this guide.

The quest manager finds the variable manager and graph registry by type, independent of autoload order or the names you chose. You can also wire custom instances explicitly:

QuestManager.initialize(VariableManager, GraphRegistry)

The entity registry is independent and is only required if your game uses exported entities, dialogue speakers, or entity-reference fields.

Exported files

MyGame/
  addons/
    narratyr_data/                 installed addon; generated by Install Addon
      plugin.cfg
      narratyr_flow_player.gd
      narratyr_variable_manager.gd
      narratyr_quest_manager.gd
      narratyr_entity_registry.gd
      narratyr_graph_registry.gd
      narratyr_save_state.gd
      ...
  narratyr_data/                   generated by Export Data
    variables.json
    enumerations.json
    graph_registry.json            graph id -> JSON path
    graph_index.json               names and ids for the Inspector dropdown
    graphs/                        one JSON file per graph
    quest_data.json
    narratyr_quest_ids.gd          generated QuestIds constants
    entity_registry.json
    entities/                      one JSON file per entity type
    translations.csv               Godot runtime translation source
    assets/                        mirrors Narratyr's asset folders
  NarratyrLocalization/            translator handoff; ignored by Godot
    .gdignore
    translator-source.csv
    translator-source.xlf

The exporter omits files with no data. A project with no variables has no variables.json.

Installation and export overwrite generated files in addons/narratyr_data/ and narratyr_data/. Put game code in your own scripts/ directory. Godot generates the companion .import, .uid, and .translation files.

The exporter may leave old files in place. An orphaned graph JSON is harmless because graph_registry.json no longer references it. You can delete these unused files.

Runtime classes

Class Kind Purpose
NarratyrVariableManager Node, normally an autoload Read and write variables, register game functions, interpolate @{Var} text
NarratyrQuestManager Node, normally an autoload Start, complete, fail, and query quests. Bind quest-wide signals
NarratyrQuestInstance RefCounted, one per quest Live quest and objective state, localized quest text, owned flow players
NarratyrFlowPlayer Node Walk a graph and expose the current dialogue or choices
NarratyrGraphRegistry Node, normally an autoload Resolve a stable graph id to parsed graph data
NarratyrEntityRegistry Node, normally an autoload Resolve exported entity dictionaries
NarratyrNodeUtil Static helper Typed accessors for node dictionaries
NarratyrSaveState Static helper Capture and restore variables and quests in the correct order
QuestIds Generated constants class Readable quest references such as QuestIds.TheAmulet

Graph nodes and entities are dictionaries. Adding an entity field requires only a data export.

NarratyrVariableManager

The variable manager loads variables.json and enumerations.json on startup. Use the typed accessors when you know the declaration's type:

VariableManager.set_variable_int("BoarsKilled", 5)
VariableManager.set_variable_bool("MetTheJarl", true)

var killed: int = VariableManager.get_variable_int("BoarsKilled")
var met_jarl: bool = VariableManager.get_variable_bool("MetTheJarl")

Other useful methods include:

  • get_variable(), get_variable_number(), and get_variable_string()
  • get_variable_array() and set_variable_array() for list and set variables. Writing a set removes duplicates
  • get_all_variable_names() and get_all_variables()
  • has_variable(), get_declared_type(), and is_collection()
  • reset_to_defaults() for starting a new game
  • interpolate_variables() for @{VarName} placeholders and variable-based text-selection directives
  • interpolate_dialogue_text(text, dialogue_node_id) for a dialogue node's full text, including its contextual @{visited, ...} directive

Every successful write emits variable_changed(var_name, new_value). That signal also wakes gates and event listeners, so write through the manager rather than changing its internal dictionary.

NarratyrQuestManager

The quest manager loads quest_data.json and creates one NarratyrQuestInstance per quest graph.

QuestManager.discover_quest(QuestIds.DownWithTheJarl)
QuestManager.start_quest(QuestIds.DownWithTheJarl)

var active_quests := QuestManager.get_active_quests()
var state := QuestManager.get_quest_state(QuestIds.DownWithTheJarl)

QuestIds constants use quest names, with stable graph ids as their values. Renaming a quest preserves its id but changes the constant's name. Update references in your game code after exporting.

Useful queries include get_quest_instance(), get_quest_definition(), get_quests_by_state(), get_quest_prerequisites(), and are_quest_start_requirements_satisfied().

Reaching an Objective node activates it. An Update Objective node can change its state. For objectives completed through gameplay, such as killing five boars, notify the manager:

QuestManager.update_objective(
    QuestIds.DownWithTheJarl,
    "the-stable-objective-node-id",
    "completed"
)

The status can be "completed", "failed", "not_applicable", or "active" to reopen a terminal objective. The optional fourth and fifth arguments patch the objective's hidden and optional flags. Pass null to leave a value unchanged.

Use complete_quest(), fail_quest(), or reset_quest() for explicit quest lifecycle changes. reset_all() returns every quest and objective to its initial project state, which is useful when starting a new game.

NarratyrFlowPlayer

The flow player advances through routing, instruction, objective, and entry nodes automatically. Gates wait for their conditions. Dialogue and choice nodes pause for the player. Custom nodes emit a signal and pause only if blocking.

For a standalone dialogue, create a player, add it to the scene tree, initialize it, bind signals, and then call play():

var conversation: NarratyrFlowPlayer

func start_conversation(graph_id: String) -> void:
    var graph := GraphRegistry.resolve_graph(graph_id)
    if graph.is_empty():
        return

    conversation = NarratyrFlowPlayer.new()
    add_child(conversation)
    conversation.initialize(VariableManager, GraphRegistry)
    conversation.dialogue_node_entered.connect(_on_dialogue)
    conversation.flow_ended.connect(_on_conversation_ended)
    conversation.flow_error.connect(_on_flow_error)
    conversation.play(graph)

Adding the player as a child gives it a lifecycle and lets it find the quest-manager autoload for quest-status expressions. Keep the reference while your UI needs to call continue_flow() or choose().

Calling play(graph) with no start node resolves the graph's entry node. Pass a stable node id as the second argument to choose another starting point.

NarratyrEntityRegistry

Entity references in graphs and fields store the entity's stable Narratyr id. Resolve that id with find_entity_by_id():

var speaker_id := NarratyrNodeUtil.speaker_entity(dialogue_node)
var speaker := EntityRegistry.find_entity_by_id(speaker_id)
if not speaker.is_empty():
    speaker_label.text = EntityRegistry.get_localized_display_name(speaker)
    var profile_path: String = speaker.get("profile_image", "")
    if not profile_path.is_empty():
        portrait.texture = load(profile_path)

An entity dictionary contains id, technical_name (the game-facing external id), display_name, display_name_localization_key, profile_image, and a properties dictionary grouped by Property Set technical name. display_name remains the source and authoring label. get_localized_display_name() resolves the stable localization key for the active locale and falls back to that source label when no translation exists.

Use find_entity(game_id) for a game-facing external id, or narratyr_id_to_game_id() / game_id_to_narratyr_id() to translate between the two. find_entities_by_type(type_name) returns every entity for one Entity Type.

NarratyrNodeUtil and graph metadata

Flow signals carry raw dictionaries. NarratyrNodeUtil provides stable accessors for the common fields:

func _on_dialogue(node: Dictionary) -> void:
    var id := NarratyrNodeUtil.id(node)
    var speaker_id := NarratyrNodeUtil.speaker_entity(node)
    var source_text := NarratyrNodeUtil.text(node)
    var fields := NarratyrNodeUtil.field_values(node)

For user-defined graph metadata, use the registry:

var metadata := GraphRegistry.resolve_graph_metadata(graph_id)
var difficulty = GraphRegistry.get_graph_metadata_field(graph_id, "Difficulty")

get_graph_metadata_field() looks up a field by its authored PascalCase name and returns null for a missing field.

Flow signals you need to handle

Connect flow signals to your UI handlers. Use the supplied flow player to resume the conversation after the player responds.

Bind on the quest manager for quest content

For quest-driven content, connect once at startup:

Signal Payload Why you want it
quest_flow_player_ready (quest_id, flow_player) Fires after a flow exists but before it runs. Attach that flow's signals here
quest_dialogue_started (quest_id, flow_player, dialogue_node) A quest flow player reached a dialogue node. Open your dialogue UI and retain this player
quest_custom_node_started (quest_id, flow_player, custom_node) A quest flow entered a custom node. Read its payload and resume it if blocking
quest_state_changed (quest_id, new_state) Update quest logs and notifications
objective_state_changed (quest_id, objective) Update objective UI. The dictionary includes state, hidden, and optional
quest_progress_changed (quest_id) Notification of quest or objective changes
func _ready() -> void:
    QuestManager.quest_flow_player_ready.connect(_on_quest_flow_ready)
    QuestManager.quest_dialogue_started.connect(_on_quest_dialogue)
    QuestManager.objective_state_changed.connect(_on_objective_changed)

func _on_quest_flow_ready(quest_id: String, flow: NarratyrFlowPlayer) -> void:
    flow.flow_error.connect(func(message: String): _on_flow_error(quest_id, message))
    flow.event_listener_fired.connect(
        func(node: Dictionary): _on_listener_fired(quest_id, flow, node)
    )

func _on_quest_dialogue(
    quest_id: String,
    flow: NarratyrFlowPlayer,
    node: Dictionary
) -> void:
    dialogue_ui.present(quest_id, flow, node)

A quest can have several flows, and restoring a save creates new flow players. Bind each player's signals when quest_flow_player_ready fires, including after a restore.

The flow player's own signals

Every visited node emits node_entered(node_id, node_type) and later node_exited(node_id, node_type). There is also a typed signal for each node kind, including:

  • dialogue_node_entered, choice_entered, and custom_node_entered
  • objective_entered, instruction_entered, and gate_entered
  • start_dialogue_node_entered, start_quest_fragment_entered, and start_quest_async_entered
  • complete_quest_entered, update_objective_entered, and end_conversation_entered

start_quest_async_entered is observational: the flow player has already asked the Quest Manager to start the target quest before this signal fires. The player resolves the manager autoload for standalone dialogue flows and uses the explicit manager on quest-owned flows.

Lifecycle signals are:

Signal Meaning
graph_pushed(from_graph_id, to_graph_id) The flow entered a dialogue or fragment
graph_popped(to_graph_id) The flow returned to a parent graph
event_listener_fired(node) A listener is about to interrupt the current presentation. Suspend the old UI
flow_ended(reason) The run ended as Natural, Stopped, Error, or DeadEnd
flow_error(message) Missing data, an evaluation failure, or route failure details

Treat DeadEnd as a content error during development. It means the current node had outgoing edges but no passable route. flow_error includes the reason for each edge.

Build the dialogue interface

At a dialogue or choice stop, check whether the project contains a choice menu. is_at_player_choice_menu() distinguishes a real menu from ordinary conditional routing.

If it returns false, display a Continue button:

continue_button.pressed.connect(flow.continue_flow)

If it returns true, render get_available_paths(). Each path may contain:

  • choice_label, label_on_disabled, and slot_id
  • passable, hidden, and disabled
  • blocked_reason when the runtime can't take a route

The index accepted by choose() is the index in the passable-path list. Count every passable path, including hidden paths, while building buttons:

var choose_index := 0
for path in flow.get_available_paths():
    var runtime_index := -1
    if path.get("passable", false):
        runtime_index = choose_index
        choose_index += 1

    if path.get("hidden", false):
        continue

    var button := Button.new()
    button.disabled = runtime_index < 0 or path.get("disabled", false)
    var label: String = path.get("choice_label", "")
    if button.disabled and not path.get("label_on_disabled", "").is_empty():
        label = path["label_on_disabled"]
    button.text = label

    if runtime_index >= 0:
        button.pressed.connect(flow.choose.bind(runtime_index))
    choice_container.add_child(button)

hidden and disabled are presentation rules stored in the project. A path can also be unavailable because its edge condition is false. Don't call choose() for an unavailable button.

Stopping on other node types

By default, flows stop at dialogue, choice, and blocking custom nodes. You can replace stop_on_types before playback:

func _on_quest_flow_ready(_quest_id: String, flow: NarratyrFlowPlayer) -> void:
    flow.stop_on_types = ["dialogue", "choice", "custom", "objective"]

A type you add has no automatic resume mechanism. Call continue_flow() to resume the flow after your game handles the node. The runtime logs a development warning for these custom stops.

try_continue() advances only when a route is currently passable and otherwise stays at the current node. QuestManager.try_advance_quest(quest_id) forwards to the primary quest flow.

Gates re-evaluate automatically when a variable updates or a quest state changes. If a gate depends on game state outside Narratyr, such as inventory, call flow.reevaluate_gate() or QuestManager.reevaluate_quest_gate(quest_id) after that state changes.

Implementing functions

For each function declared in Narratyr, implement a method on a game object with the same name and parameters:

# scripts/game_functions.gd
class_name GameFunctions
extends RefCounted

var inventory: Array[String] = []

func hasInventoryItem(item_id: String) -> bool:
    return item_id in inventory

func addInventoryItem(item_id: String, count: int) -> void:
    for _i in range(count):
        inventory.append(item_id)

Register one instance before any graph runs:

func _ready() -> void:
    VariableManager.function_host = GameFunctions.new()

The expression evaluator sends plain Godot values and calls the method by its authored name. Lists and sets arrive as Array. Enumeration values arrive as their authored integers.

Don't implement Narratyr's built-ins (length, count, indexOf, randomInteger, randomNumber, debugLog, traversal counters, or quest-status functions). The addon handles those before it consults your function host.

Event listener conditions can't call game functions. Listeners respond to changes in Narratyr state. To track game state, copy it into a Narratyr variable.

Save and restore

NarratyrSaveState captures variables, traversal history, quests, objectives, and every live quest flow in a dictionary. Store it in your game's save format:

func save_narratyr(path: String) -> void:
    var state := NarratyrSaveState.capture(VariableManager, QuestManager)
    var file := FileAccess.open(path, FileAccess.WRITE)
    file.store_string(JSON.stringify(state))

func load_narratyr(path: String) -> void:
    var file := FileAccess.open(path, FileAccess.READ)
    var state: Dictionary = JSON.parse_string(file.get_as_text())
    var result := NarratyrSaveState.restore(state, VariableManager, QuestManager)
    if result == NarratyrSaveState.RestoreResult.REFUSED_MAJOR_MISMATCH:
        # The save is unreadable by this build and NOTHING was restored.
        # Tell the player; do not carry on as though it loaded.
        pass

The helper restores variables before quests, so resumed edge and gate evaluation sees the loaded values.

  • Major version mismatches refuse the restore. The snapshot includes "version" and "minor_version". A major mismatch logs an error and leaves the current state unchanged. A minor mismatch logs a warning and restores the save, using defaults for missing fields.
  • Restore through restore(). You can encode the snapshot in any save format, but preserve both version fields and rebuild the dictionary before passing it to restore(). Individual setters can't restore all quest state, including objective activation order and secondary flows.
  • Check the result. restore() returns RESTORED, RESTORED_WITH_MINOR_MISMATCH, REFUSED_MAJOR_MISMATCH, or SUBSYSTEMS_MISSING. Use the result to report load failures in your UI. To check compatibility before loading, compare the snapshot's "version" with NarratyrSaveState.CURRENT_MAJOR_VERSION.
  • Objective edits preserve saves. Restore matches objectives by id. It skips removed objectives with a warning and starts new objectives at their authored state. Reordering objectives is safe.
  • Enumeration values use entry keys. Reordering or adding entries preserves saved values. If you rename or delete an entry, restore warns and leaves the variable unchanged. For a list or set, one missing entry prevents restoring the whole collection.
  • Restoring an active quest creates new flow players and emits quest_flow_player_ready before each resumes. Connect handlers there to restore your UI bindings.
  • Save a standalone flow player's capture_state() dictionary in your save file. Call its restore_state() after restoring Narratyr variables.
  • For a new game, call VariableManager.reset_to_defaults() and QuestManager.reset_all(). The quest reset runs without signals. Refresh your quest UI yourself.

Localization, interpolation, and rich text

The runtime translation source is narratyr_data/translations.csv. Its stable keys cover dialogue text, choice labels, quest and objective text, entity display names, enumeration display labels, translatable entity fields, and graph metadata.

After Godot imports the CSV and you add its generated .translation resource in Project Settings, use normal tr() lookups. Graph node dictionaries retain the source string, so use it as a fallback when a translation is missing:

func localized_field(item: Dictionary, field: String) -> String:
    var fallback: String = item.get(field, "")
    var key := "%s.%s" % [item.get("id", ""), field]
    var translated := tr(key)
    return fallback if translated == key else translated

For a quest, NarratyrQuestInstance already provides this behavior:

var quest := QuestManager.get_quest_instance(quest_id)
var localized_line := quest.localized_field(dialogue_node, "text")
var displayed_line := VariableManager.interpolate_dialogue_text(
    localized_line,
    dialogue_node["id"]
)

It also has get_quest_name(), get_quest_description(), get_objective_name(), get_objective_description(), and get_objective_group_name(). QuestManager.get_quest_definition() returns a localized snapshot suitable for building a quest log.

Translate first, then call interpolate_dialogue_text() to expand directives in the translated dialogue. Use interpolate_variables() for other fields. Without a node id, it leaves @{visited, ...} unchanged.

For a choice, the translation key is "%s.label" % path["slot_id"]. Use the raw choice_label as its fallback, following the same pattern as localized_field().

Rich text. In Narratyr's project settings, choose Godot (BBCode) as the rich text format. project settings. Exporting a project configured for another engine's markup produces a warning. Display the text in a RichTextLabel with BBCode enabled. Your game controls fonts, colors, and themes.

NarratyrLocalization/translator-source.csv and translator-source.xlf are richer handoff files for translators. They include speaker, graph, and role context. The folder contains a .gdignore, so Godot doesn't import these files as runtime resources.

Editor tooling

Enabling the Narratyr plugin adds a variable viewer and a graph-id Inspector control.

Variable viewer

The RPG Variables bottom panel shows every variable's name, type, and value. Outside a play session it reads authored defaults from variables.json. During a debug session it receives live values from the variable-manager autoload. Select Refresh for a new snapshot.

To change values, use the running game, the Remote scene tree, or your debug commands.

If it continues to show defaults while the game runs, confirm that Enable is on for the variable-manager autoload. Also confirm that you launched the game from an editor debug session.

Graph-id dropdowns in the Inspector

The plugin can turn an exported String property into a graph-name dropdown while storing the stable graph id. Mark the property with an export placeholder:

@export_placeholder("narratyr_graph_id")
var any_graph_id: String

@export_placeholder("narratyr_graph_id:dialogue")
var conversation_id: String

@export_placeholder("narratyr_graph_id:quest")
var quest_id: String

The optional suffix filters the list to dialogue or quest graphs. The choices come from graph_index.json, so export data again after adding or renaming graphs. If a referenced graph disappears, the Inspector preserves its id and displays it as missing.

Iterating

After editing content in Narratyr, select Export Data. Wait for Godot's filesystem scan to finish before running the game.

Run Install Addon after a Narratyr update changes the runtime. Godot loads the updated GDScript without a native build step. Re-enable the plugin if needed.

Exports come from the Narratyr source project. Teams can commit addons/narratyr_data/, narratyr_data/, and the relevant Godot import metadata, or regenerate them as part of a build step. Whichever policy you choose, keep the Narratyr source project as the authority.

From the command line

The narratyr CLI runs the same exporter headlessly:

narratyr export --engine godot --operation data \
  --project /path/to/MyStory.ntproj \
  --out /path/to/MyGodotProject \
  --source-locale en

--operation accepts install, data, or both. --json emits a machine-readable result for CI. The CLI reads the license installed by the desktop app, so the same entitlements apply.

Troubleshooting

Exporting is disabled. The free tier caps how many graph nodes a project can export. The message shows the limit and your project's node count. Activate a Pro licence or reduce the node count.

The addon or data doesn't appear in Godot. Confirm the export path is the directory that contains project.godot, then wait for Godot's filesystem scan. An addons/ folder beside, rather than inside, the project is a sign the path was one level off.

The Narratyr plugin won't enable. Run Install Addon again and inspect the first GDScript parse error in Godot's Output panel. Also confirm the project is Godot 4, not Godot 3.

An autoload reports a name conflict. Its autoload name matches the script's class_name. Use VariableManager, GraphRegistry, QuestManager, and EntityRegistry, not the longer Narratyr… names.

A manager says its JSON file is missing. Run Export Data, confirm narratyr_data/ is at the project root, and restart the running game so the autoload reloads its defaults.

Text displays a key such as abc123.text. Add the imported source .translation resource under Project Settings → Localization → Translations. For user-created lookup helpers, fall back to the raw node field when tr(key) == key.

A flow stops and nothing happens. Make sure the UI connected before play() or through quest_flow_player_ready. Also bind flow_error: a DeadEnd means the node had routes but none were passable. A blocking custom node needs continue_flow() to resume.

A listener never fires. Listener conditions can't call game functions and are re-evaluated on observable writes. Mirror the relevant state into a Narratyr variable and write it through VariableManager.

A gate never opens. If its condition depends on state outside the variable and quest managers, call reevaluate_gate() after that state changes.