Skip to main content

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 UStoryFlowScriptAsset contains its own nodes, connections, local variables, string table, and resolved media assets
Code
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 Script property configured on the UStoryFlowComponent in 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).
C++
// 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:

  1. Save the current state — The current script's context is pushed onto the CallStack as an FStoryFlowCallFrame. 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.
  2. 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.
  3. 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:

  1. Pop the call frame — The most recent FStoryFlowCallFrame is popped from the CallStack.
  2. Restore local variables — The calling script's local variables are restored from SavedVariables in the call frame, exactly as they were before the call.
  3. Restore the flow stack — Any active flow IDs are restored from SavedFlowStack in the call frame.
  4. Resume execution — The runtime continues from the runScript node's output edge in the calling script.
C++
// 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.
C++
// 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:

  1. Push a flow frame — An FStoryFlowFlowFrame is pushed onto the FlowCallStack. This frame only contains the FlowId and exists purely for depth tracking.
  2. Jump to the entry point — The runtime finds the EntryFlow node whose FlowId matches the runFlow node's FlowId, and execution continues from that entry point's output edge.
  3. 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 UStoryFlowSubsystem are 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.
C++
// 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.

C++
// 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.
    }
}

Need Help?

Join our Discord community to ask questions, share your projects, report bugs, and get support from the team and other users.

Join Discord