Appearance
Tarinoi for Godot
Plugin version 0.1.0 — requires the latest stable Godot 4.x
From an empty project to dialogue on screen in about ten minutes. Read Game Engine Plugins first if you have not — it covers what this plugin does and, just as importantly, what it leaves to you.
Pre-1.0
Version 0.1.0. The API will change before 1.0. Only the latest stable Godot 4.x is supported; there are no backports to earlier point releases.
You will need: a Tarinoi project with at least one start card, and permission to create an API token for it.
1. Install the plugin
Download tarinoi-godot-plugin-v0.1.0.zip from the latest release, then copy both folders from inside its addons/ into your project's addons/:
your_project/
addons/
godot-sqlite/
tarinoi/
plugin.cfg
plugin.gd
autoload/
core/
nodes/
scenes/The plugin keeps synced content in a local SQLite database, so it depends on godot-sqlite v4.7. That dependency is bundled in the download, with prebuilt binaries for macOS, Windows, Linux, Android and web — there is nothing separate to install.
You can also browse or clone the repository if you would rather work from source. The release zip is the same addons/ content without the test suite, internal notes and build tooling.
macOS — clear the quarantine flag first
Files downloaded through a browser are quarantined, so Gatekeeper blocks the binaries as unverified when Godot loads them, producing a run of "cannot be opened because Apple could not verify…" dialogs on import.
Clear the flag before opening the project:
bash
xattr -cr addons/godot-sqlite/bin/Clicking Allow Anyway per binary in System Settings → Privacy & Security is not a reliable substitute — it does not always clear the block. Use the command.
Windows — unblock the zip before extracting
The downloaded zip carries a Mark of the Web, and some antivirus software (Windows Defender included) quarantines or silently deletes unsigned DLLs on extraction. If addons/godot-sqlite/bin/ looks empty or partial after unzipping, check Windows Security → Virus & threat protection → Protection history for a removed item and restore it.
Unblock first to avoid it entirely — in PowerShell:
powershell
Unblock-File -Path bin.zipor right-click the zip → Properties → tick Unblock → OK.
You need at least libgdsqlite.windows.template_debug.x86_64.dll to run in the editor.
iOS — install godot-sqlite separately
The iOS binaries are 286 MB of xcframeworks, too large to ship in the download, so they are not included. Install godot-sqlite from its GitHub releases — or from Godot's AssetLib tab — and let it overwrite addons/godot-sqlite/, which restores the missing bin/*.ios.* files. Every other platform works as shipped.
Errors right after copying are expected
Godot parses the plugin scripts immediately and reports that TarinoiRuntime is not declared. The autoload does not exist until the plugin is enabled, which is the next step.
2. Enable both plugins
Project → Project Settings → Plugins. Set Godot SQLite and Tarinoi to Active. Restart the editor if prompted.
Enabling Tarinoi registers the TarinoiRuntime autoload singleton and adds a Tarinoi section to Project Settings.
3. Point Godot at your project
Project → Project Settings, scroll to Tarinoi.
| Setting | Value |
|---|---|
tarinoi/api/path | Your project's documents URL, ending in /documents. Copy it from Tarinoi's Integrate wizard. |
Then set the token: Tools → Tarinoi: Set Tarinoi API token…. A Read token is enough.
The token is stored under user://tarinoi/, outside your project directory, so it cannot be committed and does not travel into a build.
4. Sync
Tools → Tarinoi: Sync.
Watch the Output panel — a successful run logs Tarinoi: sync complete. Syncing again is incremental; only what changed comes down. If it fails, the message names the cause: check the URL and the token first.
5. Generate your bindings
Tools → Tarinoi: Regenerate Bindings.
This reads the function, variable, list, and entity declarations out of the local database and writes four GDScript files into tarinoi/codegen/output_path (by default res://bindings/generated/):
| File | Contents |
|---|---|
tarinoi_functions.gd | One inner class per function collection, one stub method per declared function |
tarinoi_variables.gd | One inner class per variable collection, a typed field per declared variable, plus get_variable / set_variable |
tarinoi_lists.gd | Nested constants for every list option key |
tarinoi_entities.gd | Constants for every entity identifier |
Functions arrive as stubs with push_error() bodies — implementing them is how your game gives authored names meaning. Variables arrive as real, typed, defaulted fields, so a pass-through implementation needs nothing but extends.
Commit the generated files. Implement against them in a separate directory so regeneration never overwrites game logic:
gdscript
# res://bindings/impl/global_variables.gd
class_name GlobalVariables
extends TarinoiVariables.TarinoiGlobalVariablesBecause the declared variables are inherited fields, renaming one in Tarinoi and forgetting to update the code that reads .pc_health is a parse error in the editor rather than a silent runtime mismatch.
If a variable's storage belongs somewhere else — a savegame, a blackboard — override the accessors and defer to super for the rest:
gdscript
class_name GlobalVariables
extends TarinoiVariables.TarinoiGlobalVariables
func get_variable(variable_name: String) -> Variant:
if variable_name == "pc_health":
return SaveSystem.player.health
return super.get_variable(variable_name)
func set_variable(variable_name: String, value: Variant) -> void:
if variable_name == "pc_health":
SaveSystem.player.health = value
return
super.set_variable(variable_name, value)Re-run Regenerate Bindings whenever authors add or rename something. Tools → Tarinoi: Validate Bindings reports what has drifted without writing anything.
6. Play it
Tools → Tarinoi: Initialize project scaffolds two files at the project root:
res://tarinoi_quickstart.tscn— a runnable scene with a start-card picker and the debug dialogue stripres://tarinoi_quickstart.gd— a stub where your bindings get registered
Register them in _setup_bindings():
gdscript
extends "res://addons/tarinoi/scenes/tarinoi_quickstart.gd"
func _setup_bindings() -> void:
var vars := GlobalVariables.new()
TarinoiRuntime.registry.bind_variable_collection("global", vars)
TarinoiRuntime.registry.bind_function_collection("global", GlobalFunctions.new(vars))Set the scene as Project → Project Settings → Application → Run → Main Scene and press F5. You should get a list of every entry point in your content; pick one and the dialogue plays. That confirms the whole chain — sync, bindings, traversal, playback.
Bind the machine identifier, not the label
A collection shown as "Global State" in Tarinoi may be global in expressions, and global is what you register. Getting this wrong fails when the dialogue runs, not when you bind.
Wiring it into your own game
The quickstart scene is a development tool, not a starting point for your UI. A real game does three things.
Configure once, at startup. The plugin's TarinoiRuntime autoload exists from project load; configure() opens the database and populates the caches.
gdscript
func _ready() -> void:
TarinoiRuntime.registry.bind_variable_collection("global", GlobalVariables.new())
TarinoiRuntime.registry.bind_function_collection("global", GlobalFunctions.new())
TarinoiRuntime.configure()Handle the signals from your own UI.
gdscript
TarinoiRuntime.line_ready.connect(_on_line_ready)
TarinoiRuntime.choices_ready.connect(_on_choices_ready)
TarinoiRuntime.dialogue_ended.connect(_on_dialogue_ended)Drive it. TarinoiRuntime.advance() moves past an NPC line; TarinoiRuntime.select_choice(index) takes an option, zero-based.
Signal reference
| Signal | Payload | Raised when |
|---|---|---|
line_ready(line_data: Dictionary) | {line, entity_label, entity_ref, line_mode, base_ref, template_ref, card_id, collection_id, data} | A line is ready to display. Call advance() when the player moves on. |
choices_ready(choices: Array) | Array of {index, line, card_id, collection_id, entity_ref, visited, data, card} | The player has options. Call select_choice(index). Already sorted for display. |
choice_made(card_data: Dictionary) | The chosen card | A choice was taken — useful for echoing the player's line into a log. |
dialogue_ended() | — | Flow reached an end. Close your UI here. |
dialogue_error(message: String) | Description | Traversal failed — a dead end, an unresolvable pin. The dialogue has ended. |
pin_choice_needed(pin_names: Array) | Pin names | Development aid: a card needs a pin picked manually. Not a player-facing case. |
sync_started() / sync_progress(message, fraction) / sync_completed(stats) / sync_failed(reason) | — | Content sync lifecycle, for editor tooling and loading screens. |
The data dictionary on a line or choice carries the card's template-defined properties — whatever your authors declared. This is where you read a mood, a camera cue, an audio ref.
Starting dialogue from the world
DialogueTrigger (extends Area3D) and DialogueTrigger2D (extends Area2D) hold a collection_id and card_id you set in the Inspector, and emit rather than act:
MyScene (Node3D)
└── Intercom (DialogueTrigger)
├── CollisionShape3D
└── MeshInstance3Dgdscript
intercom.interaction_triggered.connect(func(col_id, card_id):
TarinoiRuntime.start_dialogue(col_id, card_id)
)
# from your player controller, when the interact key is pressed in range:
intercom.activate()The separation is deliberate — the trigger notices, your game decides. Prompt UI, range checks, and whether the player is allowed to talk right now all stay yours.
Finding the IDs
Leave collection_id and card_id empty and open the scene. The Output panel prints every available start card so you can copy the right pair into the Inspector.
Replacing the dialogue UI
The bundled TarinoiDialogueStrip is a scrolling debug feed — plain, unstyled, and not intended to ship. To use your own, just do not add it: delete the DialogueStrip node from your scene, add your own Control, and connect the signals above.
The other bundled scenes, both in addons/tarinoi/scenes/:
| Scene | What it is |
|---|---|
choose_start.tscn | Start-card picker. Populates on sync_completed, emits start_selected(collection_id, card_id). |
dialogue_strip.tscn | The debug feed. Auto-connects the runtime signals on _ready(); the parent must call hide_strip() on dialogue_ended. Space/ui_accept advances, 1–9 and clicks select. |
tarinoi_quickstart.tscn | The two combined, calling configure() and sync() for you. |
Remembering what the player has seen
Two features need to know which cards a player has already been shown: the visited flag on each choice, which lets you dim options already taken, and the shown once flag an author can tick on a card.
The plugin stays stateless about it. It asks for a dialogue's seen set when the dialogue starts and hands the updated set back when it ends, keyed by the start card's ID. Persisting it is yours:
gdscript
class MySaveHistory extends TarinoiHistoryStore:
func get_visited(start_card_id: String) -> Array:
return MySaveFile.load_visited(start_card_id)
func save_visited(start_card_id: String, visited_ids: Array) -> void:
MySaveFile.store_visited(start_card_id, visited_ids)
# assign before the first start_dialogue()
TarinoiRuntime.history_store = MySaveHistory.new()TarinoiHistoryStore.InMemory is supplied for when this only needs to hold for the current play session. Leave history_store null and nothing survives past the current dialogue.
A card joins the seen set the moment the player actually sees it — an NPC line when it is displayed, a player line when it is chosen. An option offered but not taken does not count.
The shown once flag
When an author ticks shown once, a card the player has already seen stops being a valid continuation: the "ask this only once" pattern, without a flag per card in your game code.
A spent card is skipped wherever the runtime would otherwise go to it, and its functions do not run, since nobody saw it. What that means depends on what else is available:
- Offered alongside other options — dropped from the choice set, before its entry condition is even evaluated.
- The last option standing — followed directly with no choice UI, the same rule that applies when entry conditions rule options out.
- The only way forward — the dialogue ends, as an ordinary dead end.
Without a history store the flag still works within a single dialogue — a hub the player loops back to will not re-offer a spent option — but it resets when the dialogue ends.
Dead ends
A card with nowhere valid to go ends the dialogue and logs an error, handing the player back to your game rather than leaving them stuck. Every cause is treated the same: no connections, no connection naming a target, every entry condition false, every remaining candidate a spent shown-once card, or a spent shown-once card that was the only way on.
The message names the card and says which case it was. Tarinoi's health check flags graphs where a dead end can arise at authoring time, which is the right place to catch it. If a shown-once card must stay reachable after it is spent, give its source a fallback option without the flag.
Shipping a build
This step is required. An exported game cannot read the database the editor synced into user:// on your machine. Ship without a snapshot and your game starts with no dialogue.
- Sync everything you want to ship.
- Tools → Tarinoi: Snapshot for Export — copies the database into
res://tarinoi/bundled, stripped of the API path and sync cursor. - Tick
tarinoi/behaviour/offline_modein Project Settings.
Builds copy that snapshot into place on first run and never contact the network. Re-export whenever the shipped content should change.
Your API token does not travel into the build, deliberately — it lives in user://, outside the project.
Settings reference
All under Project → Project Settings → Tarinoi.
| Setting | Default | Meaning |
|---|---|---|
tarinoi/api/path | — | The project's documents endpoint, ending in /documents. |
tarinoi/api/skip_tls_verify | false | Development escape hatch for self-signed certificates. Never ship this on. |
tarinoi/api/poll_enabled | false | Re-sync periodically while playing in the editor, so authored changes appear without a restart. |
tarinoi/api/poll_interval | 10 | Seconds between polls. |
tarinoi/codegen/output_path | res://bindings/generated/ | Where generated bindings are written. |
tarinoi/codegen/on_sync | false | Regenerate bindings automatically after every sync. |
tarinoi/behaviour/committed_only | false | Ignore uncommitted author changes and read only committed content. |
tarinoi/behaviour/log_level | INFO | Plugin log verbosity. |
tarinoi/behaviour/offline_mode | false | Never contact the network; play the bundled snapshot. Required for builds. |
tarinoi/data_provider | — | Script path of a custom TarinoiDataAccess implementation. See below. |
Tools menu
| Item | What it does |
|---|---|
| Tarinoi: Initialize project | Scaffolds the quickstart scene and bindings stub. |
| Tarinoi: Sync | Pulls content into the local database. |
| Tarinoi: Snapshot for Export | Bundles the database for shipping. |
| Tarinoi: Regenerate Bindings | Writes the generated binding files. |
| Tarinoi: Validate Bindings | Reports drift between generated files and current content, without writing. |
| Tarinoi: Set Tarinoi API token… | Stores your API token outside the project. |
| Tarinoi: Clear local data | Deletes the local database and sync state. |
Going further
Reading documents outside dialogue
TarinoiRuntime.data exposes document lookups once configure() has run. get_document() is a coroutine — await it:
gdscript
var payload: Dictionary = await TarinoiRuntime.data.get_document("abc123xyz")
var ferryman := TarinoiRuntime.data.get_entity("ferryman") # cached, synchronousawait TarinoiRuntime.get_start_cards() lists every entry point in your content.
Custom data providers
TarinoiDataAccess is an abstract base; the default TarinoiDataAccess.Sync runs queries on the main thread. To move queries onto a worker thread, subclass it and override _query() — every accessor routes through it:
gdscript
class_name FastDataAccess
extends TarinoiDataAccess.Sync
signal _query_done(result: Array)
func _query(sql: String, params: Array = []) -> Array:
WorkerThreadPool.add_task(func():
var rows := _db.query_rows(sql, params)
call_deferred("emit_signal", "_query_done", rows)
)
return await _query_doneAssign it before configure(), or set tarinoi/data_provider to the script path so it can vary by export preset. For a backend that is not SQLite at all, extend TarinoiDataAccess directly and implement get_document, get_entity, load_card, and query_start_cards.
Troubleshooting
| What you see | What it means |
|---|---|
TarinoiRuntime is not declared, right after copying the addon | Expected until the plugin is enabled. Project Settings → Plugins → Tarinoi → Active. |
| macOS blocks the SQLite binaries on import | Quarantine flag. xattr -cr addons/godot-sqlite/bin/, then reopen. |
addons/godot-sqlite/bin/ is empty on Windows | Antivirus removed the unsigned DLLs. Restore from Protection history and unblock the zip before extracting. |
set tarinoi/api/path in Project Settings first | The documents URL is missing. Paste it from Tarinoi's Integrate wizard. |
| Sync fails on credentials | The token is wrong or expired. Set it again from the Tools menu. |
| The start-card list is empty | Nothing synced yet, or your content has no start cards. Sync and re-read the Output panel. |
| Conditions are always false, dialogue runs anyway | No bindings registered. Generate them, implement them, and register before the first start_dialogue(). |
no bindings registered for collection 'x' | Register under x — and check you used the machine identifier, not the display label. |
A generated stub still fires push_error | The stub is in place. Derive from the generated class and override the method. |
Dialogue stops with has no pin 'x' | A card's output selector returned a pin the card does not have. Fix the selector, or add the pin. |
| An exported build has no dialogue | No snapshot was exported. See Shipping a build — a build cannot read the editor's database. |
| The plugin parses but nothing happens on Play | configure() was never called. The quickstart scene does it for you; your own scene must do it explicitly. |
Under the hood
The plugin's own technical documentation lives in docs/technical/ in the repository — sync, importer, expression evaluation, codegen, and the runtime state machine, each written up separately. If you plan to modify the plugin, start there and with Writing your own integration.