Skip to content

Writing your own integration

Version 0.1.0 — 2026-08-19

This is what any Tarinoi client has to implement, independent of engine. It documents the data as it arrives from the public API or a Git remote, the expression language, and the traversal rules the official plugins follow.

Write to this if you are on an engine we do not cover, on an engine version we no longer support, or if you simply want an integration shaped for your project rather than ours. Both official plugins are MIT licensed and are worked examples of everything below:

  • Godot — GDScript, SQLite-backed
  • Unity — C#, SQLite-backed

Code in this document is pseudocode. It maps straightforwardly onto GDScript, C#, C++, Rust, or whatever you are writing in.


1. The division of responsibility

TarinoiYour integrationYour game
Authoring tool and data contractGraph traversal and expression dispatchFunction implementations, variable scope, persistence, triggers, presentation

Tarinoi is deliberately unopinionated. It does not ship a function library, does not know what fires a dialogue, and does not care what kind of game you are making. An integration is nearly stateless: it receives a start card, walks the graph, dispatches expressions to game-provided implementations, and hands back the card to present.


2. Getting the data

Two paths, summarised in Connecting to your project:

The public API — authenticate with an API access token and page through the documents endpoint. Responses are newline-delimited JSON with a cursor, so sync can resume after an interruption and can be incremental afterwards. This is what both official plugins do.

A Git remote — clone or pull a repository Tarinoi keeps in sync on every commit, then read the files directly. Suits pipelines that already think in files and history.

Repository layout (Git remote)

{projectId}/
  {branch}/
    .tarinoi/state.json           ← internal flush cursor; ignore
    boards/{collectionId}/
      collection.json             ← collection manifest
      manifest.json               ← bucket index
      b-000.json … b-{N}.json     ← card documents
    entities/{collectionId}/…     ← entity documents
    variables/{collectionId}/…    ← variable declarations
    functions/{collectionId}/…    ← function declarations
    lists/{collectionId}/…        ← list specifications
    templates/{collectionId}/…    ← card and entity templates
    folders/…

Bucket files are JSON objects keyed by document_id, not arrays. To load a collection, read every bucket listed in manifest.json and iterate the object's values.

collection.json is a document envelope whose document_id is the collection's own ID. The collection_id field on that same file refers to its parent, and should be ignored when populating a collections table.

The document envelope

Every document, by either path, looks like this:

json
{
  "document_id": "…",
  "collection_id": "…",
  "document_type": "card",
  "payload": {  }
}

Everything you need is in payload.

The two layers

Content exists in two layers: committed content, and uncommitted author edits on top of it. Merge them on read, with three rules:

  1. An uncommitted document beats the committed document with the same ID.
  2. An inactive uncommitted document — archived, moved, or tombstoned — suppresses the committed one. Neither is returned.
  3. Otherwise the committed document stands.

Expose a switch for committed-only reads. During development authors want to see their work in progress; a build should usually see what a player would see. Both official plugins implement this filter in exactly one place, as a SQL fragment and an equivalent in-memory merge held to the same semantics by test — worth copying, because the rules are easy to apply inconsistently.

Compatibility

Check the data version at load and refuse content you are too old to read, loudly. Silently misreading a newer format is worse than not starting.


3. Document types

Cards (document_type: "card")

Found in boards/. Cards are the nodes of a dialogue graph.

FieldTypeMeaning
base_refstringBuilt-in card type. See §4.
structuralboolean?The card controls flow rather than presenting content.
entity_refstring?entity_name of the speaking entity.
input_pinTCardPin?Condition required to enter this card.
output_pinsTCardPin[]?Outgoing wires, each with an optional condition.
output_selectorstring?Expression selecting an output pin by name. See §5.5.
connectionsstring[]?Wires to other cards. See §6.
geoTCardGeometryPosition in the authoring tool. Drives choice order — see §7.
shown_onceboolean?Once seen, stop offering this card. See §9.
dataobjectTemplate-defined property values. May contain Fn.* expressions the runtime must evaluate as side effects. See §5.4.
props{name, data_type}[]?Ordered declaration of the properties in data.

props is an ordering hint, not an inventory

props may not declare every key present in data — for instance when a template is updated after a card was authored. Never treat it as exhaustive.

Entities (document_type: "entity")

FieldTypeMeaning
entity_namestringUnique identifier, used as entity_ref on cards.
dialog_capablebooleanIf false, the entity does not speak.
is_player_characterboolean?Marks the player. At most one per project.
has_avatarbooleanWhether avatar images exist.
avatar_refsTAvatarRef[]Avatar variants. See §10.
dataobjectTemplate-defined property values.

Function declarations (document_type: "function-declaration")

A named call your integration dispatches to a game-provided implementation.

FieldTypeMeaning
function_namestringIdentifier used in expressions.
function_argsTArgDeclaration[]Argument types and reference sub-types.
function_returnsstring"string", "number", "boolean", "void", "any".
function_effectstring"pure", "side-effect", or "mutation".

