Pull-based API

No callback wiring, no inheritance, just a loop your game drives: pull events, present choices, select one. 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, AudioCueEvent, StopAudioEvent, 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;
    }
}

An event-driven world layer

When no scene is active, GetEncounters() returns what the player can do at their current location, each with a display name and a typed trigger (character, item, or the place itself) so your world UI knows what indicator to draw. StartEncounter() begins the staged scene; LeaveScene() backs out of a non-modal one. The runtime tracks the location graph, gated connections, and time; your game renders the world. A Refreshed event fires after every state change, so your UI rebuilds on events instead of polling every frame. The bundled Unity sample ships this exact pattern.

// Subscribe once; no per-frame polling
runtime.Refreshed += RefreshWorldUi;

void RefreshWorldUi() {
    foreach (var enc in runtime.GetEncounters().Encounters) {
        worldUi.ShowAffordance(enc.DisplayName, enc.Trigger);
    }
}

// Player clicks an affordance:
runtime.StartEncounter(encounter);

// ...and backs out of a non-modal scene:
runtime.LeaveScene();

Building a narrative game? StoryBonsai drops in.

Request Access →

Your systems, their story

Flag a variable as externally synced and register a getter. The story reads live game state (health from combat, gold from your economy) while the runtime blocks story-side writes that would fight your systems for ownership. Writers gate content on values your game computes, with no glue code per variable.

// Your game owns the value; the story reads it live
runtime.SyncVariable("var_health", () => player.Health);
runtime.SyncVariable("var_gold", () => economy.Gold);

// Conditions like `var_health > 25` now evaluate
// against your systems on every read.

Localization at runtime

Projects ship with a source locale and any number of target overlays. SetActiveLocale() switches mid-session; every string the runtime returns (dialogue, captions, choice titles) resolves against the active overlay with fallback to source text. ICU plurals and selects evaluate per locale, and a LocaleChanged event tells your UI to refresh.

foreach (var locale in runtime.AvailableLocales) {
    settingsMenu.Add(locale.Code, locale.Name);
}

runtime.SetActiveLocale("fr");
// Every string the runtime hands you now resolves against
// the French overlay, falling back to source text where
// no translation exists.

Audio routed by layer

Audio cues arrive as typed events carrying their layer (music, ambient, one-shot, or a custom bus you define) and their segments, each with selection and loop rules the writer authored. Your game maps layers to mixer buses and plays the files; the runtime decides when cues start and which stop event ends them. Locations bundle ambient cues into named moods your game cross-fades between.

case AudioCueEvent cue:
    audio.Play(cue.Layer, cue.Segments);
    break;

case StopAudioEvent stop:
    audio.StopLayer(stop.Layer);
    break;

Preload what plays next

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);

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 (every 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);
    }
}

Activities pause the story for you

When an activity event fires, the runtime pauses the commit: nothing downstream runs until your minigame, QTE, or form calls Complete(). Set each return value with SetField(); constraints validate on set, mandatory fields on commit, and grouped boolean fields enforce their selection bounds at completion, so the writer's branching logic never needs defensive checks. Completions replay deterministically through save and restore.

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;

How do saves survive content patches?

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, so play history, stateful expression slots, and pending activities all come back. Recovery modes (best-effort or transactional) and resolution policies (lenient or strict) give you the durability story that matches your save UX.

// A DTO you 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);

How does StoryBonsai run in CI?

storybonsai validate checks every expression and reference in your project. storybonsai pathtest runs randomized playthroughs with configurable seed and strategy. storybonsai test runs the project's expression tests. All three exit non-zero on failure. Pipe --format json into dashboards or Slack bots, or archive it with the build.

One file per entity

Every node, scene, variable, and location is its own JSON file. Small, focused diffs and merge-conflict-free collaboration between writers. Locale overlays mirror the same layout.

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
  • Any .NET-capable engine via the .NET Standard 2.1 target

Plain .NET assemblies, zero engine dependencies. No plugins to install, no sidecar processes, no extra runtimes to manage.

Request Access →

Screenshot viewer