o
odinpkg.dev
packages / library / ink-to-odin

ink-to-odin

34eff43library

Port of ink runtime to odin lang.

MIT · updated 2 months ago

PROJECT FULLY GENERATED BY LLM. DO NOT USE IT IF LLM GENERATED CODE IS NOT ALLOWED IN YOUR CODE BASE

ink-to-odin

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.

Quick start

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_cli

You 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.

Using the runtime in your game

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 and import 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, then import "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.

Public API

Driving the story

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

Variables

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

// 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)

External function bindings

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.

Save / load

// 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).

Multi-flow (parallel cursors)

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 here

Each 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.

What's supported

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 #tag and modern Begin_Tag/End_Tag; globalTags and tagsForContentAtPath accessors
  • LIST types: declarations, literals, set ops (+, -, ^, ?, !?), comparisons (==/!=/</>/<=/>=), all LIST_* builtins (COUNT, VALUE, MIN, MAX, ALL, INVERT, RANGE, RANDOM), Origin(int) constructor, list ± int shifts
  • Threads (<- knot syntax): 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

Known limitations

  • Container token in saved state's eval/output stream is symmetrically punted: writer emits null, loader returns nil. 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.

How conformance is tested

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, references vendor/ink/ink-engine-runtime/ directly): runs the reference C# runtime and writes tests/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. Its diff_test.odin byte-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 -- all

Run all tests:

odin test ./ink                          # 70 unit tests (runtime + API helpers)
odin test ./tools/ink-test-runner-odin   # conformance diff (50 seeded walks)

Project layout

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

Acknowledgments & licensing

  • 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.

Status

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.