Skip to content

Lifecycle & Validation

Execution Order for Each Block

  1. Previous block cleanup — The cleanup function returned by the previous block's handler runs at transition time (when next() is called)
  2. onValidateNextBlock — Validation before execution
  3. onBeforeBlock — Pre-processing (must call resolve() to continue)
  4. Type handler (Tier 2 then Tier 1)

Scene Events

ts
engine.onSceneEnter(({ scene, context }) => {
  // Called when handle.start() is executed
});

engine.onSceneExit(({ scene, context }) => {
  // Called when the scene ends (naturally or via cancel)
});
csharp
engine.OnSceneEnter(args => {
    // Called when handle.Start() is executed
});

engine.OnSceneExit(args => {
    // Called when the scene ends (naturally or via cancel)
});
cpp
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)
});
gdscript
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):

ts
engine.onValidateNextBlock(({ nextBlock, fromBlock, nextContext, fromContext }) => {
  return { valid: true };
});

engine.onInvalidateBlock(({ scene, reason }) => {
  console.error('Invalid block:', reason);
  scene.cancel();
});
csharp
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();
});
cpp
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();
});
gdscript
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:

ts
// 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 };
});
csharp
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();
});
cpp
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};
});
gdscript
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:

ts
engine.onBeforeBlock(({ block, resolve }) => {
  const delay = block.nativeProperties?.delay;
  if (delay) {
    setTimeout(resolve, delay * 1000);
  } else {
    resolve();
  }
});
csharp
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();
    }
});
cpp
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();
    }
});
gdscript
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:

ts
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
  };
});
csharp
engine.OnDialog(args => {
    var element = ShowDialogUI(args.Block);

    // next() is called later — by player input, timer, etc.

    return () => element.SetActive(false);
});
cpp
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(); };
});
gdscript
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:

  1. All async tracks are cancelled
  2. The cleanup function of the current block is executed
  3. The onSceneExit handler is called
  4. The scene is marked as finished
ts
engine.onInvalidateBlock(({ scene, reason }) => {
  console.error('Validation failed:', reason);
  scene.cancel();
});
csharp
engine.OnInvalidateBlock(args => {
    Console.Error.WriteLine($"Validation failed: {args.Reason}");
    args.Scene.Cancel();
});
cpp
engine.onInvalidateBlock([](auto* scene, const auto& reason) {
    std::cerr << "Validation failed: " << reason << "\n";
    scene->cancel();
});
gdscript
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:

FieldTypeDescription
isAsyncboolean?Execute on a parallel async track
delaynumber?Delay before execution (consumed by onBeforeBlock)
timeoutnumber?Execution timeout
portPerCharacterboolean?One output port per character in metadata
skipIfMissingActorboolean?Skip block if referenced actor is absent
debugboolean?Debug flag for editor use
waitForBlocksstring[]?Block UUIDs that must be visited before this block can progress
waitInputboolean?Passive flag for explicit player input control

Visual Reference

Block Execution Flow

Character Gating Flow