Block Types
Blocks are the building blocks of a dialogue scene — each node in the editor graph is a block. The engine routes the flow from block to block and calls the matching handler for each type.
There are 5 types: Dialog, Choice, Condition, Action, and Note. The first four are content blocks with a dedicated handler (onDialog, onChoice, onCondition, onAction) — all four are required and validated when start() is called. Note blocks are skipped automatically.
Handlers come in two tiers: global handlers (registered on the engine) cover all scenes and are sufficient for most games. Scene handlers (registered on a SceneHandle) can supplement or override globals for a specific scene. See Handlers for details.
DIALOG
A dialog block represents a line of speech — a character talking, a narrator, on-screen text. The engine resolves the speaking character via the onResolveCharacter callback and exposes it as context.character. A typical dialog handler creates a text instance in the game (textbox, bubble, subtitle…), waits for the player or an animation to finish, then calls next() to advance the engine. The optional cleanup function lets you clean up side effects when the engine moves to the next block.
engine.onDialog(({ block, context, next }) => {
const { dialogueText, nativeProperties } = block;
const { character, resolveCharacterPort } = context;
const text = game.getLocalizedText(dialogueText);
const emotion = game.getCharacterEmotion(character);
character && resolveCharacterPort(character.uuid);
game.moveCameraToCharacter(character);
game.animateCharacter(character, emotion);
const dialog = game.createDialog(text, character, emotion);
const shouldWaitInput = game.shouldWaitPlayerInputForDialog(nativeProperties);
// next() tells the engine this block is done — call it when the player
// dismisses the dialog or when the text animation finishes on its own
if (shouldWaitInput) {
dialog.onInput(() => next(), { once: true });
} else {
dialog.then(() =>
game.wait(nativeProperties?.timeout ?? 0).then(() => next()),
);
}
// cleanup: runs when the engine moves to the next block
return () => {
dialog.destroy();
game.animateCharacter(character, false);
};
});engine.OnDialog(args => {
var (scene, block, context, next) = args;
var (text, ch, emotion) = (
Game.GetLocalizedText(block.DialogueText),
context.Character,
Game.GetCharacterEmotion(context.Character)
);
if (ch != null) context.ResolveCharacterPort(ch.Uuid);
Game.MoveCameraToCharacter(ch);
Game.AnimateCharacter(ch, emotion);
var dialog = Game.CreateDialog(text, ch, emotion);
var shouldWaitInput = Game.ShouldWaitPlayerInputForDialog(block.NativeProperties);
// next() tells the engine this block is done — call it when the player
// dismisses the dialog or when the text animation finishes on its own
if (shouldWaitInput)
dialog.OnInput(() => next(), once: true);
else
dialog.Then(() =>
Game.Wait(block.NativeProperties?.Timeout ?? 0).Then(() => next()));
// cleanup: runs when the engine moves to the next block
return () => {
dialog.Destroy();
Game.AnimateCharacter(ch, false);
};
});engine.onDialog([&game](auto* scene, auto* block, auto* ctx, auto next) -> CleanupFn {
auto* ch = ctx->character();
auto text = game.getLocalizedText(block->dialogueText);
auto emotion = game.getCharacterEmotion(ch);
if (ch) ctx->resolveCharacterPort(ch->uuid);
game.moveCameraToCharacter(ch);
game.animateCharacter(ch, emotion);
auto* dialog = game.createDialog(text, ch, emotion);
auto shouldWaitInput = game.shouldWaitPlayerInputForDialog(block->nativeProperties);
// next() tells the engine this block is done — call it when the player
// dismisses the dialog or when the text animation finishes on its own
if (shouldWaitInput) {
dialog->onInput([next]() { next(); });
} else {
dialog->then([&game, next, block]() {
game.wait(block->nativeProperties ? block->nativeProperties->timeout.value_or(0) : 0)
.then([next]() { next(); });
});
}
// cleanup: runs when the engine moves to the next block
return [dialog, &game, ch]() {
dialog->destroy();
game.animateCharacter(ch, false);
};
});engine.on_dialog(func(args):
var block = args["block"]
var ctx = args["context"]
var next_fn = args["next"]
var ch = ctx.character
var text = game.get_localized_text(block.get("dialogueText"))
var emotion = game.get_character_emotion(ch)
if ch:
ctx.resolve_character_port(ch.get("uuid", ""))
game.move_camera_to_character(ch)
game.animate_character(ch, emotion)
var dialog = game.create_dialog(text, ch, emotion)
var should_wait = game.should_wait_player_input(block.get("nativeProperties"))
# next_fn.call() tells the engine this block is done — call it when the player
# dismisses the dialog or when the text animation finishes on its own
if should_wait:
dialog.on_input(func(): next_fn.call(), true)
else:
await dialog.wait()
await game.wait(block.get("nativeProperties", {}).get("timeout", 0))
next_fn.call()
# cleanup: runs when the engine moves to the next block
return func():
dialog.destroy()
game.animate_character(ch, false)
)When the narrative designer assigns a dedicated output per character (portPerCharacter), the handler must call resolveCharacterPort() to tell the engine which path to follow on next().
CHOICE
A choice block represents a branching point where the player picks a response — a dialogue menu, a list of options. context.choices contains all available options. When onResolveCondition() is configured, each option is tagged visible: true | false — the handler filters and displays whichever it wants. After the player interacts, selectChoice(uuid) tells the engine which path to follow, then next() advances the flow.
engine.onChoice(({ block, context, next }) => {
const { nativeProperties } = block;
const { choices, selectChoice } = context;
const visible = choices.filter(c => c.visible !== false);
const dialog = game.createChoice(visible);
// when the player picks a choice, select it and advance
dialog
.then((selected) => selectChoice(selected))
.finally(() => next());
// optional: if the narrative designer set a timeout on this block
if (nativeProperties?.timeout) {
const timeout = game.wait(nativeProperties.timeout).then(() => next());
dialog.finally(() => timeout.cancel());
}
return () => dialog.destroy();
});engine.OnChoice(args => {
var (scene, block, context, next) = args;
var visible = context.Choices
.Where(c => c.Visible != false).ToList();
var dialog = Game.CreateChoice(visible);
// when the player picks a choice, select it and advance
dialog.OnSelect(selected => {
context.SelectChoice(selected);
next();
});
// optional: if the narrative designer set a timeout on this block
if (block.NativeProperties?.Timeout is { } timeout)
Game.Wait(timeout).Then(() => next());
return () => dialog.Destroy();
});engine.onChoice([&game](auto* scene, auto* block, auto* ctx, auto next) -> CleanupFn {
std::vector<const RuntimeChoiceItem*> visible;
for (const auto& c : ctx->choices())
if (!c.visible.has_value() || c.visible.value())
visible.push_back(&c);
auto* dialog = game.createChoice(visible);
// when the player picks a choice, select it and advance
dialog->onSelect([ctx, next](const auto& selected) {
ctx->selectChoice(selected);
next();
});
// optional: if the narrative designer set a timeout on this block
if (block->nativeProperties && block->nativeProperties->timeout) {
game.wait(*block->nativeProperties->timeout).then([next]() { next(); });
}
return [dialog]() { dialog->destroy(); };
});engine.on_choice(func(args):
var block = args["block"]
var ctx = args["context"]
var next_fn = args["next"]
var visible = []
for c in ctx.choices:
if c.get("visible") != false:
visible.append(c)
var dialog = game.create_choice(visible)
# when the player picks a choice, select it and advance
var selected = await dialog.choice_selected
ctx.select_choice(selected)
next_fn.call()
# optional: if the narrative designer set a timeout on this block
# use a Timer node or game.wait() to handle timeouts natively
return func(): dialog.destroy()
)See Choice Visibility for the full opt-in tagging system.
CONDITION
A condition block is an invisible switch — it evaluates game state and silently sends the flow down one or more paths without the player seeing it. Conditions are grouped in a 2D array: each group is a "case" evaluated as an AND/OR chain.
When onResolveCondition is installed, the engine pre-evaluates all groups before calling onCondition. Each group in context.conditionGroups has a result (true/false) and a portIndex. The engine auto-resolves the routing — onCondition is optional and serves as a logging/override hook.
Routing modes:
- Switch mode (default): first matching group index routes the flow.
-1(no match) follows the default port. - Dispatcher mode (
enableDispatcher: true): all matching group indices fire as independent async tracks, default port is the main continuation.
context.resolve() accepts boolean (legacy), number (switch), or number[] (dispatcher).
engine.onCondition(({ block, context, next }) => {
const { conditionGroups } = context;
const isDispatcher = !!block.nativeProperties?.enableDispatcher;
// conditionGroups are pre-evaluated when onResolveCondition is installed.
// Each group has: conditions, portIndex, result (true/false/undefined).
const matched = conditionGroups
.filter((g) => g.result)
.map((g) => g.portIndex);
// switch mode: first match index or -1 (default port)
// dispatcher mode: all matched indices (each fires an async track)
const result = isDispatcher ? matched : (matched[0] ?? -1);
context.resolve(result);
next();
});engine.OnCondition(args => {
var groups = args.Context.ConditionGroups!;
var isDispatcher = args.Block.NativeProperties?.EnableDispatcher == true;
var matched = groups.Where(g => g.Result == true).Select(g => g.PortIndex).ToList();
object result = isDispatcher ? (object)matched : (object)(matched.Count > 0 ? matched[0] : -1);
args.Context.Resolve(result);
args.Next();
return null;
});engine.onCondition([](auto*, auto* block, auto* ctx, auto next) -> CleanupFn {
// Result is auto-resolved by the engine when onResolveCondition is installed.
// Override with ctx->resolve(result) if needed.
next();
return {};
});engine.on_condition(func(args):
var ctx = args["context"]
var groups = ctx.condition_groups
var np = args["block"].get("nativeProperties")
var is_dispatcher = np is Dictionary and np.get("enableDispatcher", false)
var matched = []
for g in groups:
if g.get("result", false):
matched.append(g.get("port_index", 0))
var result = matched if is_dispatcher else (matched[0] if matched.size() > 0 else -1)
ctx.resolve(result)
args["next"].call()
return Callable()
)ACTION
An action block fires side effects in the game — give an item, play a sound, set a flag. Each action references an actionId that the developer maps to their own systems. The handler executes the action list then calls context.resolve() to follow the "then" port, or context.reject(error) to follow the "catch" port (falls back to "then" if no "catch" connection exists).
engine.onAction(({ block, context, next }) => {
const { actions } = block;
game
.executeActionsList(actions)
.then(() => context.resolve())
.catch((err) => context.reject(err))
.finally(() => next());
});engine.OnAction(args => {
var (scene, block, context, next) = args;
Game.ExecuteActionsList(block.Actions, onComplete: () => {
context.Resolve();
next();
}, onError: err => {
context.Reject(err);
next();
});
return null;
});engine.onAction([&game](auto* scene, auto* block, auto* ctx, auto next) -> CleanupFn {
auto* ab = dynamic_cast<const ActionBlock*>(block);
game.executeActionsList(ab->actions, [ctx, next]() {
ctx->resolve();
next();
}, [ctx, next](const auto& err) {
ctx->reject(err);
next();
});
return {};
});engine.on_action(func(args):
var block = args["block"]
var ctx = args["context"]
var next_fn = args["next"]
await game.execute_actions_list(block.get("actions", []))
ctx.resolve()
next_fn.call()
)NOTE
A note block is a sticky note for the narrative designer — comments, reminders, context. It is automatically skipped during traversal. While it is technically possible to intercept a note block via onBeforeBlock, this is not recommended — the action block should cover all your side-effect needs.
Common Properties
All blocks share these base fields (BlueprintBlockBase):
| Field | Type | Description |
|---|---|---|
uuid | string | Unique identifier |
type | BlockType | Discriminant type |
label | string? | Human-readable name |
parentLabels | string[]? | Parent folder hierarchy from the editor |
properties | BlockProperty[] | Key-value properties |
userProperties | Record? | Free-form user properties |
nativeProperties | NativeProperties? | Execution properties |
metadata | BlockMetadata? | Display metadata (characters, tags, color) |
isStartBlock | boolean? | Marks the entry block |
NativeProperties
| 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 the assigned actor is missing |
debug | boolean? | Debug flag for the editor |
waitForBlocks | string[]? | Block UUIDs that must be visited before this block can progress |
waitInput | boolean? | Passive flag for explicit player input control |
enableDispatcher | boolean? | Condition block: all matching groups fire as async tracks |
