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 6 types: Dialog, Choice, Condition, Router, Action, and Note. Dialog, Choice, Condition and Action are content blocks with a dedicated handler (onDialog, onChoice, onCondition, onAction) — all four are required and validated when start() is called. A Router has no handler: the engine dispatches it on its own. 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 { text, props } = block;
  const { character, resolveCharacterPort } = context;
  const line = game.getLocalizedText(text);
  const emotion = game.getCharacterEmotion(character);

  character && resolveCharacterPort(character.id);

  game.moveCameraToCharacter(character);
  game.animateCharacter(character, emotion);

  const dialog = game.createDialog(line, character, emotion);

  // next() tells the engine this block is done. WHEN to call it is answered by three
  // natives, and `timeout` comes FIRST because it outranks the other two: all three
  // say when the block is left, and the card is the most specific answer.
  if (props?.timeout) {
    // MILLISECONDS, and the countdown starts when the line has been SAID — `timeout`
    // is how long it STAYS on screen afterwards. Arming it on arrival truncates any
    // line slower to reveal than the timeout allows.
    dialog.onRevealComplete(() => game.wait(props.timeout).then(() => next()));
    // A click may HURRY that reveal; it may not dismiss a line that plays its own time.
    dialog.onInput(() => dialog.skipReveal(), { once: true });
  } else if (game.shouldWaitPlayerInputForDialog(props)) {
    dialog.onInput(() => next(), { once: true });
  } else {
    dialog.onRevealComplete(() => 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.Text),
        context.Character,
        Game.GetCharacterEmotion(context.Character)
    );

    if (ch != null) context.ResolveCharacterPort(ch.Id);

    Game.MoveCameraToCharacter(ch);
    Game.AnimateCharacter(ch, emotion);

    var dialog = Game.CreateDialog(text, ch, emotion);
    var natives = block.NativeProperties;

    // next() tells the engine this block is done. Timeout comes FIRST: it outranks
    // WaitInput and it outranks leaving at once. All three say when the block is left,
    // and the one the writer put on the card is the most specific answer.
    if (natives?.Timeout > 0) {
        // MILLISECONDS, and the countdown starts when the line has been SAID — Timeout
        // is how long it STAYS on screen afterwards. Arming it on arrival truncates any
        // line slower to reveal than the timeout allows.
        dialog.OnRevealComplete(() =>
            Game.Wait(natives.Timeout.Value).Then(() => next()));
        // A click may HURRY that reveal; it may not dismiss a line playing its own time.
        dialog.OnInput(() => dialog.SkipReveal(), once: true);
    } else if (Game.ShouldWaitPlayerInputForDialog(natives)) {
        dialog.OnInput(() => next(), once: true);
    } else {
        dialog.OnRevealComplete(() => 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->text);
    auto emotion = game.getCharacterEmotion(ch);

    if (ch) ctx->resolveCharacterPort(ch->id);

    game.moveCameraToCharacter(ch);
    game.animateCharacter(ch, emotion);

    auto* dialog = game.createDialog(text, ch, emotion);
    auto natives = lsde::getNativeProperties(*block);

    // next() tells the engine this block is done. timeout comes FIRST: it outranks
    // waitInput and it outranks leaving at once. All three say when the block is left,
    // and the one the writer put on the card is the most specific answer.
    if (natives.timeout.value_or(0) > 0) {
        // MILLISECONDS, and the countdown starts when the line has been SAID - timeout
        // is how long it STAYS on screen afterwards. Arming it on arrival truncates any
        // line slower to reveal than the timeout allows.
        const auto stayMs = *natives.timeout;
        dialog->onRevealComplete([&game, next, stayMs]() {
            game.wait(stayMs).then([next]() { next(); });
        });
        // A click may HURRY that reveal; it may not dismiss a line playing its own time.
        dialog->onInput([dialog]() { dialog->skipReveal(); });
    } else if (game.shouldWaitPlayerInputForDialog(block->props)) {
        dialog->onInput([next]() { next(); });
    } else {
        dialog->onRevealComplete([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("text"))
    var emotion = game.get_character_emotion(ch)

    if ch:
        ctx.resolve_character_port(ch.get("id", ""))

    game.move_camera_to_character(ch)
    game.animate_character(ch, emotion)

    var dialog = game.create_dialog(text, ch, emotion)
    var natives = block.get("props", {})
    var stay_ms = natives.get("timeout", 0)

    # next_fn.call() tells the engine this block is done. timeout comes FIRST: it
    # outranks waitInput and it outranks leaving at once. All three say when the block
    # is left, and the one written on the card is the most specific answer.
    if stay_ms > 0:
        # A click may HURRY the reveal; it may not dismiss a line playing its own time.
        dialog.on_input(func(): dialog.skip_reveal(), true)
        # MILLISECONDS, counted from the moment the line has been SAID — stay_ms is how
        # long it REMAINS on screen afterwards, never how long it has to be said in.
        await dialog.reveal_finished
        await game.wait(stay_ms)
        next_fn.call()
    elif game.should_wait_player_input(natives):
        dialog.on_input(func(): next_fn.call(), true)
    else:
        await dialog.reveal_finished
        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.options 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(optionId) tells the engine which path to follow — the option id IS the port the flow leaves by (C1, C2…) — then next() advances the flow.

ts
engine.onChoice(({ block, context, next }) => {
  const natives = LsdeUtils.getNativeProperties(block);
  const { options, selectChoice } = context;

  // EVERY option is handed over, tagged. Filtering is your call — keeping the hidden ones lets
  // you grey them out or show "[locked]" instead of making them vanish.
  const offered = options.filter((o) => o.visible !== false);
  const dialog = game.createChoice(offered);

  // The option id IS its exit port (C1, C2…), so hand it straight back.
  dialog
    .then((selected) => selectChoice(selected))
    .finally(() => next());

  // Optional: if the writer set a timeout on this block. MILLISECONDS in v2, and a
  // CHOICE has no line to reveal, so the countdown starts once the options are readable.
  //
  // It must still SELECT one. An option id IS the exit port, so a choice left without
  // `selectChoice()` resolves to NO link at all and the branch dies silently — the
  // engine has nothing to follow. Picking the first offered option is the usual answer;
  // a `when` on it lets the writer decide which one that is.
  if (natives.timeout) {
    const timeout = game.wait(natives.timeout).then(() => {
      if (offered.length > 0) selectChoice(offered[0].id);
      next();
    });
    dialog.finally(() => timeout.cancel());
  }

  return () => dialog.destroy();
});
csharp
engine.OnChoice(args => {
    var natives = LsdeUtils.GetNativeProperties(args.Block);
    var offered = args.Context.Options
        .Where(o => o.Visible != false).ToList();

    var dialog = Game.CreateChoice(offered);

    // The option id IS its exit port (C1, C2…), so hand it straight back.
    dialog.OnSelect(selected => {
        args.Context.SelectChoice(selected);
        args.Next();
    });

    // Optional: MILLISECONDS in v2, counted from the moment the options are readable.
    // It must still SELECT one: an option id IS the exit port, so a choice left without
    // SelectChoice() resolves to NO link and the branch dies silently.
    if (natives.Timeout is { } timeout)
        Game.Wait(timeout).Then(() => {
            if (offered.Count > 0) args.Context.SelectChoice(offered[0].Id);
            args.Next();
        });

    return () => dialog.Destroy();
});
cpp
engine.onChoice([&game](auto*, auto* block, auto* ctx, auto next) -> CleanupFn {
    std::vector<const lsde::RuntimeChoiceItem*> offered;
    for (const auto& option : ctx->options())
        if (option.visible.value_or(true))
            offered.push_back(&option);

    auto* dialog = game.createChoice(offered);

    // The option id IS its exit port (C1, C2…), so hand it straight back.
    dialog->onSelect([ctx, next](const std::string& selected) {
        ctx->selectChoice(selected);
        next();
    });

    // Optional: MILLISECONDS in v2, counted from the moment the options are readable.
    // It must still SELECT one: an option id IS the exit port, so a choice left without
    // selectChoice() resolves to NO link and the branch dies silently.
    auto natives = lsde::getNativeProperties(*block);
    if (natives.timeout) {
        game.wait(*natives.timeout).then([ctx, next, offered]() {
            if (!offered.empty()) ctx->selectChoice(offered.front()->id);
            next();
        });
    }

    return [dialog]() { dialog->destroy(); };
});
gdscript
engine.on_choice(func(args):
    var ctx = args["context"]
    var natives = LsdeUtils.get_native_properties(args["block"])

    var offered = []
    for option in ctx.options:
        if option.get("visible") != false:
            offered.append(option)

    var dialog = game.create_choice(offered)

    # The option id IS its exit port (C1, C2…), so hand it straight back.
    var selected = await dialog.choice_selected
    ctx.select_choice(selected)
    args["next"].call()

    # Optional: natives.get("timeout") is in MILLISECONDS. Use a Timer node, started
    # once the options are readable. It must still SELECT one: an option id IS the exit
    # port, so a choice left without select_choice() resolves to NO link and the branch
    # dies silently.

    return func(): dialog.destroy()
)

visible is a tag, not a filter

With engine.onResolveCondition() installed, each option carries visible: true | false. Without one it is undefined — unknown, not hidden.

That distinction matters: saying "hidden" about a question nobody could answer would take an answer away from the player. Filter on visible !== false, never on visible === true.

See Choice Visibility for the full opt-in tagging system.

CONDITION ​

A condition block is an invisible switch — it reads game state and sends the flow down a path without the player seeing it.

The engine never compares anything itself. It reads no dictionary, does not know what credits holds, does not implement greaterOrEqual. It hands every test to onResolveCondition() and assembles the answers. Each test reaches the resolver exactly once, whatever the mode.

With a resolver installed the engine already knows the exit port before it calls the handler, which is what makes onCondition optional: it becomes a place to log or to override. The handler is handed context.cases, each case carrying its port and its already-computed result. To override, context.resolve(port) takes a port NAME — "out", "default", or a case port ("K1").

There are two modes, and only two:

  • portPerCase absent — every case must hold. If they all do the flow leaves by out; otherwise by default.
  • portPerCase: true — the first case that holds leaves by its own port (K1, K2…). If none holds, default.

A case with no when is always true, and makes every case below it unreachable in portPerCase mode. That is the writer's drawing, not an error to report. A block with no cases at all leaves by out: nothing was asked, so nothing failed.

default means "no case held" — not "the chosen exit has no wire". A port with no wire ends the flow, which is a legitimate ending.

A test whose dictionary is the reserved word choice reads an answer the player already gave: { dict: "choice", entry: "CHOICE-001", value: "C1" }. The engine answers it itself, from the scene's history — the question never reaches the game. See also scene.getChoice(blockId) and scene.evaluateCondition(test).

ts
// Optional once onResolveCondition is installed: the engine has already evaluated every case and
// already knows which port the flow leaves by. This handler is where you log it, or override it.
engine.onCondition(({ block, context, next }) => {
  // Each case carries its own exit port and its pre-evaluated result.
  const matched = context.cases.filter((c) => c.result).map((c) => c.port);
  console.log(`${block.id} matched:`, matched.join(', ') || 'none → default');

  // Override the routing with a PORT NAME: 'out', 'default', or a case port like 'K2'.
  // context.resolve('default');

  next();
});
csharp
engine.OnCondition(args => {
    var matched = args.Context.Cases
        .Where(c => c.Result == true)
        .Select(c => c.Port)
        .ToList();
    Console.WriteLine($"{args.Block.Id} matched: {string.Join(", ", matched)}");

    // args.Context.Resolve(Ports.Default);   // override with a PORT NAME

    args.Next();
    return null;
});
cpp
engine.onCondition([](auto*, auto* block, auto* ctx, auto next) -> CleanupFn {
    for (const auto& c : ctx->cases()) {
        std::cout << c.port << "=" << c.result.value_or(false) << " ";
    }

    // ctx->resolve(Ports::Default);   // override with a PORT NAME

    next();
    return {};
});
gdscript
engine.on_condition(func(args):
    var ctx = args["context"]
    var matched = []
    for c in ctx.cases:
        if c["result"]:
            matched.append(c["port"])
    print("%s matched: %s" % [args["block"]["id"], ", ".join(matched)])

    # ctx.resolve(LsdeTypes.PORT_DEFAULT)   # override with a PORT NAME

    args["next"].call()
    return Callable()
)

Two modes, and only two

portPerCase absent — every case must hold: the flow leaves by out if they all do, default otherwise. portPerCase: true — the first case that holds takes its own port (K1, K2…), default when none does.

A case with no when is always true, and makes every case below it unreachable.

The dispatcher mode is gone. It fired every matching case at once, in parallel, which meant a writer read three wires leaving a condition as a choice when it was three simultaneous launches. Use isAsync on the target blocks instead — where a reader of the graph can see it.

ROUTER ​

A router carries the same cases as a condition and reads them the opposite way. A condition asks which one holds and leaves by a single port; a router asks which ones: it evaluates every case, launches the port of each true one, and then always continues — by then when all of them held, by catch when any did not. A router with no case at all leaves by then, the way Promise.all([]) resolves.

Its K* routes are walked like any other port: an isAsync target opens its own track, the others are walked in turn, and the continuation comes last. catch cancels nothing — the tracks of the true cases are already running.

There is no onRouter handler and start() requires none: the engine dispatches a router on its own. To observe one, use handle.onBlock(id).

ts
import type { RouterContext } from '@lsde/dialog-engine';

// A ROUTER has no handler of its own: the engine evaluates EVERY case, launches the port of each
// true one and picks `then` or `catch` before a handler could speak. To WATCH one, target it by id.
handle.onBlock('ROUTER-001', ({ block, context, next }) => {
  const { cases } = context as RouterContext;
  const launched = cases.filter((c) => c.result).map((c) => c.port);
  console.log(`${block.id} launched:`, launched.join(', ') || 'nothing');

  // Nothing to resolve: the exits are a tally, not a choice.
  next();
});
csharp
handle.OnBlock("ROUTER-001", args => {
    var cases = ((IRouterContext)args.Context).Cases;
    var launched = cases.Where(c => c.Result == true).Select(c => c.Port).ToList();
    Console.WriteLine($"{args.Block.Id} launched: {string.Join(", ", launched)}");

    // Nothing to resolve: the exits are a tally, not a choice.
    args.Next();
    return null;
});
cpp
handle->onBlock("ROUTER-001", [](auto*, auto* block, IBaseBlockContext* ctx, auto next) -> CleanupFn {
    auto* router = dynamic_cast<IRouterContext*>(ctx);
    for (const auto& c : router->cases()) {
        if (c.result.value_or(false)) std::cout << block->id << " launched " << c.port << "\n";
    }

    // Nothing to resolve: the exits are a tally, not a choice.
    next();
    return {};
});
gdscript
handle.on_block("ROUTER-001", func(args):
    var ctx = args["context"]  # an LsdeBlockContext.RouterContext
    var launched = []
    for c in ctx.cases:
        if c["result"]:
            launched.append(c["port"])
    print("%s launched: %s" % [args["block"]["id"], ", ".join(launched)])

    # Nothing to resolve: the exits are a tally, not a choice.
    args["next"].call()
    return Callable()
)

A tally, not a choice

A condition asks which one and leaves by a single port. A router asks which ones: every case is evaluated — a false one in the middle hides nothing after it — each true case launches its port, and the flow then continues by then when they all held, by catch when any did not. No case at all is then, the way Promise.all([]) resolves.

The K* routes are walked like any other port: an isAsync target opens its own track, the others are this track's, in turn, and the continuation comes last — which is what keeps then/catch as the main flow when the routes are async. catch cancels nothing: the tracks of the true cases are already running.

See The Router Block for the full contract and diagrams, and Distributing characters for inPortPerCharacter — the entry port a router's routes typically name.

ACTION ​

An action block fires side effects in the game — give an item, play a sound, set a flag. context.calls carries the calls: each cites the fn of a declared function, and its args arrive by name, never by position. The handler executes them then calls context.resolve() to follow the then port, or context.reject() to follow the catch port — and when the designer wired no catch, the flow carries on through then rather than stranding the player.

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
idstringIdentity relative to its scene — DIALOG-002. Ids repeat across scenes.
keystringThe full i18n key, as the localization files carry it
typeBlockTypedialog, choice, condition, router, action or note
labelstring?Readable name, when the writer set one
parentLabelsstring[]?Parent folder hierarchy from the editor
notestring?The writer's own note
actorsstring[]?The card ids the block cites, in file order
emotionstring?The emotion's card id — it belongs to the block, not to each actor
intensitynumber?How strongly, for that emotion
textTextByLocale?The text per locale, when the export is inline
propsPropertyBag?One bag: the natives and the writer's own properties, by bare id
optionsOption[]?CHOICE only
casesConditionCase[]?CONDITION and ROUTER — the same data, read in opposite ways
callsActionCall[]?ACTION only
nextLink[]?The block's outgoing wires. There is no connection table in v2

The entry block is not flagged on the block: the scene names it, in scene.start. A scene therefore cannot declare two of them.

NativeProperties ​

The ten properties the engine reads, taken out of props. Ids cannot collide — LSDE refuses a project property that takes a native name — so telling them apart is a plain lookup.

FieldTypeDescription
isAsyncboolean?Opens a parallel track on this block instead of continuing the current one
waitForBlocksstring[]?Block ids of this scene. The block is held before it is dispatched until every one of them has finished — no handler is called
delaynumber?MILLISECONDS before the block plays. Applied by onBeforeBlock, never by the engine
timeoutnumber?MILLISECONDS the block STAYS after its line has been said, then it leaves on its own — an auto-advance for blocks. Outranks waitInput. Passed through; the engine enforces nothing
waitInputboolean?Wait for player input. Passed through, never interpreted — outranked by timeout
debugboolean?Debug flag for the editor. Passed through
portPerCharacterboolean?The block leaves by a port named by the actor's card id, instead of out
inPortPerCharacterboolean?The wire names the actor: a link's toPort is a card id, and only that actor is offered to onResolveCharacter. See Distributing characters
skipIfMissingActorboolean?Passed through — the game decides
portPerCaseboolean?CONDITION: each case leaves by its own port (K1…) instead of sharing out

delay and timeout are MILLISECONDS in v2

They were seconds in v1, and nothing reports the change at runtime: a migrated project turns a 3-second pause into 3 ms.

timeout is an auto-advance for blocks — count from the END of the line

The countdown starts when the line has been said, not when the block arrived. What the writer sets is how long it STAYS on screen after its last character is typed (or its last syllable spoken); then the block leaves on its own.

Counting from arrival is the mistake that reads naturally and plays wrong: 2500 ms on a 120-character line truncates it mid-sentence.

It outranks waitInput, and it outranks leaving immediately. All three say WHEN the block is left, and the one the writer put on the card is the most specific answer. So a click may only hurry the reveal, never dismiss the block — pressing a line that plays its own time makes no sense, speeding it up does, and the hurried reveal is what arms the countdown.

And since leaving a block is what marks it finished, a timeout is also what releases a waitForBlocks that names it.

Only three of these change anything about the traversal: isAsync, waitForBlocks and inPortPerCharacter. The other seven are handed to the game untouched.