· 13 min read
ModularStory, a story director on StateTree
The StateTree plugin that runs the story of the VR game I'm building, beat by beat.
- Engine
- Unreal Engine 6
- Built on
- StateTree and GameplayStateTree
- Platforms
- PC VR and standalone headsets
- Plugins
- ModularStory, ModularStorySUDS, StateTreeToolsetExtensions
What I built on
StateTree is Epic’s hierarchical state machine, with states, tasks that run together inside a state, enter conditions, transitions, linked assets, an editor and a debugger. The GameplayStateTree plugin adds a component that runs a tree on an actor.
ModularStory is the story layer on top. It has its own schema, a director that only runs story trees, a table of facts, 17 story tasks and a condition, handles that name actors by tag, an editor module and four console commands. ModularStorySUDS mirrors the facts into SUDS Pro, Old Doorways’ dialogue system, and StateTreeToolsetExtensions adds the transition tools that Epic’s StateTree toolset for the editor’s MCP server leaves out.
Why level scripts don’t hold up
The story is linear but reactive. Beats come in order, and each one waits on the player to walk somewhere, pick something up, look at something long enough, or nod. While a beat waits, the world reacts with a sound looping at a door or a lamp coming on.
The stock answer is level Blueprints, actor Blueprints and flags. Logic spread over actors gives no top-to-bottom view of a chapter, and one set in my game hosts several chapters, so its actors would carry all their wiring. Saving is worse. Unreal has no whole-world save, so story state scattered across actors means writing one.
On a standalone headset, a gate that’s only waiting has to cost nothing per frame. The scale is small enough for one person.
- beats so far, 100 at most
- ~60
- chapters
- ~10
- person writing them
- 1
Options I turned down
The first design on the table was Blueprint beat actors. I turned it down, because I didn’t want huge Blueprints on random actors running the level’s flow. So I set requirements first and scored six options against them, in plan-days estimated before any code.
| Option | Estimate | Why it lost |
|---|---|---|
| Blueprint beat actors | 7.5 days | Logic on actors again, and no single asset per chapter |
| Valve-style entity I/O, as in Hammer | ~10 days | The map is the script, and it leans on a whole-world save |
| Flow Graph plugin | ~11 days | The runner-up. Third-party code on an engine I re-sync wholesale, an unconfirmed save path, and a third graph language |
| One SUDS Pro script per chapter | ~6 days | Can’t run things side by side or hold world state |
| My own graph editor | 25+ days | Studios that do this have tools teams |
| StateTree with a story schema | ~12.5 days | Picked |
StateTree had the second-highest estimate, and it met every requirement. Tasks in one state run together, so a gate and a sound loop share a beat. ExitState runs on every task that entered, which makes undoable effects nearly free. The closest prior art I found is Naughty Dog’s state scripts, from Jason Gregory’s GDC 2009 talk “State-Based Scripting in Uncharted 2: Among Thieves”. Their states, tracks and signals map almost one to one onto StateTree’s states, tasks and events.
A spike came first, with Flow Graph as the fallback if StateTree missed any of four conditions. On the real director, a gate ended its beat and the loop beside it, a fact write woke a sleeping tree, and an effect survived a sustained re-entry. The fourth, a 14-beat chapter that reads well in the stock editor, is mine to judge.
A schema of its own
A StateTree schema decides which nodes and context a tree allows. UStoryTreeSchema derives from the component’s schema, so story trees keep Delay and the other common nodes. Story nodes derive from the plain FStateTreeTaskBase and FStateTreeConditionBase, and every engine schema allows only the common bases plus its own nodes, so story nodes appear in story trees and nowhere else. Epic’s AI schema adds its nodes the same way.
UStoryTreeSchema::UStoryTreeSchema()
{
// Story trees spend nearly all their time waiting on a gate, so they always sleep when nothing in them ticks,
// whatever StateTree.Component.DefaultScheduledTickAllowed says.
ScheduledTickPolicy = EStateTreeComponentSchemaScheduledTickPolicy::Allowed;
}
bool UStoryTreeSchema::IsStructAllowed(const UScriptStruct* InScriptStruct) const
{
return Super::IsStructAllowed(InScriptStruct)
|| InScriptStruct->IsChildOf(FStoryTaskBase::StaticStruct())
|| InScriptStruct->IsChildOf(FStoryConditionBase::StaticStruct());
}
Blueprint nodes can’t be fenced in like this, because the component schema allows every Blueprint node class, so story nodes are C++. In the node picker, they get a Story category of their own.
The director is an actor with a UStoryTreeComponent, whose GetSchema returns the story schema. That override filters the director’s tree picker to story trees, and HasValidStateTreeReference refuses any other tree at run time. It starts a chapter through StartLogic, the path that installs the hook which wakes a sleeping tree.
My first Beat task had no instance data, and the StateTree compiler refused it with “Malformed task, missing instance value.” The story’s node bases now give every child an empty instance struct, as UAF’s condition base does.
A beat is a state
A beat is a state with a Beat task on it. The Beat task’s tag, such as Beat.Vault.FindKey, is a fact it sets to 1 when the flow moves on, and the beat’s enter condition, Beat.Vault.FindKey == 0, keeps it out once it’s past. Each beat holds at least one gate, a task that’s considered for completion, so the beat ends when its gate passes. Loops and effects beside it aren’t considered, so they stop with the beat. In the editor, the Beat section tops each beat’s details.
- The chapter's beats, in the order they run
- The Beat section, above the stock State category
- When it ends picks Next beat or Finish chapter
- The skip condition the editor writes
- The Beat task, which sets its fact as the beat ends
- The gate, the only task considered for completion
Writing the skip condition by hand on 60 beats is how progression bugs get in, so the editor writes it. ModularStoryEditor registers an editor schema for the story schema, and the editor calls its PreValidate whenever it validates a tree, which is on every edit and every compile. It keeps each beat’s skip condition last, joined with And at the top level, so a past beat never enters. It runs before the engine fixes up state links, so a link to a renamed beat follows the new name. And it names each beat after its tag, so Beat.Vault.FindKey becomes Find Key, unless someone typed a name by hand.
The When it ends row and the Beat Tag picker follow Epic’s Avalanche transition editor.
The stock compiler accepts two mistakes that wreck a story. A state with no considered task gets a completion bit that nothing sets, so it never ends. A beat that ends with no transition on it or above it sends the tree back to the root, which selects again before ExitState runs. The Beat task hasn’t written its fact yet, so the same beat starts over, forever. The editor schema’s HandlePostInternalCompile fails the compile for both, and for two beats that share a tag, with the error on the beat.
Gates sleep
Stock StateTree tasks tick by default, and a story tree spends nearly all its time waiting. My gates follow one rule from Naughty Dog’s waits. Test the condition the moment the gate starts, then listen. A gate that’s already met passes at once, so an event that fired before anyone listened can’t strand the player.
EStateTreeRunStatus FStoryTask_WaitForFact::EnterState(FStateTreeExecutionContext& Context, const FStateTreeTransitionResult& Transition) const
{
UStoryWorldSubsystem& StorySubsystem = Context.GetExternalData(StoryHandle);
if (UE::ModularStory::CompareFact(StorySubsystem.GetFact(Fact), Compare, Value))
{
return EStateTreeRunStatus::Succeeded;
}
// The weak context finishes the task from outside the tree's update, and that wakes a sleeping tree.
FInstanceDataType& InstanceData = Context.GetInstanceData(*this);
InstanceData.FactChangedHandle = StorySubsystem.OnFactChanged.AddLambda(
[WeakContext = Context.MakeWeakExecutionContext(), GateFact = Fact, GateCompare = Compare, GateValue = Value](const FGameplayTag ChangedFact, const float NewValue)
{
if (ChangedFact == GateFact && UE::ModularStory::CompareFact(NewValue, GateCompare, GateValue))
{
WeakContext.FinishTask(EStateTreeFinishTaskType::Succeeded);
}
});
return EStateTreeRunStatus::Running;
}
The gate binds the facts subsystem’s native delegate. When the fact passes, the gate finishes itself through the weak execution context, and FinishTask schedules the tree’s next tick, which wakes it for the transition. Fact writes never pass through StateTree’s event queue, which holds 64 events, so a burst of them can’t overflow it. Watch the tick lane in the timeline below.
A Story Handle component names its actor with a gameplay tag such as Vault.Door, the way a targetname does in Hammer, and keeps its states (Held, Occupied, Open, Locked, Lit and Hidden). Walk, Grab, Release and Wait For State test a state as they start, then listen to the handle subsystem, and a gate whose handle hasn’t streamed in yet waits for it to register.
Look is the exception, since nothing fires an event when someone looks at something. It samples the gaze ten times a second on a world timer, so the tree stays asleep, and passes once the player has looked at the actor for its hold time, 2 seconds by default, like Hammer’s trigger_look. Looking away starts the hold over, and by default the actor has to be in line of sight. The gaze comes from my XRViewpoint plugin, which uses the eyes when the headset tracks them and the head when it doesn’t. Its debug view boxes whatever gameplay asks it about and the props nearby, green for anything the player is looking at.
Any gate can also send an Outcome Event, so two gates in one state, each with a transition on its event, branch on whichever passes first.
Effects hold while their state is active
An effect applies in EnterState and undoes itself in ExitState. Set Fact and Set State have a Persist box that skips the undo. A Set State that turns a door’s Locked state off with Persist leaves the door unlocked after its state ends. A Set State that lights a lamp without Persist turns it off again when its state exits. The two lines under the state bars show the difference.
Two engine behaviours needed handling. When a child state goes back to its parent, StateTree exits and re-enters the parent’s tasks with the change type Sustained, which would flicker the lamp. Effects and the Beat task turn off bShouldStateChangeOnReselect, so StateTree skips them on a sustained change. And a stop can skip ExitState when the tree’s context can’t be set up, so each effect records its undo in the story subsystem, and StopChapter undoes whatever is left, newest first.
A load is a restart
StateTree can start at a state by tag, but it only searches the root asset, and the stock component never passes one. So a load runs the chapter from the top and lets every finished beat skip itself. The facts live in UStoryWorldSubsystem as a flat map from gameplay tag to float, where a flag is 1 and a fact never written reads 0. A save only has to copy the map, and a load runs three steps in a fixed order.
- StopChapter The tree stops, and the scoped effects undo themselves
- RestoreFacts The save's facts replace the table, and OnFactsRestored fires
- StartChapter The root selects from the top and falls through to the first beat that isn't past
The stop goes first, because its undos write facts and would overwrite the save. RestoreFacts doesn’t fire OnFactChanged, since no gate is listening while the tree is stopped. The Beat task makes the fall-through work. It writes its fact in ExitState and skips the write when the tree is stopping.
void FStoryTask_Beat::ExitState(FStateTreeExecutionContext& Context, const FStateTreeTransitionResult& Transition) const
{
UStoryWorldSubsystem& StorySubsystem = Context.GetExternalData(StoryHandle);
StorySubsystem.BeatEnded(Beat);
// A stop is a load or the game ending, not the flow moving on, so the beat replays from its start next time.
if (Transition.CurrentRunStatus == EStateTreeRunStatus::Stopped)
{
return;
}
StorySubsystem.SetFact(Beat, 1.0f);
}
StateCompleted can’t do this job, because an event transition skips it, and branching on a gate’s Outcome Event is an event transition. Quitting mid-beat replays that beat from its start, which I accept, since I only plan checkpoints at beat boundaries. After a restore, the root tries each beat in order.
Facts are global, so this works inside linked templates too.
One NaN would have looped forever
ModularStorySUDS mirrors each fact as a SUDS Pro global, so a dialogue script reads {global.Story.Vault.TookKey} and writes it with [set global.Story.Vault.TookKey 1], and the bridge copies changes both ways. A review before the build caught the trap. NaN never equals itself, so every write of a NaN fact would count as a change, and the bridge would bounce it between the two forever. SetFact now refuses anything that isn’t a finite number, and so does the bridge.
Driving a run from the console
Four console commands do most of the driving, and none of them exist in Shipping builds. Story.Fact Vault.TookKey sets a fact to 1, or to a value after the tag, which passes a fact gate by hand. Story.State Vault.Door Open reports a state on a handle as if its actor had, so I can pass a handle gate without a headset. Story.Pass marks the innermost running beat past and restarts the chapter, so the next beat begins.
Story.Jump <beat> is a load with made-up facts. It reads the chapter the way it runs, into subtrees and linked templates, and finds the first beat with that tag or one under it. Then it stops the chapter, rewrites the beat facts around it and starts the chapter again.
The player lands on the mark of the last Place Player before the beat, the task that puts the player’s head over a mark. Marks often live in sets that are still streaming in, so the jump checks every 0.1 s, gives up with a warning after 10 s, and gives way if the beat’s own Place Player moves the player first. An unknown beat gets a list of the chapter’s beats instead.
To watch the beats change, I type log LogStateTree Log before pressing Play, and LogStateTree logs every enter and exit. Here’s Story.Jump GymNight.Choose from the clip at the top, cut to the Enter and Exit lines and the command’s own, with the tree’s asset path taken out of each Enter line.
LogStateTree: StoryDirector_1: Enter state 'Root(0); Wake Up(1)' (1)
LogStateTree: StoryDirector_1: Exit state 'Root.Wake Up' (1)
LogStateTree: StoryDirector_1: Enter state 'Root(0); Choose(4); Pick(5)' (1)
Story.Jump started ST_GymNight again at Beat.GymNight.Choose.
The restarted root skips Wake Up, Knock and Through, whose facts the jump set, and enters Pick inside Choose.
What’s still open
Checkpoints and the save itself are still to come. The load order is in place, but no checkpoint calls it yet, and the save doesn’t keep persistent state writes. Answer, the gate that takes a nod or a shake, polls ten times a second, because XRViewpoint’s gesture event is a dynamic delegate and a task is a struct that can’t bind one. Look Away, Wait For Event and Timed gates come next. A card-view Beat Sheet tab waits until the stock editor proves slow, since a review called it over-built for one person and about 60 beats.
Running Story.Pass once per beat walks a whole chapter. Here’s the gym chapter, trimmed the same way.
LogStateTree: StoryDirector_1: Enter state 'Root(0); Wake Up(1)' (1)
LogStateTree: StoryDirector_1: Exit state 'Root.Wake Up' (1)
LogStateTree: StoryDirector_1: Enter state 'Root(0); Knock(2)' (1)
Story.Pass passed Beat.GymNight.WakeUp and started ST_GymNight again.
LogStateTree: StoryDirector_1: Exit state 'Root.Knock' (1)
LogStateTree: StoryDirector_1: Enter state 'Root(0); Through(3)' (1)
Story.Pass passed Beat.GymNight.Knock and started ST_GymNight again.
LogStateTree: StoryDirector_1: Exit state 'Root.Through' (1)
LogStateTree: StoryDirector_1: Enter state 'Root(0); Choose(4); Pick(5)' (1)
Story.Pass passed Beat.GymNight.Through and started ST_GymNight again.
LogStateTree: StoryDirector_1: Exit state 'Root.Choose.Pick' (1)
LogStateTree: StoryDirector_1: Enter state 'Root(0); Lights Out(8)' (1)
Story.Pass passed Beat.GymNight.Choose and started ST_GymNight again.
LogStateTree: StoryDirector_1: Exit state 'Root.Lights Out' (1)
Warning: Story.Pass couldn't start ST_GymNight again.
Passing the last beat leaves the root nothing to enter, so for now the walk ends on a warning instead of a finished chapter.