Lifecycle & Validation
Execution Order for Each Block
- Previous block cleanup — The cleanup function returned by the previous block's handler runs at transition time (when
next()is called) onValidateNextBlock— Validation before executiononBeforeBlock— Pre-processing (must callresolve()to continue)- Type handler (Tier 2 then Tier 1)
Scene Events
engine.onSceneEnter(({ scene, context }) => {
// Called when handle.start() is executed
});
engine.onSceneExit(({ scene, context }) => {
// Called when the scene ends. context.reason says why:
// 'completed' | 'cancelled' | 'invalidated' | 'faulted' | 'deadlocked'
// scene.getSceneId() says WHICH scene — a global handler needs it when several play.
if (context.reason === 'faulted') {
console.error(`Scene ${scene.getSceneId()} failed`, context.error);
}
if (context.reason === 'deadlocked') {
console.warn('Scene stuck waiting for', context.waitingFor);
}
});engine.OnSceneEnter(args => {
// Called when handle.Start() is executed
});
engine.OnSceneExit(args => {
// Called when the scene ends. args.Context.Reason says why (SceneEndReason.*)
// args.Scene.GetSceneId() says WHICH scene — a global handler needs it when several play.
if (args.Context.Reason == SceneEndReason.Faulted)
Debug.LogException(args.Context.Error);
if (args.Context.Reason == SceneEndReason.Deadlocked)
Debug.LogWarning($"{args.Scene.GetSceneId()} stuck waiting for {string.Join(", ", args.Context.WaitingFor)}");
});engine.onSceneEnter([](const lsde::SceneLifecycleArgs& args) {
// Called when handle->start() is executed
});
engine.onSceneExit([](const lsde::SceneLifecycleArgs& args) {
// Called when the scene ends. args.context.reason says why (lsde::SceneEndReason::*)
// args.scene->getSceneId() says WHICH scene — a global handler needs it when several play.
if (args.context.reason == lsde::SceneEndReason::Faulted) {
// args.context.error holds the exception: std::rethrow_exception it to read it
}
if (args.context.reason == lsde::SceneEndReason::Deadlocked) {
// args.context.waitingFor names the blocks the scene was stuck on
}
});engine.on_scene_enter(func(args):
pass # Called when handle.start() is executed
)
engine.on_scene_exit(func(args):
# Called when the scene ends. args["context"]["reason"] says why (LsdeTypes.SCENE_END_*)
# args["scene"].get_scene_id() says WHICH scene — a global handler needs it when several play.
if args["context"]["reason"] == LsdeTypes.SCENE_END_DEADLOCKED:
push_warning("%s stuck waiting for %s" % [args["scene"].get_scene_id(), str(args["context"]["waitingFor"])])
)onValidateNextBlock
Intercepts each block transition for validation. The handler receives the resolved character for both the upcoming block (nextContext) and the previously executed block (fromContext):
engine.onValidateNextBlock(({ nextBlock, fromBlock, nextContext, fromContext }) => {
return { valid: true };
});
engine.onInvalidateBlock(({ scene, reason }) => {
console.error('Invalid block:', reason);
scene.cancel();
});engine.OnValidateNextBlock(args => {
// args.NextContext.Character, args.FromContext?.Character
return new ValidationResult { Valid = true };
});
engine.OnInvalidateBlock(args => {
Console.Error.WriteLine($"Invalid block: {args.Reason}");
args.Scene.Cancel();
});engine.onValidateNextBlock([](const auto& args) {
// args.nextContext.character, args.fromContext.character (check args.hasFromContext)
return ValidationResult{true};
});
engine.onInvalidateBlock([](auto* scene, const auto& reason) {
std::cerr << "Invalid block: " << reason << "\n";
scene->cancel();
});engine.on_validate_next_block(func(args):
# args["nextContext"]["character"], args["fromContext"]["character"]
return {"valid": true}
)
engine.on_invalidate_block(func(args):
printerr("Invalid block: %s" % args["reason"])
args["scene"].cancel()
)Character Gating
Use nextContext.character to control which blocks are allowed to execute based on game state:
// Block if the character is stunned
engine.onValidateNextBlock(({ nextContext }) => {
const { character } = nextContext;
if (!character) return { valid: false, reason: 'no_character' };
if (game.characterHasStatus(character, 'stunned'))
return { valid: false, reason: 'character_stunned' };
return { valid: true };
});engine.OnValidateNextBlock(args => {
var character = args.NextContext.Character;
if (character == null)
return ValidationResult.Fail("no_character");
if (game.CharacterHasStatus(character, "stunned"))
return ValidationResult.Fail("character_stunned");
return ValidationResult.Ok();
});engine.onValidateNextBlock([&game](const auto& args) {
auto* character = args.nextContext.character;
if (!character) return ValidationResult{false, "no_character"};
if (game.characterHasStatus(character, "stunned"))
return ValidationResult{false, "character_stunned"};
return ValidationResult{true};
});engine.on_validate_next_block(func(args):
var character = args["nextContext"]["character"]
if character == null:
return {"valid": false, "reason": "no_character"}
if game.character_has_status(character, "stunned"):
return {"valid": false, "reason": "character_stunned"}
return {"valid": true}
)Use fromContext.character to validate transitions between characters (e.g. relationship checks, cooldowns). fromContext is null for the first block of a scene.
onBeforeBlock
Called before each block. Must call resolve() to continue:
engine.onBeforeBlock(({ block, resolve }) => {
const delay = LsdeUtils.getNativeProperties(block)?.delay;
if (delay) {
setTimeout(resolve, delay * 1000);
} else {
resolve();
}
});engine.OnBeforeBlock(args => {
var delay = args.Block.NativeProperties?.Delay;
if (delay.HasValue)
{
// use your engine's delay system (coroutine, DOTween, Invoke, etc.)
DelayThenCall((float)delay.Value, args.Resolve);
}
else
{
args.Resolve();
}
});engine.onBeforeBlock([](const auto& args) {
auto delay = args.block->props
? args.block->props->delay : std::nullopt;
if (delay.has_value()) {
// use your engine's timer system (FTimerManager, SDL_AddTimer, etc.)
scheduleDelay(delay.value(), [&args]() { args.resolve(); });
} else {
args.resolve();
}
});engine.on_before_block(func(args):
var delay = args["block"].get("props", {}).get("delay", 0)
if delay > 0:
await get_tree().create_timer(delay).timeout
args["resolve"].call()
)A resolve() called while onBeforeBlock is still running takes effect when it returns — exactly like next() called inside a handler. The type handler is dispatched after your callback has finished, so any line you write after resolve() runs before the block, not after it.
Cleanup Functions
A handler can return a cleanup function, called when leaving the block:
engine.onDialog(({ block, next }) => {
const element = showDialogUI(block);
// next() is called later — by player input, timer, etc.
return () => {
element.remove(); // called when the engine moves to the next block
};
});engine.OnDialog(args => {
var element = ShowDialogUI(args.Block);
// next() is called later — by player input, timer, etc.
return () => element.SetActive(false);
});engine.onDialog([](auto*, auto* block, auto* ctx, auto next) -> CleanupFn {
auto* element = showDialogUI(block);
// next() is called later — by player input, timer, etc.
return [element]() { element->remove(); };
});engine.on_dialog(func(args):
var element = show_dialog_ui(args["block"])
# next is called later — by player input, timer, etc.
return func(): element.queue_free()
)Error Boundaries
Nothing is swallowed. If any of your code throws while the engine walks the graph — a type handler, a cleanup function, onValidateNextBlock, onInvalidateBlock, onBeforeBlock, onResolveCondition, onResolveCharacter or onSceneEnter — the engine closes the whole scene first, and then re-throws the error to whoever called start(), next() or resolve().
It holds on every track. A handler that throws on an isAsync branch closes the scene, not only its branch.
The order is what makes this usable. By the time the error reaches your code:
- the cleanup functions have run
- the async tracks are cancelled
onSceneExithas fired, withreason: 'faulted'and the error itself incontext.error
The dialogue stopped properly, and you decide what happens next — carry on without it, show a screen, or let it crash. Put your own try/catch around start(), next() or resolve().
The error arrives in two places, on purpose. It is thrown to whoever called next() — in a game, usually a click or a timer — and it is handed to onSceneExit in context.error, which is where the code awaiting the end of the dialogue is listening. Log it in one of the two, not both.
An exception thrown by onSceneExit itself reaches you the same way, and the scene is released all the same: engine.isRunning() no longer counts it. Unless the scene was already closing on a fault: you then receive that fault, the one that explains the rest.
GDScript
GDScript has no exceptions. A script error inside a handler is pushed to the Godot log and the call returns null: the block simply waits for a next() that will never come. Nothing closes the scene for you.
Why this changed
v1 swallowed a handler exception silently — not even logged — while an exception from the cleanup that same handler returned reached the caller. One fault, two opposite behaviours, and the quiet one hid real bugs for as long as a project ran.
2.0.0 fixed that for the type handler only. A throwing validation, onBeforeBlock or resolver — or a handler on a parallel track — still left the scene open with nothing able to move it, and a game awaiting the end of the dialogue waited forever.
Why a Scene Ended
onSceneExit is told why, in context.reason:
reason | When |
|---|---|
completed | The flow ran out of graph |
cancelled | scene.cancel() or engine.stop() |
invalidated | onValidateNextBlock refused the block the last running track was entering |
faulted | Your code threw during the walk. context.error holds what was thrown — see Error Boundaries |
deadlocked | Every track left is parked on a waitForBlocks nothing can finish. context.waitingFor names those blocks |
onSceneEnter receives no reason.
scene.getSceneId() says which scene ended, and scene.getScenePath() gives its path. A global onSceneExit needs it as soon as two scenes play at once. Store the id, not the path: the path changes the day someone renames the scene.
A deadlock closes the scene. A block waiting on a block no track will ever finish — one on a branch the flow did not take, for instance — used to hold the scene open for good, with no onSceneExit. The scene now closes as deadlocked the moment the last track able to move stops, and waitingFor tells you which join was miswired.
One Thread
The engine is not thread-safe. Call next(), resolve() and cancel() from the thread that started the scene — in Unity, the main thread; in Unreal, the game thread.
In C# and C++ a call from another thread is refused with an exception that names both threads, and it changes nothing: the same next() still works once it is called from the right thread. In Unity, switch back before calling into the engine (await UniTask.SwitchToMainThread(), or queue the call for the main thread).
cancel()
Calling scene.cancel() triggers this sequence:
- All async tracks are cancelled
- The cleanup function of the current block is executed
- The
onSceneExithandler is called, withreason: 'cancelled' - The scene is marked as finished
A cleanup that calls scene.cancel() or engine.stop() while the scene is already closing is ignored: onSceneExit fires once.
engine.onInvalidateBlock(({ scene, reason }) => {
console.error('Validation failed:', reason);
scene.cancel();
});engine.OnInvalidateBlock(args => {
Console.Error.WriteLine($"Validation failed: {args.Reason}");
args.Scene.Cancel();
});engine.onInvalidateBlock([](auto* scene, const auto& reason) {
std::cerr << "Validation failed: " << reason << "\n";
scene->cancel();
});engine.on_invalidate_block(func(args):
printerr("Validation failed: %s" % args["reason"])
args["scene"].cancel()
)NativeProperties
Execution properties that control how a block is dispatched by the engine:
| Field | Type | Description |
|---|---|---|
isAsync | boolean? | Execute on a parallel async track |
delay | number? | MILLISECONDS before the block plays. Applied by onBeforeBlock, never by the engine |
timeout | number? | MILLISECONDS the block STAYS after its line has been said, then it leaves on its own — an auto-advance for blocks. Outranks waitInput. Passed through; the engine enforces nothing |
portPerCharacter | boolean? | One output port per character in metadata |
skipIfMissingActor | boolean? | Skip block if referenced actor is absent |
debug | boolean? | Debug flag for editor use |
waitForBlocks | string[]? | Block ids of this scene. The block is held before it is dispatched until every one of them has finished. If nothing can ever finish them, the scene closes as deadlocked |
waitInput | boolean? | Passive flag for explicit player input control |
