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
onBeforeBlockis called for all blocks (main and async tracks) — see Lifecycle forresolve()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.
onBeforeBlockis 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:
const tracks = scene.getTrackInfos();
for (const track of tracks) {
console.log(`Track ${track.id} (parent: ${track.parentTrackId}) at block ${track.currentBlockUuid}`);
}var tracks = scene.GetTrackInfos();
foreach (var track in tracks)
{
Console.WriteLine($"Track {track.Id} (parent: {track.ParentTrackId}) at block {track.CurrentBlockUuid}");
}auto tracks = scene->getTrackInfos();
for (const auto& track : tracks) {
std::cout << "Track " << track.id
<< " (parent: " << track.parentTrackId << ")"
<< " at block " << track.currentBlockUuid << "\n";
}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 case | Why 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 events | Use waitForBlocks to trigger a reaction when a specific block is reached |
| Play ambient sounds or music | Action block, no player interaction needed |
| Trigger camera movements | Action block, runs in parallel |
| Delayed effects | Combine waitForBlocks + delay for precise timing |
DON'T — player interaction or game logic branching:
| Use case | Why it breaks |
|---|---|
| CHOICE block in async track | The player is already interacting with the main track — who answers the async choice? |
| Critical game state changes | If 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:
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();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();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();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
