Skip to content

Adapting a plugin

Both official plugins are MIT licensed and meant to be forked. This page is the practical companion to Writing your own integration: how the two implementations are laid out, which parts move to a new engine without much thought, and the specific traps we walked into building them.

Fork, vendor, copy a single file, or read them and write your own. All of it is fine, and none of it needs our permission.


When to adapt rather than use

You are on an unsupported engine version. Until 1.0 we support only the latest Godot 4.x and Unity 6 and newer, and a shipping project reasonably pinned to an older editor cannot follow that. The plugins are not doing anything exotic, so backporting is usually a matter of the engine API surface rather than the dialogue logic.

You are on an engine we do not cover. Unreal, GameMaker, Bevy, LÖVE, an in-house engine. Start from the data contract and use both plugins as worked examples — the same logic in two languages makes it much easier to see which parts are essential and which are engine idiom.

Our shape is wrong for your project. You already have a save system, an asset pipeline, a UI framework, a scripting layer. Taking the traversal core and dropping everything else is a perfectly good outcome — the traversal core is the part that is hard to get right.

You need behaviour we do not provide. A different sync cadence, a non-SQLite store, content baked into an asset at build time — or something we have not shipped yet, like localization. Change it. There is no plugin API we are asking you to stay inside.


How the implementations are laid out

Both follow the same six layers, bottom to top. The names differ; the structure does not.

LayerGodotUnityPorts how?
Loggingcore/tarinoi_logger.gdRuntime/TarinoiLog.csTrivially. Level-gated, no dependencies.
Storecore/db.gd, core/data_access.gdRuntime/Data/*Mostly. SQLite schema, the two-layer merge, and an overridable query seam. The merge logic is engine-agnostic; the SQLite binding is not.
Synccore/api_importer.gd, core/importer.gdRuntime/Sync/*Mostly. NDJSON paging, a resumable cursor, layer-aware upserts. The HTTP client is engine-specific; the protocol handling is not.
Expressionscore/expression_parser.gd, core/dispatcher.gdRuntime/Expressions/*, Runtime/Bindings/*Directly. Lexer, recursive-descent parser, typed AST, short-circuit evaluation, a parse cache. This is pure logic — port it almost line for line.
Runtimeautoload/TarinoiRuntime.gdRuntime/TarinoiRuntime.csDirectly, and carefully. The traversal state machine. Every rule in the contract lives here.
Editor tooling & UIplugin.gd, codegen/, scenes/, nodes/Editor/*, Runtime/Ui/*, Runtime/Components/*Not at all. Entirely engine idiom — rewrite it.

The useful consequence: roughly the middle two-thirds ports, and the top and bottom do not. The expression evaluator and the traversal state machine are where the domain knowledge lives and where a port earns its accuracy. The editor integration is where a port earns its feel, and copying our shape there is usually the wrong instinct — Godot's Project Settings and Tools menu and Unity's Settings Provider and MenuItem want different things.


Port the tests before you trust the port

The Godot plugin's test/test_runtime.gd and the Unity package's runtime tests cover the same state machine from opposite sides — transparent cards, geo.y ordering, deferred function evaluation, condition routing, pin selection, system lines, the loop guard, visited history, and the layer merge.

They are the most valuable thing to take. Traversal has many rules and most of them fail quietly: a wrongly ordered choice list looks fine, a condition that is always true looks fine, a spent shown_once card that still runs its functions looks fine right up until a player notices their inventory changed for a line they never saw.

Two practices worth stealing:

Build a fresh runtime per test. The runtime holds dialogue state, caches, and a database handle. Sharing one across tests makes failures order-dependent, which is worse than no tests.

Verify a new test fails against the old code. When you fix a bug, stash the fix and confirm the test goes red. Two of our layer-merge tests were validated this way and one of them was wrong.


Mistakes we made, so you do not have to

These are real defects that shipped in one plugin or the other. Every one of them is a consequence of the contract being easy to implement almost correctly.

Choices sorted by connection order rather than geo.y. The Godot plugin used the raw connections array instead. It looks right, because authors often wire cards top to bottom anyway. Three details go with the fix: cards without geometry sort last, not to zero — authored Y coordinates are routinely negative; ties must break by source order, since most sorts are not stable; and if your API indexes choices positionally, reassign the indices after sorting.

A bare Var.group.flag condition was always true. Variable references resolve to a reference object so functions can write through them, and in GDScript any non-null object is truthy. Every such condition silently passed. Resolve references to values in boolean contexts; pass them unresolved as function arguments.

Null had no boolean coercion. Found while fixing the above: an expression evaluating to null — an unbound collection, say — raised rather than failing the condition. An unbound anything should degrade to false and log, not crash a dialogue.

The two-layer merge was implemented twice and drifted. One path honoured committed-only mode and the other ignored it, so previewing committed content showed committed cards alongside uncommitted entities. Implement the merge in exactly one place, and if you have both a SQL and an in-memory path, hold them to the same semantics by test.

List references resolved by row order instead of identifier. A dropped identifier column made every Ls.* reference — skill-check thresholds, in practice — resolve to zero. It produced plausible numbers rather than errors, which is why it survived.

Generated variable accessors were not virtual, while function methods were. Games that wanted variables to come from a save system had to abandon the generated class entirely, losing the typed members and the authored defaults. If you generate code, make it overridable symmetrically.

Generated code landed in an assembly nothing could reference. Unity-specific in the details, general in the lesson: generated code is both per-project output and a compile-time dependency of hand-written code, and those two facts fight. Decide deliberately whether the generated folder is committed, and say which in your README — otherwise a fresh clone does not compile and the first thing a new team member sees is errors.


Engine-specific traps

Unity: ConfigureAwait(false) in library code, but not in the runtime. The sync layer uses it and must; the runtime deliberately does not, because its events have to reach game code on the main thread. The consequence is that blocking on a runtime call — .GetAwaiter().GetResult() — deadlocks the editor against a real store. It does not deadlock against a fake store that completes synchronously, which is exactly why our own test harness blocks freely and offline tests never caught it. Await runtime calls.

Unity: reflection-based dispatch can be stripped by IL2CPP. A reflective binding that works in the editor can vanish in a player build. Generated bindings dispatch through an explicit switch for this reason.

Unity: a UI test that counts widgets cannot see an invisible UI. Every choice button in our testbench rendered at 1336×0 — a VerticalLayoutGroup with childControlHeight sizes children from their preferred height, and a bare Image+Button has none. Three play-mode tests stayed green throughout, because a zero-height button answers a programmatic click perfectly well. Assert rendered size, not just presence, and give any walk-the-flow test a positive assertion about what it passed through: "no panel is up and the state is Idle" is indistinguishable from a dialogue that stopped at the first choice.

Godot: Dictionary.get() with a default does not apply when the key exists with a null value. A payload field present but null returns null, not your default, which then fails a typed cast at the call site. Check for null explicitly on anything coming out of a document payload.

Godot: var x := <expr> fails to parse when the type cannot be inferred. Concatenating an untyped loop variable is the common case. Worse, the GUT test runner silently skips a file that fails to parse — check the script count in the summary, not just the pass count.


Telling us about it

If you build an integration for another engine, we would like to link to it from Game Engine Plugins. We are not asking for contributions back, and there is no approval process — MIT means MIT. But a second implementation is the best test the data contract gets, and if you find a place where this documentation is wrong or incomplete, that is worth more to us than the code.