Skip to content

Tarinoi for Unity

Package com.tarinoi.unity 0.1.0 — requires Unity 6 (6000.0) or newer

From an empty Unity project to dialogue on screen in about ten minutes. Read Game Engine Plugins first if you have not — it covers what this package does and, just as importantly, what it leaves to you.

Pre-1.0

Version 0.1.0. The API will change before 1.0. Unity 6 and newer only — there is no 2022 LTS backport, and there will not be one before 1.0.

You will need: a Tarinoi project with some dialogue in it, and permission to create an API token for it.


1. Install the package

The package installs straight from its Git repository. It has one dependency that lives on OpenUPM, so the registry for that goes in alongside it.

Open Packages/manifest.json and add both:

json
{
  "scopedRegistries": [
    {
      "name": "package.openupm.com",
      "url": "https://package.openupm.com",
      "scopes": ["com.gilzoide"]
    }
  ],
  "dependencies": {
    "com.tarinoi.unity": "https://github.com/tarinoi/tarinoi-unity-plugin.git"
  }
}

Unity resolves the rest on its own: com.gilzoide.sqlite-net from the registry above, and com.unity.nuget.newtonsoft-json and com.unity.ugui from Unity's own registry.

The scoped registry is not optional

com.gilzoide.sqlite-net is what supplies SQLite and its native libraries for every platform. Installing from a Git URL does not change how its dependencies are resolved — without that registry entry Unity cannot find it, and the install fails.

Installing through the Package Manager window instead

Add the scoped registry under Edit → Project Settings → Package Manager first, then use Window → Package Manager → + → Install package from git URL:

https://github.com/tarinoi/tarinoi-unity-plugin.git
Pinning to a specific version

A bare Git URL tracks the repository's default branch, so a later Update can pull changes you have not reviewed. Append a tag or commit hash to pin it:

json
"com.tarinoi.unity": "https://github.com/tarinoi/tarinoi-unity-plugin.git#<tag-or-commit>"

Worth doing while the package is pre-1.0 and the API is still moving.

2. Point Unity at your Tarinoi project

Open Project Settings → Tarinoi. The settings asset is created on demand the first time you open the page.

  1. API path — paste the documents endpoint from your Tarinoi project's Integrate wizard. It ends in /documents. The Project field underneath fills in automatically; if it stays empty, the path is wrong.
  2. API token — click Set… and paste a token from the same place. A Read token is enough.

The token is stored outside your Unity project, so it is never committed and never ends up in a build.

3. Sync

Tools → Tarinoi → Sync.

The Console reports what arrived — for example sync complete — 281 upserted, 737 removed, 22 collections. Syncing again is incremental; only what changed comes down.

4. Generate your bindings

Tools → Tarinoi → Regenerate Bindings.

This writes C# into Assets/Tarinoi/Generated describing what your authors declared: one class per function collection, one per variable collection, and constants for list options and entities.

They are stubs. Deriving from them and overriding the methods is how your game gives those authored names meaning:

csharp
public class MyFunctions : Tarinoi.Generated.GlobalFunctions
{
    public override bool CheckFlag(object flag) =>
        ValueConvert.ToBool(VarRef.Resolve(flag));
}

Variable classes work the same way. The generated class gives you a typed property per declared variable with the author's default; override GetVariable / SetVariable and call base for the rest when storage belongs to your save system:

csharp
public class MyVariables : Tarinoi.Generated.GlobalVariables
{
    public override object GetVariable(string name) =>
        name == "pc_health" ? SaveSystem.Player.Health : base.GetVariable(name);

    public override void SetVariable(string name, object value)
    {
        if (name == "pc_health")
        {
            SaveSystem.Player.Health = (int)ValueConvert.ToDouble(value);
            return;
        }

        base.SetVariable(name, value);
    }
}

The name passed to the accessors is the author's declared variable name, as it appears in Var.global.*.

Re-run this whenever authors add or rename something. Tools → Tarinoi → Check Bindings reports what has drifted without writing anything.

Commit the generated folder, or say so in your README

Code that derives from generated bindings will not compile in a fresh clone if the generated folder is ignored. Either commit it, or document that Sync and Regenerate Bindings come before the first compile.

5. Play it

Tools → Tarinoi → Create Quickstart Scene, then press Play.

You get a list of every entry point in your content. Pick one and the dialogue plays — that confirms the whole chain works: sync, bindings, and playback.

To register your own bindings, import the Quickstart sample from the Package Manager window and use MyQuickstart instead; it shows where they go.


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.

csharp
// 1. Configure once, at startup.
await TarinoiRuntime.Instance.ConfigureAsync();

