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.