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 { dialogueText, nativeProperties } = block;
const { character, resolveCharacterPort } = context;
const text = LsdeUtils.getLocalizedText(dialogueText);
character && resolveCharacterPort(character.uuid);
dialog.show(text, character?.name);
this.pendingNext = next;
// auto-advance after a delay (cinematics, tutorials)
let timer: Phaser.Time.TimerEvent | null = null;
if (nativeProperties?.timeout) {
timer = this.time.delayedCall(nativeProperties.timeout * 1000, () => this.advance());
}
return () => { dialog.hide(); this.pendingNext = null; timer?.destroy(); };
});
// ── CHOICE ────────────────────────────────────────────────
params.engine.onChoice(({ block, context, next }) => {
const { nativeProperties } = block;
const { choices: items, selectChoice } = context;
const visible = items.filter(c => c.visible !== false);
const buttons = choices.show(visible, (uuid) => {
selectChoice(uuid);
next();
});
let timer: Phaser.Time.TimerEvent | null = null;
if (nativeProperties?.timeout) {
timer = this.time.delayedCall(nativeProperties.timeout * 1000, () => 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();
});
}
private advance() {
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: { uuid: string; dialogueText?: Record<string, string>; label?: string }[],
onSelect: (uuid: string) => void,
) => {
return visible.map((choice, i) => {
const text = LsdeUtils.getLocalizedText(choice.dialogueText) ?? 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.uuid));
});
},
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.DialogueText);
var ch = context.Character;
if (ch != null) context.ResolveCharacterPort(ch.Uuid);
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.Choices
.Where(c => c.Visible != false).ToList();
foreach (var choice in visible)
{
var btn = Instantiate(choiceButtonPrefab, choiceContainer);
btn.GetComponentInChildren<TMP_Text>().text =
LsdeUtils.GetLocalizedText(choice.DialogueText) ?? choice.Label ?? "";
var uuid = choice.Uuid;
btn.GetComponent<Button>().onClick.AddListener(() => {
context.SelectChoice(uuid);
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->dialogueText);
auto* ch = ctx->character();
if (ch) ctx->resolveCharacterPort(ch->uuid);
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->choices();
for (const auto& c : choices) {
if (!c.visible.has_value() || c.visible.value()) {
auto text = lsde::LsdeUtils::GetLocalizedText(c.dialogueText);
ChoiceWidget->AddOption(
FString(UTF8_TO_TCHAR(c.uuid.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& Uuid)
{
if (PendingChoiceCtx) {
PendingChoiceCtx->selectChoice(TCHAR_TO_UTF8(*Uuid));
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/blueprint.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("dialogueText"))
if ch:
ctx.resolve_character_port(ch.get("uuid", ""))
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.choices
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("dialogueText"))
btn.text = label if label else c.get("label", "")
btn.pressed.connect(func():
ctx.select_choice(c["uuid"])
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
visiblewhen achoiceFilteris configured. Create the corresponding UI elements — buttons, list, radial menu. On player selection,selectChoice(uuid)tells the engine which branch to follow, thennext()advances the flow.Condition — conditions defined in the block. Evaluate them with your game logic — check a flag, a quest, an inventory.
context.resolve(true)sends the flow to port 0,context.resolve(false)to port 1.Action — actions defined in the block. Execute them in your engine — play a sound, give an item, trigger a cinematic.
context.resolve()confirms success,context.reject(err)signals failure.
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 enforcenativeProperties.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.
