Skip to content

Async Tracks ​

When a block has props.isAsync = true, the engine creates a parallel track that runs independently of the main flow.

How Tracks are Created ​

During port resolution, if multiple outgoing connections exist:

  • The first non-async connection becomes the continuation of the current flow
  • The other connections (to blocks with isAsync) become new parallel tracks

This applies to both the main track and async tracks — an async track can spawn sub-tracks from its own outgoing async connections, creating a hierarchy of parallel execution.

Track Lifecycle ​

  • onBeforeBlock is called for all blocks (main and async tracks) — see Lifecycle for resolve() details
  • Async tracks separate outgoing connections into main vs async, just like the main track
  • Tracks are automatically cancelled when the scene ends or cancel() is called
  • When a track finishes naturally (no more connections), its sub-tracks continue to live independently
  • When a track is explicitly cancelled (cancel()), the cancellation cascades to all child tracks

waitForBlocks — waiting for another track ​

props.waitForBlocks holds a block until the blocks it names have finished. It is the join half of the fork isAsync opens: a branch runs in parallel, and a block downstream waits for it to be over before it plays.

Finished, not reached. A listed block counts once the flow has left it: your game called next(), the exit port was resolved, and the block's cleanup has run. So the bubble is off the screen and the audio voice is stopped before the joining line is dispatched — which is the whole point of drawing a join.

A rule that changed

In the first 2.x releases, being reached was enough. That made the property nearly inert in the shape designers actually draw: a fork into two blocks, then a join on both, lifted in the very tick it was registered — the two had been dispatched a fraction of a millisecond earlier — and the joining line spoke over them.

getVisitedBlocks() is unaffected: it still lists what the player has been shown, which includes a block still mid-sentence.

It holds the block BEFORE the block is dispatched. No handler is called, so your game never learns the block exists until the wait lifts — nothing of it can reach the screen early. That is the engine's decision and not a rendering choice you could make differently: waitForBlocks is a native property, the designer ticks it in LSDE, and the engine owes them the behaviour.

The rule is the same on every track, the one the player is watching included.

  • The ids name blocks of this scene. A wire has never crossed a scene boundary.
  • All of them must have finished, not just one.
  • Finishing a block releases everything waiting on it, in turn.
  • A block that never finishes parks its track for good — and a block waiting on player input forever never finishes. init() reports UNKNOWN_WAIT_BLOCK when an id is not a block of the scene at all, but it cannot know whether a real one will ever be played.

The sequence for a block carrying both waitForBlocks and delay:

waitForBlocks gate → onBeforeBlock (delay) → handler → next()

waitInput — Player Input Flag ​

props.waitInput is a passive flag — the engine exposes it but does not interpret it. Your game handler reads it to decide whether to wait for explicit player input (e.g., a second controller, a custom event, or an NPC auto-selection).

TrackInfo API — Observability ​

Use scene.getTrackInfos() to inspect running async tracks. Returns a read-only snapshot of each track's state:

ts
const tracks = scene.getTrackInfos();
for (const track of tracks) {
  console.log(`Track ${track.id} (parent: ${track.parentTrackId}) at block ${track.currentBlockId}`);
}
csharp
var tracks = scene.GetTrackInfos();
foreach (var track in tracks)
{
    Console.WriteLine($"Track {track.Id} (parent: {track.ParentTrackId}) at block {track.CurrentBlockId}");
}
cpp
auto tracks = scene->getTrackInfos();
for (const auto& track : tracks) {
    std::cout << "Track " << track.id
              << " (parent: " << track.parentTrackId << ")"
              << " at block " << track.currentBlockId << "\n";
}
gdscript
var tracks = scene.get_track_infos()
for track in tracks:
    print("Track %d (parent: %s) at block %s" % [
        track["id"], str(track["parentTrackId"]), track["currentBlockId"]])

Each TrackInfo contains: id, parentTrackId, startBlockId, currentBlockId, running. Use this for debug overlays, play-mode renderers, or validation.

What Works in Async Tracks (and What Doesn't) ​

Async tracks are great for things that happen alongside the main conversation — ambient effects, parallel animations, companion reactions. But they have limits.

DO — parallel content:

Use caseWhy it works
NPC ambient dialogue ("barks")Dialog blocks on an async track — NPCs comment, react, or banter while the main conversation continues
Character reactions synced to eventsUse waitForBlocks to trigger a reaction when a specific block is reached
Play ambient sounds or musicAction block, no player interaction needed
Trigger camera movementsAction block, runs in parallel
Delayed effectsCombine waitForBlocks + delay for precise timing

DON'T — player interaction or game logic branching:

Use caseWhy it breaks
CHOICE block in async trackThe player is already interacting with the main track — who answers the async choice?
Critical game state changesIf the async track is cancelled (scene ends), the action never executes

Choices in async tracks

A CHOICE block in an async track implies the player should make a selection while already engaged with the main dialogue. The most common scenario is an AI-driven "choice" (e.g., a companion NPC auto-selects based on personality). If an async track hits a CHOICE block without a scene-level handler that auto-selects, the flow will stall or end silently.

Multiple Scenes in Parallel ​

The engine supports running multiple scenes simultaneously. Each SceneHandle has its own state, visited blocks, and async tracks. Global handlers (Tier 1) are shared — use the scene argument to know which scene is calling:

ts
engine.onDialog(({ scene, block, context, next }) => {
  if (scene === mainDialogue) {
    showMainUI(block);
    // next() called later — by player input
  } else if (scene === tutorialOverlay) {
    showTutorialBubble(block);
    // auto-advance after display
    next();
  }
});

const mainDialogue = engine.scene('main-quest');
const tutorialOverlay = engine.scene('tutorial-hints');
mainDialogue.start();
tutorialOverlay.start();
csharp
engine.OnDialog(args => {
    if (args.Scene == mainDialogue)
    {
        ShowMainUI(args.Block);
        // next() called later — by player input
    }
    else if (args.Scene == tutorialOverlay)
    {
        ShowTutorialBubble(args.Block);
        // auto-advance after display
        args.Next();
    }
    return null;
});

var mainDialogue = engine.Scene("main-quest");
var tutorialOverlay = engine.Scene("tutorial-hints");
mainDialogue.Start();
tutorialOverlay.Start();
cpp
engine.onDialog([&](auto* scene, auto* block, auto*, auto next) -> CleanupFn {
    if (scene == mainDialogue.get()) {
        showMainUI(block);
        // next() called later — by player input
    } else if (scene == tutorialOverlay.get()) {
        showTutorialBubble(block);
        // auto-advance after display
        next();
    }
    return {};
});

auto mainDialogue = engine.scene("main-quest");
auto tutorialOverlay = engine.scene("tutorial-hints");
mainDialogue->start();
tutorialOverlay->start();
gdscript
engine.on_dialog(func(args):
    if args["scene"] == main_dialogue:
        show_main_ui(args["block"])
        # next() called later — by player input
    elif args["scene"] == tutorial_overlay:
        show_tutorial_bubble(args["block"])
        # auto-advance after display
        args["next"].call()
    return Callable()
)

var main_dialogue = engine.scene("main-quest")
var tutorial_overlay = engine.scene("tutorial-hints")
main_dialogue.start()
tutorial_overlay.start()

Routing by scene

For many concurrent scenes, consider registering scene-level (Tier 2) handlers on each handle instead of routing in the global handler. Cleaner separation, no if/else chains.

Visual Reference ​

  • Main track: A → B → C
  • Track 1 (parallel): D → E
  • Track 2 (sub-track of D): F
  • Scene cancel → all tracks cancelled
  • Track D ends naturally → F continues