Game Engine Integration
LSDE is engine-agnostic — no dependency on any game engine, UI framework, or audio system. It walks a graph and calls your handlers. This page shows how to wire it into the most common game engines.
For detailed handler implementation, see Block Types and Handlers.
Full Integration
The following example shows one way to integrate LSDE into each engine. It covers the 4 required handlers — dialog, choice, condition, action — in a single class, as a starting point.
Every game has its own needs. Adapt the structure, the layout, and the UI to your project.
// Phaser 3.80+ — overlay scene that handles all LSDE block types
import Phaser from 'phaser';
import { type DialogueEngine, LsdeUtils } from '@lsde/dialog-engine';
export class DialogueUI extends Phaser.Scene {
private pendingNext: (() => void) | null = null;
constructor() {
super({ key: 'DialogueUI' });
}
create(params: { engine: DialogueEngine }) {
const dialog = this.createDialogPanel();
const choices = this.createChoicePanel();
// player advances dialogue via keyboard or touch
this.input.keyboard?.addKey('SPACE').on('down', () => this.advance());
this.input.on('pointerdown', () => this.advance());
// ── DIALOG ────────────────────────────────────────────────
params.engine.onDialog(({ block, context, next }) => {
const { text, props } = block;
const { character, resolveCharacterPort } = context;
const line = LsdeUtils.getLocalizedText(text);
character && resolveCharacterPort(character.id);
dialog.show(line, character?.name);
this.pendingNext = next;
// `timeout` outranks waitInput: a line carrying one plays its own time and the
// click may only hurry its reveal, never dismiss it.
this.dismissableByInput = !props?.timeout;
// auto-advance for blocks. MILLISECONDS in v2 — no × 1000 — and counted from the
// moment the line has been SAID, not from the moment the block arrived.
let timer: Phaser.Time.TimerEvent | null = null;
if (props?.timeout) {
dialog.onRevealComplete(() => {
timer = this.time.delayedCall(props.timeout, () => this.leaveBlock());
});
}
return () => { dialog.hide(); this.pendingNext = null; timer?.destroy(); };
});
// ── CHOICE ────────────────────────────────────────────────
params.engine.onChoice(({ block, context, next }) => {
const { props } = block;
const { choices: items, selectChoice } = context;
const offered = items.filter(c => c.visible !== false);
const buttons = choices.show(offered, (optionId) => {
selectChoice(optionId);
next();
});
// On a CHOICE there is no line to reveal, so the countdown starts once the options
// are on screen and readable. MILLISECONDS in v2 — no × 1000. And it must still
// SELECT one: an option id IS the exit port, so a choice left without selectChoice()
// resolves to NO link and the branch dies silently.
let timer: Phaser.Time.TimerEvent | null = null;
if (props?.timeout) {
timer = this.time.delayedCall(props.timeout, () => {
if (offered.length > 0) selectChoice(offered[0].id);
next();
});
}
return () => { choices.hide(buttons); timer?.destroy(); };
});
// ── CONDITION ─────────────────────────────────────────────
params.engine.onCondition(({ block, context, next }) => {
// evaluate your game state — return true or false
const result = true; // ← replace with your game logic
context.resolve(result);
next();
});
// ── ACTION ────────────────────────────────────────────────
params.engine.onAction(({ block, context, next }) => {
// execute your game effects — set flags, play sounds, give items
// context.resolve() on success, context.reject(err) on failure
context.resolve();
next();
});
}
/** What the pointer calls. A line still revealing takes the click as "show me the
* rest"; a line on a `timeout` plays its own time and never leaves this way. */
private advance() {
if (!this.dialog.revealComplete) { this.dialog.skipReveal(); return; }
if (!this.dismissableByInput) return;
this.leaveBlock();
}
private leaveBlock() {
if (this.pendingNext) { this.pendingNext(); this.pendingNext = null; }
}
// ── UI factories ──────────────────────────────────────────────
private createDialogPanel() {
const bg = this.add
.graphics()
.fillStyle(0x000000, 0.8)
.fillRoundedRect(0, 0, 700, 150, 12);
const nameText = this.add.text(16, 12, '', {
fontSize: '16px', color: '#ffcc00', fontStyle: 'bold',
});
const bodyText = this.add.text(16, 38, '', {
fontSize: '14px', color: '#ffffff', wordWrap: { width: 668 },
});
const container = this.add
.container(50, 430, [bg, nameText, bodyText])
.setScrollFactor(0)
.setDepth(1000)
.setVisible(false);
return {
show: (text?: string, speaker?: string) => {
nameText.setText(speaker ?? '');
bodyText.setText(text ?? '');
container.setVisible(true);
},
hide: () => container.setVisible(false),
};
}
private createChoicePanel() {
return {
show: (
visible: { id: string; text?: Record<string, string> }[],
onSelect: (optionId: string) => void,
) => {
return visible.map((choice, i) => {
const text = LsdeUtils.getLocalizedText(choice.text) ?? choice.label ?? '';
return this.add
.text(80, 440 + i * 40, text, { fontSize: '15px', color: '#ffffff' })
.setScrollFactor(0)
.setDepth(1001)
.setInteractive({ useHandCursor: true })
.on('pointerdown', () => onSelect(choice.id));
});
},
hide: (buttons: Phaser.GameObjects.Text[]) => buttons.forEach(b => b.destroy()),
};
}
}
// start a dialogue scene from your game:
// this.scene.launch('DialogueUI', { engine });
// engine.scene(sceneId).start();// Unity 2021+ — MonoBehaviour that handles all LSDE block types
using System.Linq;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using LsdeDialogEngine;
public class DialogueUI : MonoBehaviour
{
[Header("Dialog UI — assign in Inspector")]
[SerializeField] private GameObject dialogPanel;
[SerializeField] private TMP_Text speakerName;
[SerializeField] private TMP_Text dialogText;
[Header("Choice UI")]
[SerializeField] private Transform choiceContainer;
[SerializeField] private GameObject choiceButtonPrefab;
private DialogueEngine _engine;
private System.Action _pendingNext;
/// <summary>
/// Call once to wire all LSDE handlers to this UI.
/// The engine must be initialized before calling this method.
/// </summary>
public void Register(DialogueEngine engine)
{
_engine = engine;
// ── DIALOG ────────────────────────────────────────────
engine.OnDialog(args => {
var (_, block, context, next) = args;
var text = LsdeUtils.GetLocalizedText(block.Text);
var ch = context.Character;
if (ch != null) context.ResolveCharacterPort(ch.Id);
speakerName.text = ch?.Name ?? "";
dialogText.text = text ?? "";
dialogPanel.SetActive(true);
_pendingNext = next;
return () => { dialogPanel.SetActive(false); _pendingNext = null; };
});
// ── CHOICE ────────────────────────────────────────────
engine.OnChoice(args => {
var (_, block, context, next) = args;
var visible = context.Options
.Where(c => c.Visible != false).ToList();
foreach (var choice in visible)
{
var btn = Instantiate(choiceButtonPrefab, choiceContainer);
btn.GetComponentInChildren<TMP_Text>().text =
LsdeUtils.GetLocalizedText(choice.Text) ?? choice.Label ?? "";
var optionId = choice.Id;
btn.GetComponent<Button>().onClick.AddListener(() => {
context.SelectChoice(optionId);
next();
});
}
return () => {
foreach (Transform child in choiceContainer)
Destroy(child.gameObject);
};
});
// ── CONDITION ─────────────────────────────────────────
engine.OnCondition(args => {
var (_, block, context, next) = args;
// evaluate your game state — return true or false
var result = true; // ← replace with your game logic
context.Resolve(result);
next();
return null;
});
// ── ACTION ────────────────────────────────────────────
engine.OnAction(args => {
var (_, block, context, next) = args;
// execute your game effects — set flags, play sounds, give items
// context.Resolve() on success, context.Reject(err) on failure
context.Resolve();
next();
return null;
});
}
/// <summary>Wire this to your Continue button's OnClick in the Inspector.</summary>
public void OnContinueClick()
{
if (_pendingNext == null) return;
_pendingNext();
_pendingNext = null;
}
/// <summary>Start a dialogue scene from your game code.</summary>
public void StartScene(string sceneId)
{
var handle = _engine.Scene(sceneId);
handle.Start();
}
}// UE5 — GameInstanceSubsystem that handles all LSDE block types
// Engine is a lsde::DialogueEngine member initialized in Initialize()
#include "lsde/engine.h"
#include "lsde/utils.h"
#include "DialogueSubsystem.h"
#include "DialogueWidget.h"
#include "ChoiceWidget.h"
void UDialogueSubsystem::RegisterHandlers()
{
// ── DIALOG ────────────────────────────────────────────────
Engine.onDialog([this](auto* scene, const auto* block, auto* ctx, auto next) -> lsde::CleanupFn {
auto localized = lsde::LsdeUtils::GetLocalizedText(block->text);
auto* ch = ctx->character();
if (ch) ctx->resolveCharacterPort(ch->id);
DialogWidget->SetDialogue(
FString(UTF8_TO_TCHAR(localized.value_or("").c_str())),
FString(UTF8_TO_TCHAR(ch ? ch->name.c_str() : "")));
DialogWidget->SetVisibility(ESlateVisibility::Visible);
PendingNext = std::move(next);
return [this]() {
DialogWidget->SetVisibility(ESlateVisibility::Collapsed);
PendingNext = nullptr;
};
});
// ── CHOICE ────────────────────────────────────────────────
// PendingChoiceCtx is valid only during the current block — the engine
// invalidates it once the cleanup runs or the scene advances.
Engine.onChoice([this](auto* scene, const auto* block, auto* ctx, auto next) -> lsde::CleanupFn {
const auto& choices = ctx->options();
for (const auto& c : choices) {
if (!c.visible.has_value() || c.visible.value()) {
auto text = lsde::LsdeUtils::GetLocalizedText(c.text);
ChoiceWidget->AddOption(
FString(UTF8_TO_TCHAR(c.id.c_str())),
FString(UTF8_TO_TCHAR(text.value_or("").c_str())));
}
}
PendingChoiceCtx = ctx;
PendingChoiceNext = std::move(next);
return [this]() {
ChoiceWidget->ClearOptions();
PendingChoiceCtx = nullptr;
};
});
// ── CONDITION ─────────────────────────────────────────────
Engine.onCondition([](auto*, const auto* block, auto* ctx, auto next) -> lsde::CleanupFn {
// evaluate your game state — return true or false
bool result = true; // ← replace with your game logic
ctx->resolve(result);
next();
return {};
});
// ── ACTION ────────────────────────────────────────────────
Engine.onAction([](auto*, const auto* block, auto* ctx, auto next) -> lsde::CleanupFn {
// execute your game effects — set flags, play sounds, give items
// ctx->resolve() on success, ctx->reject(err) on failure
ctx->resolve();
next();
return {};
});
}
// UFUNCTION(BlueprintCallable) — call from your Continue button
void UDialogueSubsystem::AdvanceDialogue()
{
if (PendingNext) { PendingNext(); PendingNext = nullptr; }
}
// UFUNCTION(BlueprintCallable) — call from your choice button delegate
void UDialogueSubsystem::OnChoiceSelected(const FString& OptionId)
{
if (PendingChoiceCtx) {
PendingChoiceCtx->selectChoice(TCHAR_TO_UTF8(*OptionId));
if (PendingChoiceNext) { PendingChoiceNext(); PendingChoiceNext = nullptr; }
PendingChoiceCtx = nullptr;
}
}
// UFUNCTION(BlueprintCallable) — start a dialogue scene
void UDialogueSubsystem::StartScene(const FString& SceneId)
{
auto handle = Engine.scene(TCHAR_TO_UTF8(*SceneId));
handle->start();
}# Godot 4.3+ — autoload node that handles all LSDE block types
# Register as autoload in Project > Settings > Autoload
extends Node
@onready var dialogue_panel: PanelContainer = %DialoguePanel
@onready var speaker_label: Label = %SpeakerLabel
@onready var dialogue_label: RichTextLabel = %DialogueLabel
@onready var choice_container: VBoxContainer = %ChoiceContainer
var _engine: LsdeDialogueEngine
var _pending_next: Callable
func _ready() -> void:
var json = JSON.parse_string(
FileAccess.open("res://data/blueprints.json", FileAccess.READ).get_as_text())
_engine = LsdeDialogueEngine.new()
_engine.init({"data": json})
_engine.set_locale("en")
_register_handlers()
func _register_handlers() -> void:
# ── DIALOG ────────────────────────────────────────────────
_engine.on_dialog(func(args):
var block = args["block"]
var ctx = args["context"]
var next_fn = args["next"]
var ch = ctx.character
var text = LsdeUtils.get_localized_text(block.get("text"))
if ch:
ctx.resolve_character_port(ch.get("id", ""))
speaker_label.text = ch.get("name", "") if ch else ""
dialogue_label.text = text if text else ""
dialogue_panel.visible = true
_pending_next = next_fn
return func():
dialogue_panel.visible = false
_pending_next = Callable()
)
# ── CHOICE ────────────────────────────────────────────────
_engine.on_choice(func(args):
var ctx = args["context"]
var next_fn = args["next"]
var choices = ctx.options
var visible: Array = []
for c in choices:
if c.get("visible") != false:
visible.append(c)
for c in visible:
var btn = Button.new()
var label = LsdeUtils.get_localized_text(c.get("text"))
btn.text = label if label else c.get("label", "")
btn.pressed.connect(func():
ctx.select_choice(c["id"])
next_fn.call()
)
choice_container.add_child(btn)
return func():
for child in choice_container.get_children():
child.queue_free()
)
# ── CONDITION ─────────────────────────────────────────────
_engine.on_condition(func(args):
var ctx = args["context"]
var next_fn = args["next"]
# evaluate your game state — return true or false
var result = true # ← replace with your game logic
ctx.resolve(result)
next_fn.call()
)
# ── ACTION ────────────────────────────────────────────────
_engine.on_action(func(args):
var ctx = args["context"]
var next_fn = args["next"]
# execute your game effects — set flags, play sounds, give items
# ctx.resolve() on success, ctx.reject(err) on failure
ctx.resolve()
next_fn.call()
)
## Player input — advance dialogue on ui_accept (Space, Enter, gamepad A)
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("ui_accept") and _pending_next.is_valid():
_pending_next.call()
_pending_next = Callable()
get_viewport().set_input_as_handled()
## Start a dialogue scene from your game code
func start_scene(scene_id: String) -> void:
var handle = _engine.scene(scene_id)
handle.start()The 4 Handlers
Each handler receives the block data and a next() callback. The developer processes the data in their engine, then calls next() when the block is done. The timing of that call belongs entirely to the game.
Dialog — text, character, native properties. Display the dialogue in your UI, wait for player input or a delay, then call
next(). Return a cleanup function to hide the UI when the engine moves to the next block.Choice — list of choices tagged
visiblewhenonResolveCondition()is installed. The engine hands you every option, tagged; filter onvisible !== false. Create the corresponding UI elements — buttons, list, radial menu. On player selection,selectChoice(optionId)tells the engine which branch to follow, thennext()advances the flow.Condition — the cases defined in the block. Install
onResolveCondition()once and the engine pre-evaluates them, which makesonConditionoptional. To override the routing,context.resolve(port)takes a port name —"out","default", or a case port ("K1").Action — the block's calls, in
context.calls: anfnand itsargsby name. Execute them in your engine — play a sound, give an item, trigger a cinematic.context.resolve()leaves bythen,context.reject()leaves bycatch— falling back tothenwhen nocatchis wired.
Tips
next()is the remote control. Call it instantly for rapid-fire dialogue, or hold it until an animation finishes. The engine waits — it has no concept of time.- Cleanup functions clean up after you. Return a function from any handler — the engine calls it when moving to the next block. Perfect for hiding UI, stopping audio, or freeing nodes.
onBeforeBlockhandles delays. The engine does not enforceprops.delay—onBeforeBlockreads it and callsresolve()after a timer. Full control.- Async tracks are parallel flows. When a cutscene needs dialogue and camera movement at the same time, blocks marked
isAsyncin the editor run on independent tracks.
