A brief non-API overview of Story Engine data design
These are more in-progress design documents than documentation; besides not being finalized, please keep in mind that most things described here are only for alpha version development; game writers authoring content will not need to know any of these specifics.
StoryManager - Bevy Resource
- save/load
- state
- index for all objects, maps {stuff}Id to Bevy Entity ids
- log (text log of recent things said by various characters or narrated)
- history (a list of all player choices already clicked, all text seen, etc)
- initiates Story Beats (? uncertain and/or one of several ways to start them)
- heartbeat - every time an element is processed or skipped, increment (persisted u64)
- possibly more helper functions both for runtime and editor (recursive getters, integrity checks?)
Core systems provided by the engine
- listen for a message to start a story beat
- find beats/elements with
IsStarting, queue their Condition for evaluation - evaluate them
- collect the results
- if successful, add
IsPlaying - if the default egui runner is enabled, listen to that being added and trigger animations etc
Overview of date structures
Content containers: Chapters, StoryBeats, Characters, StoryVars, ChapterVars, CharacterVars
Maybe some small internal enums like PlaybackState
Some small story helper enums like Yes-No-Maybe (IndecisiveBool?), True-False-Unknown (QuantumBool?) etc (helpful for the editor, as a type of story variable). Possibly common dice combinations too. Consider using https://crates.io/crates/gametools for dice and cards.
Most of the story content are Bevy Components and Relationships that aren’t made of any single, heavy enum/struct, but belong to three somewhat overlapping categories:
- StoryElements (for content),
- Conditions (for changing the flow, mostly for not showing or skipping Elements), and
- Consequences (mostly overlapping with StoryElements, but more for changing variables etc).
Chapter, StoryBeat
Chapters are just a logical grouping, and don’t have any functionality of their own.
StoryBeat represents a chunk of the story, a logical group that doesn’t hold story content directly. It can comprise one dialog box, a thought bubble, a long conversation, or the whole game.
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)
StoryBeat contains a link to the first Element to be evaluated.
When a Beat is played, that Element is marked with IsStarting marker Component,
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.
Nesting
StoryBeats can be nested, with a stack-like behavior or pushing and popping all visual elements to be resumed later, after the “interjected” StoryBeat finishes.
Navigating between nodes
Following nodes are Components on StoryBeats and StoryElements. There is no implicit array of elements that follow each other with a custom breaking conditions or GoTo. Instead, most Elements contain StoryElementId that follows. If it’s missing, and there’s nothing in the queue, StoryBeat ends and gets cleaned up.
The following Components affect the flow:
- NextElementOnceOnly (top priority)
- NextElement (high priority, erases flavor queue)
- FlavorElement (used in player choices, parent node contains NextElement, and each of player choices contains a FlavorElement and other optional consequences; after the queue is exhausted, NextElement is called, which can be overridden)
- PostFlavorElement (same as above, just put at the end of queue)
- NextElementRandom (different from others, contains array of ElementIds that will be picked randomly, if they pass their Conditions)
- NextStoryBeat (deletes any Element Queues, cleans up the scene, starts the new beat)
- InterjectStoryBeat (pushes all displayed data onto a stack, and starts the new story beat right away)
StoryElement summary
Something like “dialogue box” or “sound cue” or “one player dialog option”.
These are separate Components instead of a single Component/Enum, for a few reasons:
(1) cleaner code, since different types will have very different ways of handling them, and (2) much easier extensibility, since adding very different types of content just means defining a new component and system to handle that component when it’s being played (3) for many use cases, easier to parallelize a playing few elements at once
Parallel animations etc are not currently in “Alpha 1” scope, there will be Sequencer for that later.
Tracking state
Elements themselves will have tags (marker Components):
- Seen - marked after finishing animation/etc. Do not mark unclicked menu options just because you’ve displayed them.
- SeenInPreviousGame - ??? todo, something like after completing a game or a chapter, Seen components will be copied to this one, for fast-replay of branching narratives
- IsPlaying - sound, music, video, animation currently playing
- IsFinishedPlaying - animation and visual elements no longer animating, but still on screen
- IsLooping - TBD, perhaps mark things as looping after IsPlaying finishes once? Not sure if I want to turn it into an enum. Perhaps more likely just keep AnimationState that will have a is_looping bool property, but limit the components to just IsPlaying and IsFinishedPlaying.
Possibly these, but undercooked:
- disable player control while running
- disable skip
- hide UI
- encourage autosave
Misc quick haxx
- AnswerRoundRobin - a list of IDs that will rotate, and perhaps ?? update the NextElementId component on each click.
Used for getting 2+ answers to the same question, unlike the
ShowOnlyOncecomponent. - QuestionAndAnswerRoundRobin??? - TBD - loop both questions and answers in sync. Used for monkey island-esque “do you sell X?”
gags, or for encouraging a character to tell more bit by bit.
- could also just be a “macro” in the editor, and be just a regular chain in the data or idk
Example StoryElements
- /// when several things need to happen at the same time; continue after all finish
StoryMolecule(Vec
), - /// VN style sentence, potentially with a character name and portrait (or bustup later) Sentence(Sentence),
- /// Window with a message and an OK button Modal(String),
- /// Toast that will appear for a while (and stack) in the corner and then disappear (animate in/out preferably) TimedToast(String),
- /// Toast that will appear in the corner (and stack) and stay there until dismissed WaitingToast(String),
- // also add PriorityModal? Interrupting? Queued? Ignorable? Summarizable?
- /// will automatically display “…” and wait for a click, VN style Pause,
- CharacterAnim,
- BgChange,
- MusicChange,
- De/ActivateBeatAudioFilter - turns on audio filter on main channel that can be deactivated later, or deactivates at a change of a story beat (incl. interjection)
- De/ActivateBeatVideoFilter - adds a filter/shader (e.g. retro, B&W, for flashbacks)
- De/ActivateBeatVideoFrame - adds frame (for flashbacks)
- De/ActivateStickyAudioFilter - turns on a sticky global audio filter that does not go away even after ending story beats. Stays in save. Deactivates in main menu.
- De/ActivateStickyVideoFilter
- De/ActivateStickyVideoFrame
- SoundCue,
- ActionReward,
- Choice,
- /// stop the parent Beat now and skip to a different beat GoTo(StoryBeatId),
- /// insert the following story beat here, and then result the current beat Interject(StoryBeatId),
- /// finish the current beat and go to the following beat QueueAtEnd(StoryBeatId),
Decisions, PlayerChoice, ChoiceOption
ChoiceNode is a (Story Element) Component that’s the root, optionally with text like “where do you want to go”
Choice is a singular option - a Component of a separate entity, which also has
PlayerChoices/ChoiceInsideGroup are the relationship types between the two.
Consequences
Consequences are technically StoryElements, but are more focused on “invisible”
things - changing variables, affecting game state, changing the world, history,
relationships. ConsequenceOf is the relationship type to ChoiceOption.
Examples:
- RunSystemCached (enum of manually mapped enum options to unparametrized bevy systems)
- SetVar
- SetChapterVar
- SetCharacterVar
- Adjust…Var
- Delete…Var
(low prio idea) More complex animations combos
Sequencer will be available for chaining a bunch of Elements together, with either sequential timing (x after x), or discreet values (animate x in, wait 200ms, after that do X and Y in parallel).
If it’s easy to use, sequencer might be also used to set up scenes (Element bundles of sorts).
TODO: figure out if this can be also used for staggered typing, and/or for captions during cutscenes
Condition Effects
By default, an element is shown. Element can have conditions applied to it, which are child entities of one of the following relationship sub-types:
- IsShownIf - if conditions pass, show elements. No children = show. This is the default condition type.
- IsGreyedOutIf - applicable to player choices and buttons - if conditions pass, grey out
- IsHiddenIf - opposite of IsShownIf. Has priority over IsShownIf.
These can apply to a StoryElement directly, or to Choices/Buttons.
Related to that
There’s also a special Component (not a relationship)
- ShowOnlyOnce - after viewing the story element, replace this component with Hidden
Other marker Components:
- Hidden - automatically skipped, takes precedence over IsShown and IsHidden
- Seen - marked as already visited (maybe with visit timestamp, story heartbeat id, idk)
- WasSeenInPreviousGame ??
Player choices
One type of a StoryElement is PlayerChoice. They all are checked for conditions, and if all of them are not shown or greyed out, skip to the next StoryElement.
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, ChapterVarInteger, ChapterVarFloat etc).
Conditions are evaluated with logical AND within their group (e.g. targets of IsShown).
LogicalOrCondition(Vec<impl Condition>) is a helper Condition to allow OR.
(low prio idea) 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
Or maybe this could be like. A Bundle/bsn preset that uses some default, but a different constructor function will produce different UI style?