Handlers
Handlers
Handlers are the bridge between the engine and your game. They work like observers — you subscribe a function, and the engine calls it when the matching event occurs. This is how you trigger the right behaviors in your game engine: display text, play an animation, evaluate state, etc.
The engine exposes the following handlers:
| Handler | Level | Description |
|---|---|---|
onDialog | global / scene | Dialog block — display text |
onChoice | global / scene | Choice block — present choices |
onCondition | global / scene | Condition block — evaluate and branch |
onAction | global / scene | Action block — trigger side effects |
onResolveCharacter | global / scene | Resolve which character is speaking |
onBeforeBlock | global | Before every block (delay, entry animations…) |
onValidateNextBlock | global | Validate before progressing to a block |
onInvalidateBlock | global | React when validation fails |
onSceneEnter | global / scene | A scene starts |
onSceneExit | global / scene | A scene ends |
onBlock | scene | Override a specific block by UUID |
onDialogId | scene | Override a specific DIALOG block by UUID (type-safe) |
onChoiceId | scene | Override a specific CHOICE block by UUID (type-safe) |
onConditionId | scene | Override a specific CONDITION block by UUID (type-safe) |
onActionId | scene | Override a specific ACTION block by UUID (type-safe) |
onResolveCondition | global | Unified condition resolver (choice visibility + condition pre-evaluation) |
setChoiceFilter | global | Deprecated — use onResolveCondition instead |
onDialog, onChoice, and onAction are required — the engine validates their presence when start() is called and throws a descriptive error if any are missing. onCondition is optional when onResolveCondition is installed — the engine auto-routes from pre-evaluated condition groups.
// required — the engine won't start without these
engine.onDialog(dialogHandler);
engine.onChoice(choiceHandler);
engine.onCondition(conditionHandler);
engine.onAction(actionHandler);
// optional — lifecycle, validation, pre-execution
engine.onBeforeBlock(beforeBlockHandler);
engine.onResolveCharacter(resolveCharacterHandler);
engine.onValidateNextBlock(validateHandler);
engine.onSceneEnter(sceneEnterHandler);
engine.onSceneExit(sceneExitHandler);
const handle = engine.scene(sceneId);
handle.start();// required — the engine won't start without these
engine.OnDialog(dialogHandler);
engine.OnChoice(choiceHandler);
engine.OnCondition(conditionHandler);
engine.OnAction(actionHandler);
// optional — lifecycle, validation, pre-execution
engine.OnBeforeBlock(beforeBlockHandler);
engine.OnResolveCharacter(resolveCharacterHandler);
engine.OnValidateNextBlock(validateHandler);
engine.OnSceneEnter(sceneEnterHandler);
engine.OnSceneExit(sceneExitHandler);
var handle = engine.Scene(sceneId);
handle.Start();// required — the engine won't start without these
engine.onDialog(dialogHandler);
engine.onChoice(choiceHandler);
engine.onCondition(conditionHandler);
engine.onAction(actionHandler);
// optional — lifecycle, validation, pre-execution
engine.onBeforeBlock(beforeBlockHandler);
engine.onResolveCharacter(resolveCharacterHandler);
engine.onValidateNextBlock(validateHandler);
engine.onSceneEnter(sceneEnterHandler);
engine.onSceneExit(sceneExitHandler);
auto handle = engine.scene(sceneId);
handle->start();# required — the engine won't start without these
engine.on_dialog(dialog_handler)
engine.on_choice(choice_handler)
engine.on_condition(condition_handler)
engine.on_action(action_handler)
# optional — lifecycle, validation, pre-execution
engine.on_before_block(before_block_handler)
engine.on_resolve_character(resolve_character_handler)
engine.on_validate_next_block(validate_handler)
engine.on_scene_enter(scene_enter_handler)
engine.on_scene_exit(scene_exit_handler)
var handle = engine.scene(scene_id)
handle.start()Two-Tier Handler System
The engine resolves handlers in two tiers:
- Global handlers — registered on the engine, they define the default behavior for every scene. They are typically all you need.
- Scene handlers — registered on a specific
SceneHandle, they let you override or extend the default behavior when a scene requires a different rendering or control flow. This is rare, but available.
When a block is dispatched, the engine resolves the handler in this order:
handle.onBlock(uuid)orhandle.onDialogId(uuid)/handle.onActionId(uuid)/ ... — block-specific overridehandle.onDialog()/handle.onChoice()/ ... — scene-level type handlerengine.onDialog()/engine.onChoice()/ ... — global handler
When both tiers are present, both run in sequence — scene first, then global — unless the scene handler calls context.preventGlobalHandler() to suppress the global pass.
// Tier 1 — global
engine.onDialog(({ block, context, next }) => {
console.log('Global dialog handler');
next();
});
// Tier 2 — scene-specific
const handle = engine.scene(sceneId);
handle.onDialog(({ block, context, next }) => {
console.log('Scene-specific dialog handler');
context.preventGlobalHandler();
next();
});
handle.start();// Tier 1 — global
engine.OnDialog(args => {
Console.WriteLine("Global dialog handler");
args.Next();
return null;
});
// Tier 2 — scene-specific
var handle = engine.Scene(sceneId);
handle.OnDialog(args => {
Console.WriteLine("Scene-specific dialog handler");
args.Context.PreventGlobalHandler();
args.Next();
return null;
});
handle.Start();// Tier 1 — global
engine.onDialog([](auto*, auto* block, auto* ctx, auto next) -> CleanupFn {
std::cout << "Global dialog handler\n";
next();
return {};
});
// Tier 2 — scene-specific
auto handle = engine.scene(sceneId);
handle->onDialog([](auto*, auto* block, auto* ctx, auto next) -> CleanupFn {
std::cout << "Scene-specific dialog handler\n";
ctx->preventGlobalHandler();
next();
return {};
});
handle->start();# Tier 1 — global
engine.on_dialog(func(args):
print("Global dialog handler")
args["next"].call()
return Callable()
)
# Tier 2 — scene-specific
var handle = engine.scene(scene_id)
handle.on_dialog(func(args):
print("Scene-specific dialog handler")
args["context"].prevent_global_handler()
args["next"].call()
return Callable()
)
handle.start()Character Resolution
Character resolution is optional. By registering an onResolveCharacter callback, the engine invokes it before every block that has characters in its metadata.characters. The callback receives the list of characters assigned to the block and returns the one that should be active — or undefined if none is available. The resolved character is then accessible via context.character in all handlers.
This is the ideal integration point to query your game state: check if a character is present in the scene, alive, in camera range, etc. Returning undefined opens the door to several strategies: skip the block via skipIfMissingActor, cancel the scene via handle.cancel(), or handle the case directly in the handler.
// Engine-level — applies to all scenes
engine.onResolveCharacter((characters) => {
return party.getActiveLeader(characters);
});
// Scene-level override
const handle = engine.scene(sceneId);
handle.onResolveCharacter((characters) => {
return battle.getActiveUnit(characters);
});engine.OnResolveCharacter(chars => party.GetActiveLeader(chars));
var handle = engine.Scene(sceneId);
handle.OnResolveCharacter(chars => battle.GetActiveUnit(chars));engine.onResolveCharacter([](const auto& chars) {
return party.getActiveLeader(chars);
});
auto handle = engine.scene(sceneId);
handle->onResolveCharacter([](const auto& chars) {
return battle.getActiveUnit(chars);
});engine.on_resolve_character(func(chars):
return party.get_active_leader(chars)
)
var handle = engine.scene(scene_id)
handle.on_resolve_character(func(chars):
return battle.get_active_unit(chars)
)Scene Lifecycle
The onSceneEnter and onSceneExit callbacks let you react to a scene starting and ending — enable cinema mode, freeze NPCs, prepare the UI, clean up resources, etc. They are available at global level (on the engine) and at scene level (via handle.onEnter() / handle.onExit()). The scene handler replaces the global one if defined.
engine.onSceneEnter(({ scene }) => {
game.cinemaMode(true);
game.stopNpcMovements();
});
engine.onSceneExit(() => {
game.cinemaMode(false);
game.resumeNpcMovements();
});
// scene-level override
const handle = engine.scene(sceneId);
handle.onEnter(({ scene }) => {
game.playIntroSequence(scene);
});engine.OnSceneEnter(args => {
Game.CinemaMode(true);
Game.StopNpcMovements();
});
engine.OnSceneExit(args => {
Game.CinemaMode(false);
Game.ResumeNpcMovements();
});
// scene-level override
var handle = engine.Scene(sceneId);
handle.OnEnter(args => {
Game.PlayIntroSequence(args.Scene);
});engine.onSceneEnter([&game](auto* scene, auto*) {
game.cinemaMode(true);
game.stopNpcMovements();
});
engine.onSceneExit([&game](auto*, auto*) {
game.cinemaMode(false);
game.resumeNpcMovements();
});
// scene-level override
auto handle = engine.scene(sceneId);
handle->onEnter([&game](auto* scene, auto*) {
game.playIntroSequence(scene);
});engine.on_scene_enter(func(args):
game.cinema_mode(true)
game.stop_npc_movements()
)
engine.on_scene_exit(func(args):
game.cinema_mode(false)
game.resume_npc_movements()
)
# scene-level override
var handle = engine.scene(scene_id)
handle.on_enter(func(args):
game.play_intro_sequence(args["scene"])
)Block Override
onBlock(uuid) lets you target a specific block by its identifier and assign it a dedicated handler. This is a rare use case — generic handlers cover the vast majority of needs — but for very specific scenarios where an individual block requires distinct behavior, it is available.
const handle = engine.scene(sceneId);
handle.onBlock('block-uuid-123', ({ block, context, next }) => {
next();
});var handle = engine.Scene(sceneId);
handle.OnBlock("block-uuid-123", args => {
args.Next();
return null;
});auto handle = engine.scene(sceneId);
handle->onBlock("block-uuid-123", [](auto*, auto*, auto*, auto next) -> CleanupFn {
next();
return {};
});var handle = engine.scene(scene_id)
handle.on_block("block-uuid-123", func(args):
args["next"].call()
return Callable()
)Type-Safe Block Override
onDialogId(uuid), onChoiceId(uuid), onConditionId(uuid), and onActionId(uuid) are type-safe alternatives to onBlock(uuid). They work exactly the same way — same priority, same preventGlobalHandler support — but the handler receives the specialized block type and context instead of the generic union.
Use these when you know the block type at registration time and want full autocompletion on block and context.
const handle = engine.scene(sceneId);
handle.onActionId('block-uuid-123', ({ block, context, next }) => {
// block is ActionBlock — actions is directly accessible
for (const action of block.actions ?? []) {
executeAction(action);
}
// context is ActionContext — resolve/reject are available
context.resolve();
next();
});var handle = engine.Scene(sceneId);
handle.OnActionId("block-uuid-123", args => {
// args.Block is ActionBlock — Actions is directly accessible
foreach (var action in args.Block.Actions ?? [])
ExecuteAction(action);
// args.Context is IActionContext — Resolve/Reject are available
args.Context.Resolve();
args.Next();
});auto handle = engine.scene(sceneId);
handle->onActionId("block-uuid-123", [](auto* scene, const ActionBlock* block, IActionContext* ctx, auto next) -> CleanupFn {
// block is const ActionBlock* — actions is directly accessible
for (const auto& action : block->actions)
executeAction(action);
// ctx is IActionContext* — resolve/reject are available
ctx->resolve();
next();
return {};
});var handle = engine.scene(scene_id)
handle.on_action_id("block-uuid-123", func(args):
# args["block"] contains actions directly
for action in args["block"].get("actions", []):
execute_action(action)
# args["context"] has resolve/reject
args["context"]["resolve"].call()
args["next"].call()
return Callable()
)