Architecture of the storytelling engine
This is a preliminary design as of now.1
In short: StoryManager is the Bevy Resource which:
- loads and saves user data (game save),
- and story data (game content),
- keeps a secondary index of every story related object,
- stores the list of current story beat’s assets actively used (music, backgrounds, characters on screen)
- since we can “nest” story beats into each other, this will push/pop those onto a stack
- and stores the current progress.
Everything else is a Bevy Component. This allows gradual development of new features without having to refactor a large unwieldy struct/enum/system.
Some objects might reference assets (character thumbnail, bustup, audio). Likely just a String with the file path, and not another layer of abstraction, although that might change to offer either - String for small games with limited assets (most VNs, JRPG), indirect reference for complex games that need to manage assets in different complex ways (3D CRPG/JRPG, etc).
Every object has a custom newtype ID - u64. During early development, it’s hardcoded in Rust code. Maybe stored as RON files. Once editor is available, it will be created and maintained by the editor and unique to the story, i.e. once assigned ID will never be reused, to prevent save corruption on game update. Future format will likely be BSN2, esp. once Bevy 0.20 lands (Q3/Q4 2026).
How most of story-related data is handled: All story data is loaded in memory and stored in Bevy ECS. There is no current plan to add i/o streaming; it might come later in theory, as long as we store all IDs, which shouldn’t take more than a few dozen MB RAM even with millions of objects (text lines, dialogue options, conditions, consequences, characters, animations, sound cues, etc).
(Media assets might get loaded or unloaded dynamically, that’s probably easier to do, and saves more RAM than worrying about text content.)
These story objects serve dual purpose of being a story storage, and once they’re activated, gain more Components like Sprite, Transform, Tween, etc, which are then removed once they fill their purpose.
A step by step example
Summary:
- game triggers Story Beat
- StoryManager and a few core systems handle logistics
- if there are Conditions associated, during the
Lastschedule, queue Elements up for evaluation - on the next frame, run the business rule engine during the
Firstschedule - in
PreUpdate, process results, do init for the newly shows Elements (e.g. spawn sprites) - during
Update, keep processing them for as long as necessary (e.g. animating a sprite) - If conditions fail, jump to another node if one is provided (NextElement)
- After story Elements complete (animation finishes, player clicks an option), jump to different Element, rinse, repeat
In a lot more words:
The work is starting with Bevy’s First schedule (i.e. before PreUpdate,
SceneTransition, Update, PostUpdate) so that things can be evaluated
in the same frame by the time Update schedule starts.
The game must keep track of conditions necessary to trigger a story somehow. The exact membrane doesn’t have a clear API yet, and the current idea is that
- A) for small projects, this can be somewhat hardcoded, which is my use case now,
so game system can just say “trigger story beat 123” (using
ResMut<StoryManager>) - B) still for small projects, there will be Triggers, e.g. a game system sends
a Trigger Message like “Meeting Character John Doe”, potentially validate other
conditions (is in location X, etc) and the handler will convert that into Story Beat Id
- the game would do that in the previous frame during Update or PostUpdate
- in First, the StoryManager checks if there are any Triggers
- if there are any Conditions associated with it, add them to the queue by sending them as Messages
- which will be processed in PreUpdate as described below
- C) As the project matures, and as Bevy gets a first party editor, there will be a plugin for the editor that will link game events to Story Beats (main container for small chunks of story).
Once either of the above finishes, we continue during First schedule:
-
a Story Beat Id will be started by StoryManager.
- StoryManager will set something like current_story_beat_id = 1
- StoryManager will record this to the history of visited nodes (hashmap most likely)
- ?maybe? record it in a list of in-progress events
-
StoryBeat will contain a link to the initial StoryElement
-
we check for the ConditionLink associated with this StoryElement (1:n connection, the same conditions can be reused later)
- Because we’ve started in the
FirstSchedule, have time to evaluate things - Send ConditionLinks as Messages, which comprise ConditionEffect and Condition itself
- Effect is an enum used for grouping, e.g. “is this (dialogue) option visible”,
“is this (dialogue) option greyed out”, “do we skip this (story beat)”
etc - each Effect one if/then branch, so to speak
- Since Conditions can apply to any part of Story, I probably won’t bother making sure that every ConditionEffect->target object combination is valid
- Condition is a singular clause of the imagined “if” statement, i.e. is player health above 50%, is story variable IS_SAD true, etc
- Because we’ve started in the
-
In Bevy, Story Element entity will have two or more components: mandatory StoryElementId and one or more of different StoryElement structs,
- e.g. CharacterAnimationElement will move a sprite on screen (e.g. “enter screen left”)
- in the future, the story engine will provide default canonical implementation of handling each StoryElement
- but they’ll be each opt-in/out and fully programmable to suit game’s GUI
- one handler can handle as many different aspects as it’s practical, but often just 1
- they’re just Bevy systems
- initial set up of Elements is done via a Message, it’s continuation is done via With
-
Started this 2026-08-17, last update 2026-08-28 ↩
-
Bevy Scene Notation. Introduced in 0.19, will be made a lot more usable in 0.20 when they add a native loader, ETA Q4/2026. ↩