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. Boot with runtime.Start() or runtime.StartScene(sceneId) to begin at a specific scene.

runtime.Start();

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, EnterSceneEvent, LocationChangedEvent, AdvanceTimeEvent, SkillCheckResolvedEvent. 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

PeekMedia() returns the deduplicated video and audio assets that would play across every currently visible choice, without advancing state. Prewarm videos, load audio, decode images. Zero-latency transitions.

var peek = runtime.PeekMedia();

foreach (var v in peek.Video) media.Prewarm(v.File);
foreach (var a in peek.Audio) media.PrewarmAudio(a.File);

Building a narrative game? StoryBonsai drops in.

Request Access →

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

Saves survive content patches. Tweak a condition, retune a balance number, add a new node, and old saves still load. Two equivalent APIs power this: CaptureSave() returns a typed ReplaySave DTO for studios with their own serializer; SaveToJson() returns a string for studios that want to drop a save into a slot. Both are replay-based. Recovery modes (best-effort or transactional) and resolution policies (lenient or strict) give you the durability story that matches your save UX.

// DTO — hand straight to your existing serializer
var save = runtime.CaptureSave();
playerSave.Write(save);

var loaded = playerSave.Read<ReplaySave>();
runtime.RestoreSave(loaded);

// Or use the JSON convenience API
var json = runtime.SaveToJson();
runtime.LoadFromJson(json);

CLI for CI

storybonsai validate checks every expression and reference in your project. storybonsai pathtest runs randomised playthroughs with configurable seed and strategy. storybonsai test runs the project's expression and assertion tests. All three exit non-zero 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. Built into the runtime.

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. One DLL, zero engine dependencies. No plugins to install, no sidecar processes, no extra runtimes to manage.

Request Access →

Screenshot viewer