// 2. Register your bindings, before any dialogue runs.
TarinoiRuntime.Instance.Registry.BindVariables("global", myVariables);
TarinoiRuntime.Instance.Registry.BindFunctions("global", myFunctions);

// 3. Handle the events, and drive it from your own UI.
TarinoiRuntime.Instance.LineReady    += line => ShowLine(line.EntityLabel, line.Line);
TarinoiRuntime.Instance.ChoicesReady += ShowChoices;
TarinoiRuntime.Instance.DialogueEnded += CloseDialogueUi;

await TarinoiRuntime.Instance.StartDialogueAsync(collectionId, cardId);

Then AdvanceAsync() past a line, and SelectChoiceAsync(index) to take an option.

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.

Event reference

TarinoiRuntime.Instance is a plain C# singleton, not a MonoBehaviour. Events are marshalled onto the main thread, so you can touch Unity objects in the handlers.

EventPayloadRaised when
LineReadyDialogueLineA line is ready to display. Call AdvanceAsync() when the player moves on.
ChoicesReadyIReadOnlyList<DialogueChoice>The player has options, already ordered for display. Call SelectChoiceAsync(index).
ChoiceMadeDialogueLineA choice was taken — useful for echoing the player's line into a log.
DialogueEndedFlow reached an end. Close your UI here.
DialogueErrorstringTraversal failed. The dialogue has ended.
PinChoiceNeededIReadOnlyList<string>Development aid: a card needs a pin picked manually. Not a player-facing case.
SyncStarted / SyncProgress / SyncCompleted / SyncFailed— / SyncProgress / SyncStats / stringContent sync lifecycle, for editor tooling and loading screens.

DialogueLine carries Line, EntityRef, EntityLabel, LineMode, BaseRef, TemplateRef, CardId, CollectionId, and Data. DialogueChoice adds Index and Visited and drops the label fields you do not need for a button.

Data is a JObject holding the card's template-defined properties — whatever your authors declared. This is where you read a mood, a camera cue, an audio reference.

Assembly definitions and JObject

If your asmdef has Override References on, add Newtonsoft.Json.dll to its precompiled references — authored payloads reach you as JObject. And if your own asmdef needs to see the generated bindings, turn on Own assembly definition in Project Settings → Tarinoi and regenerate: without it, generated code lands in Assembly-CSharp, which no asmdef can reference.

Starting dialogue from the world

Three components, in Tarinoi.Components:

ComponentBehaviour
DialogueTriggerHolds a collectionId and cardId. Fires InteractionTriggered when your code calls Activate().
DialogueTriggerVolumeA collider-driven DialogueTrigger. In OnEnter mode it fires the moment a tagged collider enters; in WhileInside mode it fires nothing by itself and instead raises OccupantEntered / OccupantExited and exposes IsOccupied.
DialogueTriggerVolume2DThe 2D twin.

WhileInside is the shape most games with world dialogue want: the player walks into range, your game shows a prompt, and they decide to talk.

csharp
volume.OccupantEntered += _ => prompt.SetActive(true);
volume.OccupantExited  += _ => prompt.SetActive(false);
trigger.InteractionTriggered += (col, card) =>
    TarinoiRuntime.Instance.StartDialogueAsync(col, card);

// from your player controller, on the interact key:
if (volume.IsOccupied) volume.Activate();

The separation is deliberate — the trigger notices, your game decides. Prompt UI, range checks, and whether the player may talk right now stay yours.

Replacing the dialogue UI

The bundled DialogueStrip is a scrolling debug feed built at runtime — plain, unstyled, and not intended to ship. To use your own, do not add it: build your UI however you like and subscribe to the events above. StartCardPicker and TarinoiQuickstart are likewise development tools.


Remembering what the player has seen

Two features need to know which cards a player has already been shown: the Visited flag on a 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 cards when the dialogue starts and hands the updated set back when it ends — persisting them is your job, keyed by the start card's ID:

csharp
public sealed class SaveFileHistory : IHistoryStore
{
    public IEnumerable<string> GetVisited(string startCardId) =>
        MySave.LoadSeenCards(startCardId);

    public void SaveVisited(string startCardId, IEnumerable<string> visitedIds) =>
        MySave.StoreSeenCards(startCardId, visitedIds);
}

TarinoiRuntime.Instance.HistoryStore = new SaveFileHistory();

InMemoryHistoryStore is supplied for when this only needs to hold for the current play session. Leave HistoryStore 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 only way forward — the dialogue ends, as a dead end like any other.

Without an IHistoryStore the flag still works inside 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 way: 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 continuation.

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. 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, not optional. A build cannot see the content you synced in the editor: Unity gives players their own storage location, separate from the editor's. Ship without a snapshot and your game starts with no dialogue at all.

  1. Sync everything you want to ship.
  2. Tools → Tarinoi → Snapshot for Export — copies the content into StreamingAssets, stripping the API path and sync cursor.
  3. Tick Offline mode in Project Settings → Tarinoi.

