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:
- Choose the Godot project path in Narratyr. Select Install Addon.
- Select 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 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(), andget_variable_string()get_variable_array()andset_variable_array()for list and set variables. Writing a set removes duplicatesget_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 variable-based text-selection directivesinterpolate_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, 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
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, andslot_idpassable,hidden, anddisabledblocked_reasonwhen 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 torestore(). Individual setters can't restore all quest state, including objective activation order and secondary flows. - Check the result.
restore()returnsRESTORED,RESTORED_WITH_MINOR_MISMATCH,REFUSED_MAJOR_MISMATCH, orSUBSYSTEMS_MISSING. Use the result to report load failures in your UI. To check compatibility before loading, compare the snapshot's"version"withNarratyrSaveState.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_readybefore each resumes. Connect handlers there to restore your UI bindings. - Save a standalone flow player's
capture_state()dictionary in your save file. Call itsrestore_state()after restoring Narratyr variables. - For a new game, call
VariableManager.reset_to_defaults()andQuestManager.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.