Skip to content

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.

ts
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);
  };
});
csharp
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);
    };
});
cpp
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);
    };
});
gdscript
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.

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

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

ts
engine.onAction(({ block, context, next }) => {
  const { actions } = block;
  game
    .executeActionsList(actions)
    .then(() => context.resolve())
    .catch((err) => context.reject(err))
    .finally(() => next());
});
csharp
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;
});
cpp
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 {};
});
gdscript
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):

FieldTypeDescription
uuidstringUnique identifier
typeBlockTypeDiscriminant type
labelstring?Human-readable name
parentLabelsstring[]?Parent folder hierarchy from the editor
propertiesBlockProperty[]Key-value properties
userPropertiesRecord?Free-form user properties
nativePropertiesNativeProperties?Execution properties
metadataBlockMetadata?Display metadata (characters, tags, color)
isStartBlockboolean?Marks the entry block

NativeProperties

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 the assigned actor is missing
debugboolean?Debug flag for the editor
waitForBlocksstring[]?Block UUIDs that must be visited before this block can progress
waitInputboolean?Passive flag for explicit player input control
enableDispatcherboolean?Condition block: all matching groups fire as async tracks