Using Loreline with Godot

Loreline provides a GDExtension plugin for Godot 4.2+. This guide shows how to set up your project, load a .lor script, and handle dialogue, choices, and script completion using GDScript.

Setup

Download loreline-godot.zip (v0.9.0). The archive contains:

To add Loreline to your project, copy the addons/loreline/ folder into the addons/ directory of your own Godot project.

Loading a script

Get the shared Loreline instance, then await loreline.parse() with the resource path to your .lor file:

var loreline: Loreline = Loreline.shared()

func _ready() -> void:
    var script = await loreline.parse("res://story/CoffeeShop.lor")
    if script == null:
        push_error("Failed to parse CoffeeShop.lor")
        return
    loreline.play(script, _on_dialogue, _on_choice, _on_finished)

parse() returns a Signal you can await. The signal fires once parsing and all imports have been resolved, with the parsed script (or null on parse error).

Passing a res:// or user:// path as the first argument is a shortcut: Loreline reads the file for you. If you want to pass raw source content instead, see "Custom loading" below.

Custom loading

If you need to control how files are loaded (encrypted files, network resources, etc.), load the source yourself and provide a file handler. The file handler is a Callable that takes (path, provide) and calls provide.call(content) with the file content (or provide.call(null) for not-found):

func _ready() -> void:
    var file := FileAccess.open("res://story/CoffeeShop.lor", FileAccess.READ)
    var source := file.get_as_text()
    file.close()

    var script = await loreline.parse(
        source, "res://story/CoffeeShop.lor", _handle_file)
    loreline.play(script, _on_dialogue, _on_choice, _on_finished)

func _handle_file(path: String, provide: Callable) -> void:
    if FileAccess.file_exists(path):
        var f := FileAccess.open(path, FileAccess.READ)
        provide.call(f.get_as_text())
    else:
        provide.call(null)

The handler can answer synchronously like above, or later. For example, you could fetch a file over the network and call provide.call(...) once the response arrives. parse() won't complete until every import has been answered, but each request must be answered exactly once.

Handling dialogue

Start playback by calling loreline.play() with the parsed script and your handler functions:

loreline.play(script_data, _on_dialogue, _on_choice, _on_finished)

The dialogue handler receives the interpreter, the character identifier, the text, an array of tags, and a callable to advance the script. Call advance.call() to continue to the next line:

func _on_dialogue(interp: LorelineInterpreter, character: String, text: String, tags: Array, advance: Callable) -> void:
    if character != "":
        var display_name: String = interp.get_character_field(character, "name")
        if display_name != "":
            character = display_name
        print(character + ": " + text)
    else:
        print(text)

    advance.call()

Handling choices

The choice handler receives the interpreter, an array of option dictionaries, and a callable to select an option. Each option has a "text" field and an "enabled" field. Call select.call(index) with the index of the chosen option:

func _on_choice(_interp: LorelineInterpreter, options: Array, select: Callable) -> void:
    var enabled_indices: Array[int] = []
    for i in range(options.size()):
        if options[i]["enabled"]:
            enabled_indices.append(i)
            print("  [" + str(enabled_indices.size()) + "] " + options[i]["text"])

    # In a real project, wait for player input here.
    # For this example, automatically select the first enabled choice:
    select.call(enabled_indices[0])

Handling script completion

The finish handler is called when the script reaches its end:

func _on_finished(_interp: LorelineInterpreter) -> void:
    print("--- The End ---")

Starting from a specific beat

By default, play() starts from the beginning of the script. To start from a specific beat, pass its name:

loreline.play(script_data, _on_dialogue, _on_choice, _on_finished, "MorningScene")

Interpreter options

You can pass additional options to play() to register custom functions or apply translations:

var options := LorelineOptions.new()
options.set_function("roll", func(interp, args): return randi_range(1, int(args[0])))

loreline.play(script_data, _on_dialogue, _on_choice, _on_finished, null, options)

Async custom functions

Use set_async_function() when your custom function needs to await something (a signal, a timer, an HTTP response) before the script continues. The Callable receives an extra resolve: Callable argument; call resolve.call() to resume the interpreter.

beat start
  Fetching your score...
  fetchScore()
  Your score is $score.
