Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Blog of sorts to sort out my thoughts

See the navigation on the left.

Non-technical

You can listen to my music on Deezer or Spotify.

Storytelling Engine

Yeah, I don’t have a better name.

This is the preliminary outline for a Rust / Bevy based, data-driven ‘engine’ which I hope will be suitable for handling basic visual novel stuff, but also be flexible enough to serve a wide variety of purposes and games, hypothetically even complex CRPG dialog trees and open world exploration type things.

My thesis right now is that writing the engine will be relatively easy (i.e. there will be no Hard or Cursed1 problems, but not that it’s going to be done in an afternoon), but making an editor which would be easy to learn, pleasant to use, and also sufficiently fool-proof will be the harder part.

Design goals - beginning (prototype Q3/2026)

My goals when I started this are to fit my purpose only, but to be flexible enough in design/architecture to be worth upgrading and making universal enough.

  • no scripting engine
  • no DSL
  • avoid writing BRE as long as possible (failed)
  • Rust, Bevy native, data-driven
  • tremendous performance, no runtime reflection other than what Bevy is doing already
  • default dialogue runner GUI, even if an ugly one (perhaps egui at first)
  • avoid messy string key-value storage for story variables as much as possible
  • coding custom game features with normal Bevy code is good and expected (e.g. custom in-game reward for a story dialog option that’s not just a flag/variable)
  • support for Conditions and Consequences, e.g.
    • hide this dialogue option when energy is low
    • grey out this dialogue option when strength < 6
    • ask this question and get an answer only for the first time
    • show different question and answer every other time
    • allow for random-pool answers
  • misc Consequence features I want to have
    • straightforward dialogue
    • sound cue and music change
    • sprite animation (“john doe bustup enter screen left”)
    • allow for Tracery format sentences
  • everything relevant has custom IDs
  • mapping domain IDs to Entity id with secondary index
  • no storage format at first; Rust only, always-recompile is fine for now
    • compile time correctness if possible
  • make sure design allows for procedurally added story elements

Not in scope (yet):

  • fully featured GUI for everything
  • pretty design
  • voice narration, lip-syncing, progressive text typing
  • easy CRPG dialog trees, although technically possible
  • scripting
  • articy, renpy, yarn spinner, twine import/export of any kind
  • correctness checks for story itself à la https://www.yarnspinner.dev/storysolver

Design goals - hypothetical mature stage (2 years of work)

  • provides stock egui or bevy_ui implementation, but fully replaceable
  • reasonably easily pluggable into any Bevy game
  • stable and reliable save format for the whole story
    • track discarded IDs so that deleting and adding a character, and then loading a game doesn’t corrupt data
  • stable and reliable save format for the game state
  • 90% feature parity with Ren’Py and Yarn Spinner
  • compatibility or one-way migration tools from Ren’Py and Yarn Spinner
  • incremental sync with Articy exports (i.e. adding/removing/renaming characters won’t break everything)
  • hot reload option in debug builds
  • allow for different pre-defined modalities - DnD, SPECIAL, Scarlet Hollow (Copyright allowing!)
  • have a running, fun, non-theoretical, non-trivial example of procgen dynamic storytelling
    • i.e. a game prototype where player actions can spawn dynamic story elements that might be a little silly and formulaic, but are still better than whatever Skyrim 18th re-release is doing these days
    • no LLMs anywhere in the process
  • implement branched quest lines
  • synthetic benchmark performance:
    • 1000 chapters, 10k characters, 100k story beats,
    • 1M story elements, 5M conditions and 5M consequences
    • 20M story variables (mix of global and per chapter and per character)
    • response time for any given story element <1ms

Design goals - open source (2-3 years of work)

  • Ideally would have a WASM editor which would allow to create stories and export into a stable+versioned format
  • optional human-readable export format (BSN possibly?)
  • non-cloud browser only storage
  • maybe 3rd party storage integration (google drive .zip upload/download)
  • keep “enterprise” features out of open source because I need to pay rent

Design goals - commercial product (2-5 years of work)

  • wasm editor above, but more powerful?
  • definitely an image editor at least for simple drafts
  • publish stories them into a “cloud” for very cheap (free tier? free play for small chapters? storage caps?)
  • and generate on demand finished games for android/win/linux and maybe if $$ then mac/ios if feasible at all
  • desktop Editor should have powerful integration with Rust codebase, i.e. the ability to track enums and systems and offer these from drop down menus as different Conditions and Consequences
  • tablets with physical keyboards should provide a reasonably productive platform to author stories on.

Secret stretch goals mostly for myself

While not core to the project, I’d like to see if this engine can be actually used to run simulations, the ‘dream’ goal being 12000 NPCs being able to move around and make decision based on their individual situations. Story Elements would not be “show GUI or play sound” but instead just empty containers grouping Conditions and Consequences which would change their relationships, location, resources, etc.