Variable declarations (document_type: "variable-declaration")

FieldTypeMeaning
variable_namestringLeaf name in Var.group.variable_name.
data_typestring"string", "number", "boolean", or "list-reference".
default_valueany?A suggestion. Scope, storage, and lifecycle belong to the game.

List specifications (document_type: "list-spec")

FieldTypeMeaning
list_namestringIdentifier.
data_typestringType of value in each option.
list_options{key, value}[]The enumeration.

List references resolve by identifier

A list-reference variable holds an option key, and the option must be looked up by the list's machine identifier — not its label, and not its row order. Getting this wrong tends to produce a plausible-looking zero rather than an error, which is how it survives testing. Both official plugins have had this bug.


4. Card base types

base_refstructuralMeaning
startyesEntry point. Find them by filtering base_ref == "start". A board may have several — authors use them as alternative entry points.
lineA line of dialogue. Speaker in entity_ref, text in data.line.
blankGeneric content card, template-defined data only.
mediaReferences an authoring-support asset. See §10.
jumpyesTransfers flow unconditionally. Target in data.target_collection_id and data.target_card_id.
annotationAuthor note. No output pins, never presented.
backdropyesVisual grouping device in the authoring tool. No output pins.

Cards with neither input_pin nor output_pinsannotation, backdrop — are authoring artefacts. Skip them during traversal.


5. Expressions

5.1 Grammar

Conditions and output selectors are serialised expression strings, in a deliberately restricted grammar:

  • CallsFn.collection.Name(arg1, arg2, …)
  • MembersNamespace.group.name
  • Boolean combinators&&, ||, !, and parentheses

There are no arithmetic operators, no comparisons, and no string literals. Every value enters through a function call or a member lookup. An empty condition means unconditional.

Report a malformed expression once and degrade gracefully — cache the failure alongside successes, or a bad expression floods the log on every evaluation. Report only the first error per expression too; a derailed cursor generates cascading noise.

5.2 Variable references

Var.ferryman.met_the_ferryman

Var is the root namespace, the middle segment is the variable collection, the leaf is the variable_name. Resolve by looking up the declaration and asking the game for the current value.

Resolve before coercing

A bare Var.group.flag used as a condition must be resolved to its value before being treated as a boolean. If your reference type is an object and your language treats any non-null object as truthy, every such condition silently becomes true. This exact bug shipped in the Godot plugin.

Functions that need to write a variable receive the reference rather than the value — keep a located-but-unread reference type so an implementation can set as well as get.

5.3 Binding and dispatch

Your integration does not implement functions. It dispatches to implementations the game registers, keyed by collection identifier:

registry.bind_functions("flags", my_flags_impl)
registry.bind_variables("global", my_variables_impl)

fn evaluate_call(collection, name, args):
    impl = registry.functions[collection]
    if impl == null:
        error("no bindings registered for collection " + collection)
        return null
    return impl.invoke(name, args)

Bind against the collection's machine identifier, not its display label.

Prefer explicit dispatch — a TryInvoke-style switch — over reflection. Reflective call sites can be stripped by ahead-of-time compilers, so a reflective binding that works in the editor can fail in a shipped build.

Functions declared side-effect or mutation change game state. Call them in declaration order, never speculatively.

5.4 Function expressions in card.data

Any string value in card.data matching the Fn.* call pattern is a side-effect expression: a function to call when the card is committed to.

Card kindWhen to evaluate
NPC lineWhen the line is shown, before raising the line event.
Player choiceWhen the player selects it — never when choices are merely presented. Unchosen candidates must not have their functions run.
Blank / start / jump / structuralImmediately on traversal, before following connections.

Evaluation order follows each property's position in card.props; expressions not listed in props come last, in whatever stable order your map iteration gives.

The token Card.CurrentContextCard may appear as an argument and must be passed the current card's full payload at evaluation time — the standard pattern for skill-check cards that read their own data.threshold or data.skill:

Fn.global.CheckSkill(Card.CurrentContextCard)

Return values from data expressions are discarded. Anything that should influence routing belongs in output_selector, not in data.

5.5 Output selectors

When a card has an output_selector, evaluate it as a call expression. The result must be a string matching one of the card's output_pins[].name values; use it to find the connection and advance. No match is an error — report it and end the dialogue rather than guessing.


6. Connections and end of flow

connections is an array of wire strings:

"{sourcePinName}>>{targetCardId}"

The reserved target "flow:end" terminates flow instead of advancing:

"default>>flow:end"
parts   = connection.split(">>")
from_pin = parts[0]        # "default", "yes", "no", …
to_card  = parts[1]        # a document_id, or "flow:end"

7. Choice order

When several targets are offered, present them in ascending geo.y — the vertical position authors gave the cards in the graph editor is how they express intended display order.

choices = active_pins
    .map(pin -> resolve_target(card, pin.name))
    .filter(target -> target != null and target != END_FLOW)
    .sort_by(target -> target.payload.geo.y)

