Pull-based API

The runtime returns data. Your game decides what to do with it. No callback wiring, no event handlers — just a loop the game drives.

while (true) {
    foreach (var e in runtime.GetEventQueue()) {
        HandleEvent(e);
    }

    var choices = runtime.GetChoices();
    if (choices.IsComplete) break;

    var picked = await ui.PresentChoices(choices.Choices);
    runtime.SelectChoice(picked.Uuid);
}

Typed events

Polymorphic C# event objects, not untyped dictionaries. SequenceEvent, HookEvent, ActivityEvent, VariableChangedEvent, ActChangedEvent. Pattern-match them. hook.ParamsAs<T>() gives type-safe access to custom parameters.

foreach (var e in runtime.GetEventQueue()) {
    switch (e) {
        case SequenceEvent seq:
            await media.Play(seq.File);
            break;
        case HookEvent hook when hook.Name == "show_meter":
            var p = hook.ParamsAs<ShowMeterParams>();
            ui.ShowMeter(p.Value, p.Min, p.Max);
            break;
        case VariableChangedEvent v:
            ui.UpdateVariable(v.Key, v.NewValue, v.Min, v.Max);
            break;
    }
}

Preload the next content

PeekSequences() tells you, for every currently visible choice, which media would play first if picked — without advancing state. Prewarm videos, load audio, decode images. Zero-latency transitions.

foreach (var peek in runtime.PeekSequences()) {
    if (peek.Type == MediaItemType.Video) {
        media.Prewarm(peek.File);
    }
}

Video annotations and story beats

GetStoryBeats() returns beats from media the player has actually seen, in play order, with importance that decays the further you move past each beat (each beat sets its own decay value). Build "previously on…" recaps, character-aware ambience, or analytics — from data already in your story.

var beats = runtime.GetStoryBeats();

foreach (var beat in beats) {
    if (beat.Importance >= StoryBeatImportance.Major) {
        recapBuilder.Add(beat.MediaId, beat.At);
    }
}

Activity system

Delegate control to the game for minigames, puzzles, or QTEs. Typed variable wiring with constraints and modifiers. Set each return value via SetField() and call Complete() — constraints are validated on set, mandatory fields on commit.

case ActivityEvent activity:
    var result = await minigame.Run(activity.Name, activity.Fields);

    foreach (var field in result.Values) {
        activity.SetField(field.Id, field.Value);
    }
    activity.Complete();
    break;

Save and restore

Opaque save state. SaveToJson() gives you a string; LoadFromJson(save) puts everything back — play history included, so story-beat recall survives reloads. Your save system doesn't need to know about conversation internals.

var save = runtime.SaveToJson();
playerSave.Write(save);

// later…
var loaded = playerSave.Read();
runtime.LoadFromJson(loaded);

CLI for CI

storybonsai validate checks every expression and reference in your project. storybonsai pathtest runs randomised playthroughs with configurable seed and strategy. Non-zero exit on failure. Pipe --format json into dashboards, Slack bots, or archive it with the build.

One file per node

Every node is its own JSON file. Small, focused diffs. Merge-conflict-free collaboration between writers.

Single-player or multiplayer. Native from day one.

Characters are tagged with a player ID, so each visible choice routes to the right player automatically. SelectChoices(choiceA, choiceB) submits every player's pick at once and the runtime applies them in a single pass. Sequences and activities also carry player IDs, so media and input reach the right screen. Network transport is your call — the runtime is network-agnostic.

var p1 = await ui.PresentChoices(choicesForP1);
var p2 = await ui.PresentChoices(choicesForP2);

runtime.SelectChoices(p1.Uuid, p2.Uuid);

Platform support

Unity, Godot (.NET), MonoGame. .NET Standard 2.1 for maximum compatibility with any .NET-capable engine.

Request Access →