In a way it could ??? replace IAUS for some things? I guess maybe even completely if Conditions return floats, but I don’t think I want to do that in the near future.

Design goals - megalomaniac (5+ years)

  • rival Ren’Py and Yarn Spinner, but without forcing their text formats
  • replace Articy Draft as the industry standard

No that’s it, just two goals, I’m humble like that. /s

Jokes aside, I have a lot of respect for Yarn Spinner and Ren’Py, but I find their text formats ugly and I want to break away from that. (But not to forbid users from using a text-only format.) I have no actual complaints about Articy, I doubt I have a chance of making this into a commercial product, but I want to try anyway because I have a habit of making BREs, and I think the world needs to see a bit more Rust tooling instead of being stuck in 1990s style UIs full of ugly small buttons.


  1. Hard problems are things like physics engines, compilers, dealing with Google, optimizing equally for desktop as well as small screens, etc. Cursed problems are usually game design related, which often have no “right” solution, like making a multiplayer game that groups you with strangers that’s resistant to griefers, or making a chat filter that doesn’t censor “assassin”.

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,
  • 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, etc).

Every object has a custom newtype ID - u64. In alpha, it’s hardcoded in Rust code. When 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.

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

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 handles logistics
  • if there are Conditions associated, during the First schedule, queue Elements up for evaluation
  • in PreUpdate, get the results and if it passes, do startup tasks (e.g. create new Entities)
  • during Update, keep processing them for as long as necessary (e.g. animating a sprite)
  • If conditions fail, jump to another node (StoryElement)
  • 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 First Schedule, 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
  • 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

  1. Time of writing: 2026-08-19

  2. 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.

A brief non-API overview of Story Engine data design

StoryManager

  • save/load
  • state
  • getter
  • initiates Story Beats

Overview

Content containers: Chapters, Characters, StoryVars, ChapterVars, CharacterVars

Some small internal enums like PlaybackState

Some small story helper enums like Yes-No-Maybe, True-False-Unknown etc

The core of the problem: StoryBeats, StoryElements, Conditions, Consequences

Smaller parts for moving things around: ConditionEffect, ConditionLink, ConditionResult

StoryBeat

A chunk of the story. Can be one dialog box, can be a thought bubble, can be a long conversation.

TODO: Might contain a link to some “macro” for a scene setup, which will likely just be a collection of Elements (background change, music change, sound cue, character sprites animating in)

Contains a link to the first Element to be evaluated. When a Beat is played, that Element is immediately loaded and its Conditions (if any) are evaluated.

If there are no conditions, or display conditions pass, emit a message with the Element and mark it is in progress.

StoryBeat doesn’t control the narrative pace, it’s up to the game-story engine integration to mark story element as completed and move on to the next one.

StoryElement summary

Something like “dialogue box” or “sound cue” or “one player dialog option”.

These will be actually separate Components instead of a single Enum, to make it easier to split the code for processing them.

TODO: Need to rethink this because since an animation will take idk 1 second, we need to mark the Element as being in progress, and proceed with the animation every frame while it’s running, possibly ad infinitum, without stopping player input. Probably some ElementProperties Component that will contain stuff like “pause everything until completed”, “move to another element right away” or “move to another element after 1 repeat or 300ms”

TODO: Maybe make something like StoryMolecule which will be a Vec of Elements to be played at once? idk

To control serial versus parallel execution, one Bevy entity can contain different Components (parallel; but different types). Special types will have to be made to add multiple instances of the same type (character animation)

ConditionEffect

A small enum that is used to group a collection of Conditions, containing something like

  • display if true
  • grey out if true (for dialogue options and buttons)
  • skip if true (takes priority? idk yet)

ConditionEffect acts as a logical AND for a group (1+) of Conditions, and all of them - within the StoryElement - will be evaluated to a single boolean.

Condition

Like Elements, not actually a single struct/enum, but a collection of different Components, e.g. ChapterVar(untyped string name, operator enum(compare value or values))

I will try to use Traits not to create overly verbose API for each separate type (ChapterVarBool, ChapterVarIntereger, ChapterVarFloat etc).

StoryEnums

(Much) Later, in the editor, users will be able to define Enums which will become Rust Enums with all their properties, including Default, and able to contain other data (probably with limitations, e.g. must implement PartialEq, Copy and/or Clone).

These will make it easier / more reliable to check custom story conditions instead of confusion prone magical numbers “player_mood == 6” or typo prone magical strings like “player_mood == ‘hangry’”.

(low prio idea) ElementModifier

just a dumb idea - maybe create a list of common vfx to apply, e.g. reverb, blur, b&w

this would have 0 or more Conditions of its own

????????? SceneOverrides?

idk like. thinking like P5X has overrides for UI style when P3 characters are on screen maybe SceneSetup or whatever could hold info about current StoryBeat maybe StoryBeat additional components. think about it