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
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 — 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()reportsUNKNOWN_WAIT_BLOCKwhen 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:
const tracks = scene.getTrackInfos();
for (const track of tracks) {
console.log(`Track ${track.id} (parent: ${track.parentTrackId}) at block ${track.currentBlockId}`);
}var tracks = scene.GetTrackInfos();
foreach (var track in tracks)
{
Console.WriteLine($"Track {track.Id} (parent: {track.ParentTrackId}) at block {track.CurrentBlockId}");
}auto tracks = scene->getTrackInfos();
for (const auto& track : tracks) {
std::cout << "Track " << track.id
<< " (parent: " << track.parentTrackId << ")"
<< " at block " << track.currentBlockId << "\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["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 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
