Godot integration
Description: Export a Narratyr project into a Godot 4 project and drive dialogues and quests from GDScript.
Prerelease. The Godot integration ships 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 writes two things into your game project: a reusable GDScript addon, and JSON, GDScript, translations, and assets generated from your Narratyr document. Your game talks to the addon through autoload singletons and creates flow players when it wants to run a dialogue.
Exporting covers the idea in general terms. This page is the Godot specifics: where to point the exporter, what each button writes, how to register the autoloads, and what your game has to implement before a dialogue will run.
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.
- Your Narratyr project, saved. Saving is especially important if it contains assets: the exporter copies them from the saved project's asset directory.
- A dialogue UI of your own. Narratyr walks the graph and tells you which line or choices are current; it deliberately does not impose a visual style or scene structure.
No native extension or compiler toolchain is required.
Setting the export path
Open the Export view in Narratyr and pick the Godot tab. Point Godot Project
Path at the folder containing project.godot — the project root, not addons/,
narratyr_data/, or the folder above the project.
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.
The exporter writes relative to whichever folder you choose. If the path is one level off,
you will get a new addons/ or narratyr_data/ folder in the wrong place rather than a
working integration. The path is remembered per Narratyr project and per 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 XLIFF.
Install Addon vs Export Data
The two buttons write different parts of the integration.
| Install Addon | Export Data | |
|---|---|---|
| Writes | Runtime and editor GDScript under addons/narratyr_data/ |
Project-specific files under narratyr_data/, copied assets, and translator files |
| Needs a compile | No | No |
| Run it when | Once at setup, and after a Narratyr update changes the addon | Whenever authored content or schema changes |
| Typical frequency | Occasionally | Constantly |
Install Addon writes the graph walker, expression evaluator, variable and quest managers, registries, save helper, and editor tooling. It does not enable the plugin or add autoloads for you, because those are settings owned by your Godot project.
Export Data writes the current variables, enumerations, entities, graphs, quests, translation source, generated quest-id constants, and binary assets. Unlike the Unreal exporter, a schema change does not produce compiled types in Godot: export data again and the GDScript runtime reads the new JSON.
Rule of thumb. Install the addon when the integration itself changes. Export data when your game data changes.
First-time setup
Do these steps once for a new Godot project:
- In Narratyr, set the Godot project path and click Install Addon.
- Click Export Data.
- Open the Godot project. Under Project → Project Settings → Plugins, enable Narratyr.
- Under Project → Project Settings → Autoload, register the variable manager, graph registry, quest manager, and entity registry as described below.
- Under Project → Project Settings → Localization → Translations, add the imported
source translation, normally
res://narratyr_data/translations.en.translationfor anensource locale.
Godot imports the exported CSV into one .translation resource per locale column. If the
resource does not 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.
Do not name an autoload NarratyrVariableManager, NarratyrGraphRegistry,
NarratyrQuestManager, or NarratyrEntityRegistry. Those are the scripts' class_name
values, and Godot does not 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.
What lands in your project
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
Files that have no corresponding data are omitted — for example, a project with no
variables has no variables.json.
Treat addons/narratyr_data/ and narratyr_data/ as generated output. Install or export
overwrites the files it owns, so put game code in your own scripts/ directory and do not
modify generated files in place. Godot's .import, .uid, and .translation artifacts
are engine-generated companions and are not written by Narratyr.
The exporter currently does not remove every old Godot file that is no longer produced. An
orphaned graph JSON is harmless because graph_registry.json no longer points to it, but
you can delete stale files from the generated folders if you want a clean tree.
The classes you will actually use
| Class | Kind | What it is for |
|---|---|---|
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 |
Every graph node and entity is a Dictionary. This keeps the addon independent of your
authored schema: adding an entity field changes JSON, not runtime source code.
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(), andget_variable_string()get_variable_array()andset_variable_array()for list and set variables; sets are de-duplicated at the write boundaryget_all_variable_names()andget_all_variables()has_variable(),get_declared_type(), andis_collection()reset_to_defaults()for starting a new gameinterpolate_variables()for@{VarName}placeholders and text-selection directives
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 is generated from quest names. Each constant's value is the stable graph id, so
the runtime identity does not change when you rename a quest. A rename does change the
constant's readable member name; update game code that uses that member after exporting.
Useful queries include get_quest_instance(), get_quest_definition(),
get_quests_by_state(), get_quest_prerequisites(), and
are_quest_start_requirements_satisfied().
Gameplay-driven objectives need a game-side update. Reaching an Objective node activates it, and an Update Objective node can change it from the graph. If the completion condition lives in gameplay — killing five boars, for example — 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() silently returns every quest and objective to its authored initial
state, which is useful when starting a new game.
NarratyrFlowPlayer
The flow player walks a graph. It auto-advances through routing, instruction, objective, and entry nodes; gates wait for their conditions; dialogue and choice nodes pause for the player. A blocking custom node also pauses, while a non-blocking custom node emits its signal and continues.
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 allows it to 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 only when you deliberately want 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 = speaker.get("display_name", "")
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, profile_image, and a properties dictionary grouped by Property Set
technical name.
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 when it is absent.
Flow signals you need to handle
Nothing in Narratyr draws UI. A working integration binds the signals that tell the game what the flow reached, displays the result, and resumes the supplied flow player when the player acts.
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 own signals here |
quest_dialogue_started |
(quest_id, flow_player, dialogue_node) |
A quest flow reached dialogue; open your dialogue UI and retain this specific 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) |
Broad “something changed” notification, also used internally by gates |
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 a primary flow plus secondary flows spawned by async listeners or Fork
nodes. Save restore also creates fresh flow-player nodes. That is why manager signals hand
you the relevant player and why quest_flow_player_ready is the right binding point — do
not assume a quest has one permanent player.
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, andcustom_node_enteredobjective_entered,instruction_entered, andgate_enteredstart_dialogue_node_entered,start_quest_fragment_entered, andstart_quest_async_enteredcomplete_quest_entered,update_objective_entered, andend_conversation_entered
Lifecycle signals are:
| Signal | Meaning |
|---|---|
graph_pushed(from_graph_id, to_graph_id) |
The flow dived into 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 details of a dead end |
Treat DeadEnd as a content error during development. It means the current node had
outgoing edges but every route was blocked; flow_error includes the per-edge reasons.
Building the dialogue UI
At a dialogue or choice stop, ask the flow whether the author created a choice menu.
is_at_player_choice_menu() distinguishes a real menu from ordinary conditional routing.
If it returns false, display one Continue affordance:
continue_button.pressed.connect(flow.continue_flow)
If it returns true, render get_available_paths(). Each path may contain:
choice_label,label_on_disabled, andslot_idpassable,hidden, anddisabledblocked_reasonwhen no route can be taken
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 authored presentation rules. A path can also be non-passable
because its edge condition is false. Do not call choose() for any disabled or
non-passable button.
Stopping on other node types
The default stop set is dialogue, choice, and custom; only custom nodes whose exported
blocking flag is true actually halt. 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() after your game
finishes handling it, or the flow remains parked. The runtime logs a development warning
when this happens.
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 that read Narratyr variables or quest status re-evaluate automatically. If a gate
depends on state the addon cannot observe — inventory or world state behind a game
function, for example — call flow.reevaluate_gate() or
QuestManager.reevaluate_quest_gate(quest_id) after that state changes.
Implementing functions
Functions declared in Functions are calls into your game. Godot does not generate a base class or stubs for them; create an object with methods whose names and parameters exactly match the definitions in Narratyr:
# 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; enum values arrive as their authored integers.
Do not 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.
Functions cannot appear in event listener conditions. Listeners re-evaluate on observable state changes, and the addon cannot know when opaque game state changed. Mirror that state into a Narratyr variable instead.
Save and restore
Narratyr does not choose a save-file format or slot. NarratyrSaveState gives you one
dictionary containing variables, traversal history, quests, objectives, and every live
quest flow:
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.
Things to know:
- Major version mismatches may be refused; minor ones are not. The snapshot carries a
major (
"version") and a minor ("minor_version"). Major version changes are generally reserved for modifications that would break compatibility. A major mismatch means a persisted key changed shape — an error is pushed, nothing is restored, and the save should be treated as unloadable. A minor mismatch only ever means keys were added, so the restore proceeds after a warning and anything the snapshot lacks keeps its default. - The snapshot is an interchange format, but restore is not. The captured dictionary is
plain data, so you can re-encode it into any container or format you like, and
"version"/"minor_version"travel with it. Going the other way, rebuild the dictionary and hand it torestore(): reconstructing state by calling individual setters is not supported. There is no "set quest state", the objective flags and activation order round-trip only through the snapshot, and secondary flow players can only be rebuilt by the quest instance that owns them. restore()returns what it did.RESTORED,RESTORED_WITH_MINOR_MISMATCH,REFUSED_MAJOR_MISMATCH(nothing restored — the managers still hold whatever they had), orSUBSYSTEMS_MISSING. The refusal is also pushed to the log, but the return value is what lets you react in your UI. To decide before committing to a load, compare the snapshot's"version"againstNarratyrSaveState.CURRENT_MAJOR_VERSION.- Editing a quest's objectives does not invalidate its saves. Objective state is restored by objective id, so adding, removing, or reordering objectives in Narratyr is safe. An objective in the save that the quest no longer has is skipped with a warning; one added since the save starts at its authored state.
- Reordering an enumeration does not invalidate its saves either. An enum-typed variable persists its entry key, not the enumeration's integer, so a saved value follows its key. An entry the enumeration no longer has is skipped with a warning and the variable keeps its current value; for a list or set, one unresolvable entry leaves the whole collection alone rather than silently dropping members. Renaming or deleting an entry is the case that costs you a saved value — reordering and appending are free.
- Restoring an active quest creates new flow-player nodes and emits
quest_flow_player_readybefore each one resumes. Manager-level bindings therefore rebuild themselves. - A standalone flow player is not part of the quest snapshot. Store its
capture_state()dictionary yourself and callrestore_state()after restoring Narratyr variables. - A new game normally calls
VariableManager.reset_to_defaults()andQuestManager.reset_all(). The quest reset is intentionally silent, so refresh quest UI yourself. - JSON is convenient for the exported value types. If your game uses
var_to_bytes(), a custom Resource, or another save format, the captured dictionary can live inside that format instead.
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, 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_variables(localized_line)
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.
Localization and variable interpolation are separate operations. Translate first, then
call interpolate_variables() so every locale can position its @{Var} placeholders
naturally.
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 the helper above.
Rich text. Set the project's rich text format to Godot (BBCode) in Narratyr's
project settings. Exporting a project configured for another engine's markup produces a
warning. Present the resulting text in a RichTextLabel with BBCode enabled; fonts,
colors, and theme resources remain under your game's control.
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 does not import these files as runtime resources.
Editor tooling
Enabling the Narratyr plugin adds a variable viewer and a graph-id Inspector control.
RPG Variables panel
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; click Refresh to request a new
snapshot.
The current panel is a viewer rather than an editor. Change values through the running game, the Remote scene tree, or your own debug commands and watch the panel update.
If it stays on defaults while the game is running, confirm that the variable-manager autoload is enabled and 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 rather than
silently clearing the property.
Iterating
The day-to-day loop is: change content in Narratyr, click Export Data, and return to Godot. Let Godot finish its filesystem scan before running if JSON, translations, or copied assets changed.
Re-run Install Addon after a Narratyr update that ships runtime changes. Because the addon is GDScript, Godot reparses it; there is no native build step. Re-enable the plugin if your project settings had it disabled, but a normal reinstall does not change the plugin's path.
Exports are derived artifacts. 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 names the cap and your project's node count — activate a Pro licence or reduce the node count.
The addon or data does not 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 will not 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 custom node may be authored as blocking and need continue_flow().
A listener never fires. Listener conditions cannot 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.