Make the sort stable, with source order as the tiebreaker, or cards sharing a Y coordinate will reorder between runs.


8. Traversal

fn run_dialogue(start_card_id):
    card = load_card(start_card_id)
    assert card.payload.base_ref == "start"

    while true:
        card = advance(card)
        if card == null:
            break                # end of flow
        present(card)            # the game renders it

fn advance(card):
    if card.payload.base_ref == "jump":
        return load_card(card.payload.data["target_card_id"],
                         card.payload.data["target_collection_id"])

    pins = card.payload.output_pins ?? []
    if len(pins) == 0:
        return null              # no exits

    if card.payload.output_selector:
        pin_name = evaluate_expression(card.payload.output_selector)
        return follow_pin(card, pin_name)

    candidates = pins
        .map(pin -> { pin: pin, target: peek_target(card, pin.name) })
        .filter(c -> c.target != null)
        .filter(c -> not is_spent_shown_once(c.target))    # before conditions — see §9
        .filter(c -> evaluate_condition(c.pin.condition))

    if len(candidates) == 0:
        return null              # dead end
    if len(candidates) == 1:
        return follow_pin(card, candidates[0].pin.name)

    ordered = candidates.sort_by(c -> c.target.payload.geo.y)
    chosen  = await player_chooses(ordered)
    return follow_pin(card, chosen.pin.name)

fn follow_pin(card, pin_name):
    conn = card.payload.connections.find(c -> c.split(">>")[0] == pin_name)
    if conn == null:
        return null
    target = conn.split(">>")[1]
    return target == "flow:end" ? null : load_card(target)

Guard against loops: a card that re-enters itself within one traversal step without presenting anything will hang your game. Both official plugins keep a per-traversal visited set for this, separate from the seen-card history in §9.


9. Seen cards and shown_once

Two behaviours depend on knowing what the player has already been shown: a visited flag on each choice, so games can dim options already taken, and the author-facing shown once flag.

Keep this stateless in the integration. Ask the game for a dialogue's seen set when the dialogue starts, keyed by the start card's ID, and hand the updated set back when it ends. Persistence is the game's business.

A card joins the seen set when the player actually sees it — an NPC line when displayed, a player line when chosen. Offered but not taken does not count.

shown_once: true on a card means: once seen, it is no longer a valid continuation. It is spent when both hold — the flag is set, and its ID is already in the seen set. A spent card is skipped wherever traversal would otherwise go to it, and its functions do not run, because the player never saw it.

  • Offered alongside others — dropped from the choice set, before its entry condition is evaluated. Order matters: a spent card's condition must never run.
  • Last option standing — followed directly, no choice UI.
  • Only way forward — the dialogue ends, as an ordinary dead end.

Dead ends

A card with nowhere valid to go should end the dialogue and log an error, handing the player back to the game rather than leaving them stuck. Treat every cause the same way — no connections, no connection naming a target, all conditions false, all remaining candidates spent — but name the card and the case in the message, so the cause stays identifiable.


10. Assets

Avatars are listed in avatar_refs:

json
{ "variant": "default", "link": "ferryman--default", "version": 3 }

link has the form {entity_name}--{avatar_name} and is the stable identifier for that image.

Avatars in Tarinoi are 320×320 authoring aids, not game-ready art. Writing them out as placeholders so artists can see what goes where is useful; overwriting a file that already exists is not — production assets win. Which variant to show, and how, is the game's decision. Tarinoi expresses no display intent.

Media cards (base_ref: "media") carry a clio:// URL in data.media_link. These are writers' reference images — moodboards, illustrations. Read them for authoring context if you are building editor tooling; do not serve them to players.


11. Generating bindings

Both official plugins generate typed stubs from the function, variable, list, and entity declarations, and it is the single highest-value piece of tooling in either. It turns "an author renamed a function" from a runtime failure into a compile error.

What to generate:

OutputContents
FunctionsOne class per collection, one overridable stub per declared function
VariablesOne class per collection, a typed member per variable with the author's default, plus overridable get/set accessors
ListsConstants for every option key
EntitiesConstants for every entity identifier

Two things worth copying:

Make everything overridable, symmetrically. If function methods are virtual but variable accessors are not, a game that wants variables to come from its save system has to abandon the generated class entirely and give up the typed members and defaults. The asymmetry has no justification and users hit it immediately.

Never overwrite game code. Generate into a dedicated directory, have games implement in a separate one by deriving, and provide a validate-only mode that reports drift without writing.


12. What the integration does not own

ConcernOwner
Which dialogue fires whenGame
Variable scope and persistenceGame
Function implementationsGame
Player input and choice UIGame
VO, audio, camera, animationGame
Avatar selection and presentationGame
Long-term seen-card historyGame (you provide the interface and a short-term default)
In-game media assetsGame

The job is: walk the graph, dispatch expressions, return cards. Everything else belongs to the game.


Next

Adapting a plugin covers how the two official implementations are laid out, which layers port cleanly, and the specific mistakes we made building them.