PROJECT FULLY GENERATED BY LLM. DO NOT USE IT IF LLM GENERATED CODE IS NOT ALLOWED IN YOUR CODE BASE
An Odin port of inkle's ink narrative scripting runtime — the engine behind 80 Days, Heaven's Vault, Sorcery!, and many other story-driven games.
This is the runtime player only. Authoring still happens in the original .ink language, compiled to JSON with inklecate. This package consumes that JSON and runs the story.
Conformance: byte-for-byte parity with inkle's reference C# runtime across 50 seeded random walks (5 fixtures × 10 seeds), covering lists, threads, externals, randomness, visit/turn counts, and full state serialization.
You need Odin and a git client. Tests against the reference runtime additionally need .NET 6+.
git clone --recursive https://github.com/Wc4ever/ink-to-odin.git
cd ink-to-odin
# Play TheIntercept in the terminal:
odin run ./examples/intercept_cliYou should see "They are keeping me waiting." followed by a numbered choice prompt. Type a number, press enter; q quits.
All shell snippets in this README are run from the repo root unless noted.
The package lives at ink/ and is self-contained — no Odin dependencies outside core. Two ways to wire it into your project:
- Drop the
ink/directory next to your sources andimport ink "./ink"(or whatever relative path). - Or put the repo on Odin's collection path:
odin run ./mygame -collection:ink=path/to/ink-to-odin/ink, thenimport "ink".
Minimal embedding:
package mygame
import "core:fmt"
import "core:os"
import ink "ink"
main :: proc() {
json_bytes, ok := os.read_entire_file("my_story.ink.json")
if !ok {
fmt.eprintln("could not read story")
return
}
defer delete(json_bytes)
story: ink.Compiled_Story
if err := ink.compiled_story_load(&story, string(json_bytes)); err != .None {
fmt.eprintfln("load: %v", err)
return
}
defer ink.compiled_story_destroy(&story)
state: ink.Story_State
ink.story_state_init(&state, &story)
defer ink.story_state_destroy(&state)
for {
// Drain text up to the next choice or the end of the story.
for ink.story_state_can_continue(&state) {
if !ink.story_continue(&state) do break
text := ink.story_current_text(&state)
defer delete(text)
// ... render `text` to your UI ...
}
choices := ink.story_current_choices(&state)
if len(choices) == 0 do break
// Present choices[i].text to the player, get their selection (0..len-1):
picked_index := 0 // replace with the actual pick
ink.story_choose_choice_index(&state, picked_index)
}
}Backend-agnostic by design: nothing in ink/ depends on a renderer or game framework. The examples/intercept_cli/ consumer is a terminal app; a raylib (or anything else) consumer is just a different driver around the same Story_State.
| Proc | What it does |
|---|---|
compiled_story_load(^Compiled_Story, json_text) -> Load_Error |
Parse inklecate-emitted JSON |
story_state_init(^Story_State, ^Compiled_Story) -> bool |
Create a runnable state, run global decl |
story_state_reset(^Story_State) |
Reset to fresh state, keep compiled story |
story_state_can_continue(^Story_State) -> bool |
True iff story_continue would produce text |
story_continue(^Story_State) -> bool |
Step forward one line of output |
story_current_text(^Story_State, allocator) -> string |
Latest line of text |
story_current_tags(^Story_State, allocator) -> []string |
Tags emitted on the current line |
story_current_choices(^Story_State) -> []Choice |
Pending choices (empty until story pauses) |
story_choose_choice_index(^Story_State, idx) -> bool |
Pick a choice and resume |
story_choose_path_string(^Story_State, path, increment_turn) |
Jump to a knot/stitch from host code |
story_state_has_error(^Story_State) -> bool / story_errors(^Story_State) -> []string |
Surface runtime errors |
story_warnings(^Story_State) -> []string / story_clear_errors / story_clear_warnings |
Same for warnings |
score, ok := ink.story_get_variable_int(&state, "score")
ink.story_set_variable_int(&state, "score", 42)
name, _ := ink.story_get_variable_string(&state, "player_name")
ink.story_set_variable_string(&state, "player_name", "Hero")Typed accessors: _int, _float, _bool, _string for both get and set, returning (value, ok) on get. Polling-only — no observer/callback API. Setting an undeclared global returns false.
// Tags above the first knot in the .ink source:
globals := ink.story_global_tags(&state); defer delete(globals)
// Tags at the top of a specific knot or stitch:
header := ink.story_tags_at_path(&state, "myKnot.myStitch"); defer delete(header)ink scripts can declare EXTERNAL fn(args) and the host registers a callback:
my_roll :: proc(args: ink.External_Args, user_data: rawptr, alloc: runtime.Allocator) -> ^ink.Object {
sides := i64(6)
if v, is_int := args[0].variant.(ink.Int_Value); is_int do sides = v.value
o := new(ink.Object, alloc)
o.variant = ink.Int_Value{value = rand.int63_max(sides) + 1}
return o
}
ink.story_bind_external(&state, "roll", my_roll, lookahead_safe = true)For unbound externals you can either fall back to a same-named ink function (state.allow_external_function_fallbacks = true) or get a runtime error. Bindings marked lookahead_safe = false are correctly deferred when the runtime is doing newline lookahead, so callbacks with side effects only fire once.
// Mid-game save:
saved_json := ink.state_to_json(&state)
defer delete(saved_json)
os.write_entire_file("savegame.json", transmute([]byte)saved_json)
// Resume:
fresh: ink.Story_State
ink.story_state_init(&fresh, &story)
ink.state_load_json(&fresh, string(saved_json))The save format is identical to inkle's reference (so games can save in Odin and load in Unity, or vice versa).
ink.story_continue(&state) // Default flow
ink.story_switch_flow(&state, "bard") // Create / switch to "bard"
ink.story_choose_path_string(&state, "bard_intro", increment_turn = false)
ink.story_continue(&state) // Bard flow
ink.story_switch_to_default_flow(&state) // Default's pending choices still hereEach flow has its own callstack, output stream, and choice list; globals (variables, visit counts) are shared. story_remove_flow(name) drops an inactive non-default flow; story_alive_flow_names() lists everything that's currently in play.
Verified against five fixtures (TheIntercept + 4 focused tests) across 50 seeded random walks, byte-identical to upstream:
- Knots, stitches, gathers, weaves, nested choices, sticky vs once-only choices, default invisible choices
- Diverts (absolute & relative paths), function calls, tunnels, tunnel-onwards
- Variables (
VAR,temp, ref params), assignments, variable pointers - Conditionals (
{cond: A | B}), sequences and shuffles (deterministic, RNG-matched to upstream) - Glue, function-bracket whitespace trimming, output stream snapshot/rewind
- Native operators (
+,-,*,/,%,==,!=,<,>,<=,>=,!,&&,||,MIN,MAX,POW, unary negate,FLOOR,CEILING,INT,FLOAT) - Visit counts, turn indices,
READ_COUNT(-> knot),TURNS_SINCE(-> knot) RANDOM(min, max),SEED_RANDOM(seed)- Tags: legacy
#tagand modernBegin_Tag/End_Tag;globalTagsandtagsForContentAtPathaccessors - LIST types: declarations, literals, set ops (
+,-,^,?,!?), comparisons (==/!=/</>/<=/>=), allLIST_*builtins (COUNT,VALUE,MIN,MAX,ALL,INVERT,RANGE,RANDOM),Origin(int)constructor,list ± intshifts - Threads (
<- knotsyntax): PushThread on the StartThread cmd, parent's pointer auto-advances on PopThread, choice-thread snapshot/restore on pick - External function bindings with optional same-named ink fallbacks; lookahead-safe deferral so unsafe callbacks only fire once after snapshot resolution
- Save/load round-trip via
state_to_json/state_load_json, format-compatible with the reference runtime - Multi-flow (
SwitchFlow, parallel cursors) with full save/load support
Containertoken in saved state's eval/output stream is symmetrically punted: writer emitsnull, loader returnsnil. Under normal play these slots only carry value types so it doesn't trigger; flagged here for completeness.- The runtime targets ink format version 21 (current as of inkle/ink 1.x). Older saves are accepted from save version 8 onward (matching upstream's compatibility window).
If you have a story that hits a real divergence, file an issue with the .ink and a description of what should happen — the diff harness makes failures easy to localize.
vendor/ink/ is a git submodule of github.com/inkle/ink — the canonical upstream. Two test runners produce per-step logs in the same format:
tools/ink-test-runner/(dotnet, referencesvendor/ink/ink-engine-runtime/directly): runs the reference C# runtime and writestests/golden/reference/<fixture>/seed_{0..9}.log. Treated as source of truth.tools/ink-test-runner-odin/(Odin): runs this runtime, produces in-memory logs. Itsdiff_test.odinbyte-diffs against the dotnet goldens for every fixture.
Logs include public observables (OUTPUT, TAGS, CHOICES, PICK) and the full state.ToJson() snapshot per step (object keys recursively sorted, arrays in source order). Choice picks are seeded via a port of System.Random (ink/net_random.odin) so the same numerical RNG sequence drives both runtimes.
Fixtures under tests/fixtures/:
| Fixture | What it exercises |
|---|---|
the_intercept |
Real shipping ink (inkle's demo). Broad coverage. |
lists |
LIST defs, set ops, all LIST_* builtins, list+int shifts |
random_visits |
RANDOM, READ_COUNT, TURNS_SINCE, sticky-choice loops |
threads |
<- knot thread syntax, PushThread / PopThread |
externals |
EXTERNAL declarations with ink-side fallbacks |
Regenerate goldens after a submodule bump or a new fixture:
# 1. Compile each .ink (only when authoring/changing fixtures)
dotnet build vendor/ink/inklecate/inklecate.csproj -c Release
dotnet vendor/ink/inklecate/bin/Release/net6.0/inklecate.dll \
tests/fixtures/<fixture>/<Name>.ink
# 2. Run the dotnet runner. `all` does every fixture; passing a name does one.
dotnet run --project tools/ink-test-runner -- allRun all tests:
odin test ./ink # 70 unit tests (runtime + API helpers)
odin test ./tools/ink-test-runner-odin # conformance diff (50 seeded walks)ink-to-odin/
├── ink/ the package (import "path/to/ink")
│ ├── api.odin game-facing helpers (variables, tags, errors)
│ ├── compiled_story.odin Compiled_Story + lifecycle, list defs registry
│ ├── externals.odin EXTERNAL bindings, dispatch, fallback path
│ ├── json_load.odin parses inklecate's compiled JSON (incl. listDefs)
│ ├── json_save.odin state_to_json (round-trips with upstream)
│ ├── list_definitions.odin LIST def registry, item lookup
│ ├── list_ops.odin union/diff/intersect/shift/range/sort, LIST_*
│ ├── runtime_object.odin Object + Object_Variant tagged union
│ ├── value.odin Int / Float / String / List / etc. value types
│ ├── path.odin Path navigation (knot.stitch.idx, with relative `^`)
│ ├── pointer.odin (Container, index) execution position
│ ├── object.odin root-walking, content-at-path, relative resolution
│ ├── call_stack.odin frames, threads, temp variables
│ ├── variable_state.odin globals, defaults, list-item lookup, var-pointer deref
│ ├── output_stream.odin text accumulation, glue/trim, current_text/tags
│ ├── story_state.odin the saveable runtime state, multi-flow management
│ ├── state_snapshot.odin deep-copy/restore for newline lookahead
│ ├── state_load.odin state_load_json (mirror of json_save)
│ ├── story.odin the evaluator: continue, choose, control commands
│ ├── net_random.odin port of System.Random for seeded ops
│ └── *_test.odin @(test) procs
│
├── examples/
│ ├── intercept_cli/ terminal player for TheIntercept
│ └── loader_smoke_test/ visual sanity check for the JSON loader
│
├── tests/
│ ├── fixtures/
│ │ ├── the_intercept/ vendored TheIntercept.ink + compiled .json
│ │ ├── lists/ LIST coverage fixture
│ │ ├── random_visits/ RANDOM / visit-count fixture
│ │ ├── threads/ StartThread fixture
│ │ ├── externals/ EXTERNAL + ink fallback fixture
│ │ ├── external_unsafe/ lookahead-safe deferral fixture
│ │ └── api/ helpers test fixture
│ └── golden/reference/<fixture>/ dotnet runner output, source of truth
│
├── tools/
│ ├── ink-test-runner/ dotnet test runner (writes goldens, takes a fixture name)
│ └── ink-test-runner-odin/ Odin test runner + diff test (iterates all fixtures)
│
└── vendor/ink/ git submodule of inkle's reference repo
- inkle created the ink language and runtime. The reference runtime in
vendor/ink/and The Intercept (tests/fixtures/the_intercept/TheIntercept.ink) are MIT-licensed, © 2016 inkle Ltd. - The port is written in Odin.
The Odin port itself (everything outside vendor/ink/ and tests/fixtures/the_intercept/) is MIT-licensed.
Beta. Conformance covers most surface area of ink format v21 (50 byte-identical seeded walks across five fixtures hitting every control command, native function, list op, and the threading + externals + multi-flow paths). The remaining gap is real-world stories that exercise unusual combinations not in the fixtures — the diff harness makes any new divergence easy to bisect.