wrote a lightweight Ink dialogue runner for Godot 4 — no plugin, just reads the compiled JSON

119 views 0 replies

Using Ink for dialogue in my RPG. The official GodotInk plugin works but it's a C# layer I don't fully understand, and when I hit edge cases I couldn't debug through it cleanly. So I spent a weekend reading compiled Ink output and wrote a minimal GDScript runner that talks directly to the .ink.json file.

No plugin, no C# bridge. Compile your .ink files with Inklecate (or the browser-based compiler), point the runner at the output JSON, done.

class_name InkRunner
extends RefCounted

var _containers: Dictionary = {}
var _current: Array = []
var _pointer: int = 0

func load_json(path: String) -> void:
    var parsed: Dictionary = JSON.parse_string(FileAccess.get_file_as_string(path))
    var root: Array = parsed.get("root", [])
    _unpack_containers(root, "")
    _current = root
    _pointer = 0

func get_next_line() -> String:
    while _pointer < _current.size():
        var token = _current[_pointer]
        _pointer += 1
        if token is String and token.begins_with("^"):
            return token.substr(1).strip_edges()
        elif token is Dictionary and token.has("->"):
            _jump(token["->"])
            return get_next_line()
    return ""

func get_choices() -> Array:
    var choices := []
    for i in range(_pointer, _current.size()):
        var token = _current[i]
        if token is Dictionary and (token.has("*") or token.has("+")):
            choices.append({"text": token.get("*", token.get("+", "")), "index": i})
        else:
            break
    return choices

func choose(choice_index: int) -> void:
    var choice = get_choices()[choice_index]
    var token = _current[choice["index"]]
    if token.has("->"):
        _jump(token["->"])

func _jump(target: String) -> void:
    if _containers.has(target):
        _current = _containers[target]
        _pointer = 0

func _unpack_containers(container: Array, prefix: String) -> void:
    for token in container:
        if token is Dictionary:
            for key in token.keys():
                if key != "#" and token[key] is Array:
                    var full_key := (prefix + "." + key).trim_prefix(".")
                    _containers[full_key] = token[key]
                    _unpack_containers(token[key], full_key)

The Ink JSON format isn't documented as a standalone spec anywhere obvious. I had to read through compiled output and cross-reference the inkjs source to work it out. Text content is stored as strings prefixed with ^, diverts are {"->":"knot_name"} objects, choices are * or + dictionary entries stored inline in the container, and named containers (knots/stitches) appear as keyed dictionary entries inside their parent container array. Weird format once you see it, makes sense once you understand why.

What's missing: variable interpolation in text output, conditional branching logic, visit counts, tunnels, external functions. But for branching dialogue with choices and basic flow it runs in production and I can actually step through it with a debugger when something breaks, which was the whole point.

The part I'm still not happy with: Ink's weave syntax compiles into a nested structure that my flat container model doesn't traverse correctly. Complex weaves with inline choices and gather points produce containers-within-containers in ways I haven't fully mapped yet. Anyone dug into the ink-unity-integration or inkjs runtime to understand how weave traversal is supposed to work? Feels like I'm missing something fundamental about how the pointer model handles re-entry.

Moonjump
Forum Search Shader Sandbox
Sign In Register