Skip to content

Adapting a plugin ​

All three official plugins are MIT licensed and meant to be forked. This page is the practical companion to Writing your own integration: how the three implementations are laid out, which parts move to a new engine without much thought, and traps to avoid when 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, Unity 6 and newer, and Unreal Engine 5.8, and a shipping project reasonably pinned to an older editor cannot follow that. The plugins are not doing anything exotic, so back-porting is usually a matter of the engine API surface rather than the dialogue logic.

You are on an engine we do not cover. GameMaker, Bevy, LÖVE, an in-house engine. Start from the data contract and use the plugins as worked examples – the same logic in three 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 localisation. Change it. There is no plugin API we are asking you to stay inside.


How the implementations are laid out ​

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

LayerGodotUnityUnreal EnginePorts how?
Loggingcore/tarinoi_logger.gdRuntime/TarinoiLog.csthe LogTarinoi categoryTrivially. Level-gated, no dependencies.
Storecore/db.gd, core/data_access.gdRuntime/Data/*Tarinoi/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/*Tarinoi/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/*Tarinoi/Expressions/*, Tarinoi/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.csTarinoiRuntime.*, TarinoiSubsystem.*Directly, 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/*TarinoiEditor/*, Tarinoi/Ui/*, Tarinoi/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 Unity's Settings Provider and MenuItem, and Unreal's developer settings and tool menus all want different things.


Port the tests before you trust the port ​

The Godot plugin's test/test_runtime.gd, the Unity package's runtime tests and the Unreal plugin's Tarinoi.Runtime automation specs cover the same state machine – 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.


Traps to avoid ​

These are subtle errors that follow from misunderstandings about data structures. 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. Writers place cards in the order in which they want choices to appear, from top to bottom. The raw connections array is not sorted, and you should not rely on it for order. 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.

Null has no boolean coercion. An expression evaluating to null – an unbound collection, say – raised as an exception rather than failing the condition. An unbound anything should degrade to false and log. Authoring mistakes should not crash a dialogue.

The two-layer merge implemented in the wrong place. Uncommitted cards live in the buffer layer, which needs to be merged into the main layer to reproduce the state authors see in Tarinoi. 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 makes every Ls.* reference resolve to zero. If the list is numeric – say, difficulty thresholds – this produces plausible numbers rather than errors.

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

Generated code lands in an assembly nothing can reference. The API bindings and reference implementations generated by the Tarinoi plugin 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.

Unreal: the engine's SQLite allows one connection per database file. Under the default build setting (bCompileCustomSQLitePlatform), SQLiteCore runs on Unreal's file layer with no shared memory and no locking: a second connection to an open file fails to open, and a connection is not thread-safe. The Unity design – a background writer connection beside the runtime's reader – does not carry over. Share one connection per file, use it from the game thread, and keep the network I/O asynchronous instead. Changing that build setting forces a unique build environment on the whole game, so a plugin should not.

Unreal: FString maps and sets ignore case. TMap<FString, …> hashes and compares case-insensitively, so Global and global land in one slot. Authored identifiers, document ids (mixed-case) and expression source text are all case-sensitive; a case-insensitive parse cache would hand Fn.Global.X the parse of Fn.global.X. Use case-sensitive key functions for all of them. The automation framework's TestEqual for strings ignores case too.

Unreal: generated headers must have unique names across the whole build. UnrealHeaderTool rejects two headers with the same file name anywhere in a target, and UCLASS names are global (there are no namespaces). Anything compiled beside a game's own generated bindings – a test fixture, say – needs distinct file and class names.

Unreal: a widget's Slate side is rebuilt more often than it is created. The first TakeWidget() runs a widget's construct/destruct cycle; unbinding game events in NativeDestruct silently dropped them. Tie event bindings to the widget object, not its Slate lifetime.

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.