Builds then copy that snapshot into place on first run and never contact the network. Re-export whenever you want shipped content to change.

The same separation applies to your API token, deliberately: it lives outside the project and does not travel into a build.

From a build script:

bash
Unity -batchmode -quit -projectPath . \
  -executeMethod Tarinoi.Editor.TarinoiCli.SyncAndGenerate
Unity -batchmode -quit -projectPath . \
  -executeMethod Tarinoi.Editor.TarinoiCli.ExportSnapshot

IL2CPP

Function dispatch goes through ITarinoiFunctions.TryInvoke, which generated bindings implement as a switch — that is IL2CPP-safe. The ReflectionFunctions adapter exists for hand-written classes, but reflective call sites can be stripped in a player build, so prefer the generated path for anything you ship.


Settings reference

Project Settings → Tarinoi.

SettingDefaultMeaning
API pathThe project's documents endpoint, ending in /documents.
API tokenSet through the masked dialog; stored outside the project.
Re-sync while playing / Every (seconds)off / 10Re-sync periodically in Play mode, so authored changes appear without a restart.
Skip TLS verificationoffDevelopment escape hatch for self-signed certificates. Never ship this on.
Output folderAssets/Tarinoi/GeneratedWhere generated bindings are written.
Regenerate after syncoffRegenerate bindings automatically after every successful sync.
Own assembly definitionoffGive the generated bindings their own assembly, so your own asmdefs can reference them.
Committed content onlyoffShow only committed content, hiding uncommitted edits — what a player would see.
Log levelInfoHow much Tarinoi writes to the Console.
Offline modeoffPlay from the bundled snapshot and never contact the API. Required for builds.
Custom document storeAssembly-qualified type name of a custom IDocumentStore implementation.

Tools menu

Sync · Regenerate Bindings · Check Bindings · Set API token… · Snapshot for Export · Clear Local Content · Create Quickstart Scene

Command line

Entry pointWhat it does
Tarinoi.Editor.TarinoiCli.SyncAndGenerateSyncs and regenerates bindings from a build script.
Tarinoi.Editor.TarinoiCli.ExportSnapshotWrites the shipping snapshot.
Tarinoi.Editor.TarinoiCli.Configure -tarinoiApiPath <url>Points a fresh checkout at a project without opening the editor. The token is still set by hand, so it never reaches a log.

Going further

Custom document stores

IDocumentStore is the seam between the runtime and storage. SqliteDocumentStore is the default and completes synchronously; assign your own to TarinoiRuntime.Instance.DocumentStore before ConfigureAsync() to put queries on another thread or use a different backend.

Awaiting is mandatory with a custom store

TarinoiRuntime deliberately does not use ConfigureAwait(false) — its events must reach your code on the main thread. With the built-in store every await completes synchronously, so blocking on a runtime call happens to be safe. With a genuinely asynchronous store it is not: block on one and you deadlock the main thread. Await runtime calls, always.


Troubleshooting

What you seeWhat it means
"Set your project's API path…"Project Settings → Tarinoi, paste the documents URL.
"credentials rejected"The token is wrong or expired. Set it again.
"project not found"The API path points at a project that is not there. Check it.
The entry-point list is emptyNothing synced yet, or your content has no start cards. Sync and re-check the Console.
"no bindings registered for function collection 'x'"Register a binding under x — and check you used the machine identifier, not the label.
"'Col.Name' is not implemented"The generated stub is still in place. Derive from the class and override it.
"…is not bound. Regenerate your bindings."An author added a function since you last generated. Run Regenerate Bindings.
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.
A build has no dialogue, though the editor doesNo snapshot was exported. See Shipping a build — a player cannot read the editor's content.
A build logs "No API token saved"It is trying to sync. Turn on Offline mode; players should play a snapshot, not call the API.
"The type 'JObject' is defined in an assembly that is not referenced"Your asmdef has Override References on. Add Newtonsoft.Json.dll to its precompiled references.
"The type or namespace name 'Generated' does not exist" from your own asmdefGenerated code landed in Assembly-CSharp. Turn on Own assembly definition and regenerate.
A fresh clone of your repository does not compileYour code derives from generated bindings and the generated folder is ignored. Commit it, or document that Sync and Regenerate Bindings come first.
The editor hangs on a runtime callSomething blocked on a Task instead of awaiting it, with a custom IDocumentStore in place. Await it.

Under the hood

The package source is on GitHub, MIT licensed. If you plan to modify it, start with Writing your own integration for the data contract and Adapting a plugin for how the implementation is laid out.