Multiple Scripts
StoryFlow projects often span multiple script files connected by runScript and runFlow nodes. This guide covers how multi-script execution works in the plugin, how the call stack manages state, and how variable scoping keeps each script isolated.
Project Structure
A StoryFlow project is a collection of script files (.sfe), each containing its own node graph. When the project is imported into Unreal Engine, these scripts are stored inside the UStoryFlowProjectAsset.
- UStoryFlowProjectAsset holds all scripts in a
TMap<FString, UStoryFlowScriptAsset*>keyed by normalized file path - The StartupScript property on the project asset defines which script is the entry point
- Each
UStoryFlowScriptAssetcontains its own nodes, connections, local variables, string table, and resolved media assets
UStoryFlowProjectAsset (SF_Project)
├── StartupScript: "scripts/main.json"
├── Scripts:
│ ├── "scripts/main.json" → UStoryFlowScriptAsset
│ ├── "scripts/chapter1.json" → UStoryFlowScriptAsset
│ ├── "scripts/chapter2.json" → UStoryFlowScriptAsset
│ └── "scripts/shop.json" → UStoryFlowScriptAsset
├── Characters: TMap<FString, UStoryFlowCharacterAsset*>
└── ResolvedAssets: TMap<FString, UObject*> Starting a Script
There are two ways to begin dialogue execution from your game code:
- StartDialogue() — Uses the
Scriptproperty configured on theUStoryFlowComponentin the Details panel. This is the most common approach. - StartDialogueWithScript(ScriptPath) — Starts a specific script by path, overriding the component's default. Useful when the same component needs to run different scripts depending on context (e.g., different conversations for the same NPC).
// Start using the component's configured script
StoryFlowComponent->StartDialogue();
// Start a specific script by path
StoryFlowComponent->StartDialogueWithScript(TEXT("scripts/chapter1.json"));
Both methods load the script's local variables fresh from the script asset and begin execution at the Start node (ID "0").
RunScript Nodes
RunScript nodes allow one script to call another script with full return support. When the called script finishes, execution returns to the calling script and continues from the runScript node's output edge. Think of it like a function call.
How RunScript Works
When the runtime encounters a runScript node, it performs the following steps:
- Save the current state — The current script's context is pushed onto the
CallStackas anFStoryFlowCallFrame. This frame captures everything needed to resume later: the current script path, the runScript node's ID (as the return point), a reference to the current script asset, a snapshot of all local variables, and the current flow stack. - Load the new script — The target script's local variables are loaded fresh from the script asset. Any previous modifications to that script's variables from an earlier call are not carried over.
- Begin execution — The runtime jumps to the new script's Start node (ID
"0") and processes nodes normally.
When the called script reaches an End node:
- Pop the call frame — The most recent
FStoryFlowCallFrameis popped from theCallStack. - Restore local variables — The calling script's local variables are restored from
SavedVariablesin the call frame, exactly as they were before the call. - Restore the flow stack — Any active flow IDs are restored from
SavedFlowStackin the call frame. - Resume execution — The runtime continues from the runScript node's output edge in the calling script.
// The call frame struct that captures script state
USTRUCT()
struct FStoryFlowCallFrame
{
GENERATED_BODY()
// Path of the script that made the call
UPROPERTY()
FString ScriptPath;
// The runScript node to return to
UPROPERTY()
FString ReturnNodeId;
// Reference to the calling script asset
UPROPERTY()
TWeakObjectPtr<UStoryFlowScriptAsset> ScriptAsset;
// Snapshot of local variables at the time of the call
UPROPERTY()
TMap<FString, FStoryFlowVariable> SavedVariables;
// Active flow IDs at the time of the call
UPROPERTY()
TArray<FString> SavedFlowStack;
}; Call Stack Details
The call stack is a standard LIFO (last-in, first-out) stack. Each runScript node pushes a frame, and each End node pops one. This means scripts can call other scripts to any depth (up to the safety limit), and each level of nesting correctly restores its caller's state.
Two delegates fire during script transitions:
- OnScriptStarted(ScriptPath) — Fires when entering a new script. Use this to update UI indicators, load scene assets, or log analytics.
- OnScriptEnded(ScriptPath) — Fires when leaving a script (either via End node or return). Use this to clean up script-specific resources.
// Listen for script transitions
StoryFlowComponent->OnScriptStarted.AddDynamic(this, &AMyActor::HandleScriptStarted);
StoryFlowComponent->OnScriptEnded.AddDynamic(this, &AMyActor::HandleScriptEnded);
void AMyActor::HandleScriptStarted(const FString& ScriptPath)
{
UE_LOG(LogTemp, Log, TEXT("Entered script: %s"), *ScriptPath);
}
void AMyActor::HandleScriptEnded(const FString& ScriptPath)
{
UE_LOG(LogTemp, Log, TEXT("Left script: %s"), *ScriptPath);
} RunFlow Nodes
RunFlow nodes are in-script macros that jump to a reusable subgraph within the same script. Unlike runScript, flows do not return to the caller when they finish. They are a way to organize and reuse parts of a node graph without duplicating nodes.
How RunFlow Works
When the runtime encounters a runFlow node:
- Push a flow frame — An
FStoryFlowFlowFrameis pushed onto theFlowCallStack. This frame only contains theFlowIdand exists purely for depth tracking. - Jump to the entry point — The runtime finds the
EntryFlownode whoseFlowIdmatches the runFlow node'sFlowId, and execution continues from that entry point's output edge. - No return — When the flow reaches an End node, execution simply ends. The flow frame is not popped or used for navigation. There is no "return to caller" behavior.
Flows are script-local
Flows cannot cross script boundaries. A runFlow node can only jump to an EntryFlow node within the same script. To execute logic in a different script and return, use a runScript node instead.
EntryFlow Nodes
Each flow subgraph begins with an EntryFlow node. This node has a FlowId property that must match the FlowId on the corresponding runFlow node. When the runtime processes a runFlow node, it searches the current script for an EntryFlow node with a matching FlowId and begins executing from that node's output.
You can think of EntryFlow nodes as named entry points within a script — labels that runFlow nodes can jump to.
RunScript vs RunFlow
CALL vs JUMP
The key difference between runScript and runFlow is whether execution returns to the caller. Understanding this distinction is critical to designing your node graphs correctly.
| runScript | runFlow | |
|---|---|---|
| Semantics | CALL — returns when done | JUMP — does not return |
| Scope | Crosses script boundaries | Same script only |
| State saved | Full call frame (variables, flow stack, return node) | Flow ID only (depth tracking) |
| On End node | Pops call frame, restores state, resumes caller | Execution ends (no pop, no return) |
| Variables | New script gets fresh local variables | Shares the current script's variables |
| Max depth | 20 | 50 |
| Use case | Modular scripts, chapter transitions, shared conversations | Reusable subgraphs, repeated logic blocks within a script |
Local Variables and Scope
Each script in a StoryFlow project has its own set of local variables. Understanding how variable scoping works across script calls is important for building predictable dialogue logic.
- Fresh on each call — When a script starts executing (whether via
StartDialogue,StartDialogueWithScript, or a runScript node), its local variables are copied fresh from the script asset's defaults. Any changes made during a previous execution of that script are not carried over. - Saved and restored across calls — When script A calls script B via a runScript node, script A's local variables are saved in the call frame. When script B ends and execution returns to script A, those variables are restored to exactly the state they were in before the call. Changes made inside script B do not affect script A's variables.
- Global variables persist — Variables stored on the
UStoryFlowSubsystemare global. They are shared across all scripts and persist for the lifetime of the subsystem. Use global variables for state that needs to survive across script boundaries, such as player reputation, quest flags, or inventory counts.
// Read a global variable (shared across all scripts)
int32 Reputation = StoryFlowComponent->GetIntVariable(
TEXT("player_reputation"), true // bGlobal = true
);
// Global variables persist across script calls
// Local variables are reset each time a script starts When to use global vs local
Use local variables for temporary state within a single script — tracking which dialogue branch the player explored, counting loop iterations, or storing intermediate calculation results. Use global variables for persistent state that multiple scripts need to read or write — player choices that affect later chapters, relationship scores, or game-wide flags.
Depth Protection
The plugin enforces depth limits at multiple levels to prevent infinite recursion from crashing your game. If any limit is exceeded, the runtime stops execution and fires the OnError delegate with a descriptive message.
| Limit | Max Depth | What It Prevents |
|---|---|---|
| Script call depth | 20 | Infinite runScript recursion (script A calls script B calls script A...) |
| Flow call depth | 50 | Infinite runFlow recursion (flow A jumps to flow B jumps to flow A...) |
| Evaluation depth | 100 | Infinite node evaluation chains (deeply nested boolean logic or conversion nodes) |
| Processing depth | 1000 | Infinite ProcessNode recursion (runaway node traversal) |
Handle depth errors gracefully
Bind to the OnError delegate to catch depth limit violations. The error message includes which limit was exceeded and the current depth, helping you identify the problematic node graph. In a shipping game, you may want to log the error and gracefully end the dialogue rather than leaving the UI in a broken state.
// Catch runtime errors including depth limit violations
StoryFlowComponent->OnError.AddDynamic(this, &AMyActor::HandleStoryFlowError);
void AMyActor::HandleStoryFlowError(const FString& ErrorMessage)
{
UE_LOG(LogStoryFlow, Error, TEXT("StoryFlow error: %s"), *ErrorMessage);
// Gracefully end dialogue if it's still active
if (StoryFlowComponent->IsDialogueActive())
{
StoryFlowComponent->StopDialogue();
// Hide dialogue UI, notify the player, etc.
}
}