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 (naturally or via cancel)
});engine.OnSceneEnter(args => {
// Called when handle.Start() is executed
});
engine.OnSceneExit(args => {
// Called when the scene ends (naturally or via cancel)
});engine.onSceneEnter([](auto* scene, auto*) {
// Called when handle->start() is executed
});
engine.onSceneExit([](auto* scene, auto*) {
// Called when the scene ends (naturally or via cancel)
});engine.on_scene_enter(func(args):
pass # Called when handle.start() is executed
)
engine.on_scene_exit(func(args):
pass # Called when the scene ends (naturally or via cancel)
)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 = block.nativeProperties?.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->nativeProperties
? args.block->nativeProperties->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("nativeProperties", {}).get("delay", 0)
if delay > 0:
await get_tree().create_timer(delay).timeout
args["resolve"].call()
)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
Every handler call is wrapped in a try/catch. If a handler throws:
- The error is silent — it is not logged or re-thrown. If your scene ends unexpectedly, check your handlers.
- For the main track: the scene ends cleanly
- For async tracks: only the affected track is terminated — other tracks and the main flow continue
This is cross-language compatible (try/catch in TS, C#, C++, GDScript).
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 - The scene is marked as finished
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? | Delay before execution (consumed by onBeforeBlock) |
timeout | number? | Execution timeout |
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 UUIDs that must be visited before this block can progress |
waitInput | boolean? | Passive flag for explicit player input control |
