Skip to content

Async Tracks

When a block has nativeProperties.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 — Track Synchronization

Use nativeProperties.waitForBlocks to synchronize parallel tracks. It accepts an array of block UUIDs that must be visited before the block can proceed:

  • On the start block: The entire track waits before even beginning execution. onBeforeBlock is not called until all required blocks are visited.
  • On any other block: When the handler calls next(), the advance is deferred until the condition is met.

The full execution sequence with delay and waitForBlocks:

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

waitInput — Player Input Flag

nativeProperties.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.currentBlockUuid}`);
}
csharp
var tracks = scene.GetTrackInfos();
foreach (var track in tracks)
{
    Console.WriteLine($"Track {track.Id} (parent: {track.ParentTrackId}) at block {track.CurrentBlockUuid}");
}
cpp
auto tracks = scene->getTrackInfos();
for (const auto& track : tracks) {
    std::cout << "Track " << track.id
              << " (parent: " << track.parentTrackId << ")"
              << " at block " << track.currentBlockUuid << "\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["currentBlockUuid"]])

Each TrackInfo contains: id, parentTrackId, startBlockUuid, currentBlockUuid, 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