var options := LorelineOptions.new()
options.set_async_function("fetchScore", _fetch_score)
loreline.play(script_data, _on_dialogue, _on_choice, _on_finished, "", options)

func _fetch_score(interp: LorelineInterpreter, _args: Array, resolve: Callable) -> void:
    await get_tree().create_timer(2.0).timeout
    interp.set_top_level_state_field("score", 42)
    resolve.call()

The interpreter pauses until resolve.call() fires, then resumes with the next line.

Lifetime. Retaining any of advance, select, resolve, or your interp variable keeps the interpreter alive. If you drop all of them, the interpreter is released, even mid-play. In the snippet above, the await keeps the function (and therefore resolve) alive across the 2-second pause, which in turn keeps the interpreter alive. If you drop resolve without calling it, the async call is cancelled cleanly and the interpreter is released.

Async functions can only be called in statement position in the script, not inside expressions or interpolations.

Localization

load_locale() walks the script's import tree, finds the matching .lang.lor file next to each imported file, and gives you back a LorelineTranslations you can pass to LorelineOptions. Like parse(), it returns a Signal you can await:

func _ready() -> void:
    var script = await loreline.parse("res://story/CoffeeShop.lor")
    var translations = await loreline.load_locale("fr", script)

    var options := LorelineOptions.new()
    options.set_translations(translations)

    loreline.play(script, _on_dialogue, _on_choice, _on_finished, "", options)

Translation files that don't exist for a given locale are silently skipped, so you can ship partial translations and the original text will be used as a fallback. If you use a custom file handler with parse(), pass it as the final argument to load_locale() too.

Saving and restoring state

Loreline provides two save/restore modes on the Godot side.

Fresh resume. Capture the current state to a string, persist it, and later create a new interpreter from the saved string:

# Save current state (e.g., to a file)
var save_data: String = interp.save_state()
FileAccess.open("user://save.json", FileAccess.WRITE).store_string(save_data)

# Later: resume from saved state
var loaded := FileAccess.open("user://save.json", FileAccess.READ).get_as_text()
var script = await loreline.parse("res://story/CoffeeShop.lor")
var resumed := loreline.resume(script, _on_dialogue, _on_choice, _on_finished, loaded)

In-place restore. Restore a snapshot onto an existing interpreter without discarding it:

var save_data: String = interp.save_state()
# ... later, on the same interp ...
interp.restore_state(save_data)

Timing. A save taken during a dialogue or choice callback captures the state such that resuming re-delivers that same callback. The player sees the same line (or the same choices) again, and calling advance() from there continues normally. This is usually what you want for auto-save after each dialogue.

Complete example

Here is a minimal GDScript that loads and plays a Loreline script, printing output to the console. Attach this script to any Node:

extends Node

var loreline: Loreline = Loreline.shared()

var awaiting_choice := false
var enabled_indices: Array[int] = []
var pending_select: Callable

func _ready() -> void:
    var script_data = await loreline.parse("res://story/CoffeeShop.lor")

    if script_data:
        loreline.play(script_data, _on_dialogue, _on_choice, _on_finished)

func _on_dialogue(interp: LorelineInterpreter, character: String, text: String, _tags: Array, advance: Callable) -> void:
    if character != "":
        var display_name: String = interp.get_character_field(character, "name")
        if display_name != "":
            character = display_name
        print(character + ": " + text)
    else:
        print(text)
    print("")
    advance.call()

func _on_choice(_interp: LorelineInterpreter, options: Array, select: Callable) -> void:
    enabled_indices.clear()
    for i in range(options.size()):
        if options[i]["enabled"]:
            enabled_indices.append(i)
            print("  " + str(enabled_indices.size()) + ". " + options[i]["text"])
    pending_select = select
    awaiting_choice = true

func _unhandled_input(event: InputEvent) -> void:
    if not awaiting_choice:
        return
    if event is InputEventKey and event.pressed:
        var num := event.keycode - KEY_1
        if num >= 0 and num < enabled_indices.size():
            awaiting_choice = false
            pending_select.call(enabled_indices[num])

func _on_finished(_interp: LorelineInterpreter) -> void:
    print("\n--- The End ---")

Going further

For a complete Godot project with UI, animations, and styled output, the sample/ folder included in loreline-godot.zip provides a full working example.