For developers
A .NET Standard 2.1 runtime built for maximum compatibility.
Your game owns the loop. The engine hands you data, you decide what to do with it.
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.