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
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.
How StoryBonsai compares to Ink, Yarn Spinner, and Dialogue System for Unity →