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

ModularStory, a story director on StateTree
Every chapter of the VR game I’m building is one StateTree asset that reads top to bottom as its beats. A tree waiting on the player doesn’t tick, and a load is a restart in which every finished beat skips itself.
The gym chapter in Play In Editor, with the StateTree debugger following the run. At two seconds Story.State marks the mug as held, so Pick's Grab gate passes. Took Mug waits a second, then Lights Out turns the lamp off.
Engine
Unreal Engine 6
Built on
StateTree and GameplayStateTree
Platforms
PC VR and standalone headsets
Plugins
ModularStory, ModularStorySUDS, StateTree​Toolset​Extensions

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.

OptionEstimateWhy it lost
Blueprint beat actors7.5 daysLogic on actors again, and no single asset per chapter
Valve-style entity I/O, as in Hammer~10 daysThe map is the script, and it leans on a whole-world save
Flow Graph plugin~11 daysThe 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 daysCan’t run things side by side or hold world state
My own graph editor25+ daysStudios that do this have tools teams
StateTree with a story schema~12.5 daysPicked

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 node picker in a story tree, with the Story category beside the stock ones.

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.

  1. The chapter's beats, in the order they run
  2. The Beat section, above the stock State category
  3. When it ends picks Next beat or Finish chapter
  4. The skip condition the editor writes
  5. The Beat task, which sets its fact as the beat ends
  6. The gate, the only task considered for completion
The gym chapter in the stock StateTree editor, with the Knock beat selected.

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.

Clearing the Beat Tag renames the state Beat and drops its skip condition, and the links to it follow the new name. Picking the tag again brings it all back.

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.

A beat without a gate fails the compile.

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 chapter over time. The component's tick stays off while a gate waits, and each pass wakes it for the transition.

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.

xr.Gaze.Debug 2 during the Knock beat, with the StateTree debugger under it. The box around whatever the camera looks at turns green. The first look at the lever door lasts 1.5 seconds, and a glance at the unlocked door starts the hold over. Two unbroken seconds on the lever door pass the gate, and the debugger moves to Through.

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.

A persistent write outlives its state. A scoped one ends with it, and a child returning to its parent touches neither.

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.

  1. StopChapter The tree stops, and the scoped effects undo themselves
  2. RestoreFacts The save's facts replace the table, and OnFactsRestored fires
  3. StartChapter The root selects from the top and falls through to the first beat that isn't past
The load order.

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.

The past beats fail their skip conditions, and the first beat that isn't past enters.

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.

What Story.Jump Vault.LookInside does before the chapter starts 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.

← All projects