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 Unity plugin, how the call stack manages state, and how variable scoping keeps each script isolated.
Overview
A StoryFlow project is a collection of script files (.sfe), each containing its own node graph. When the project is imported into Unity, these scripts are stored inside the StoryFlowProjectAsset as a dictionary of StoryFlowScriptAsset objects keyed by normalized file path.
There are two ways scripts can reference each other:
- RunScript - A call that enters a new script file and returns to the caller when the new script reaches an End node.
- RunFlow - A jump to a reusable subgraph within the same script. No return, no cross-script boundary.
RunScript vs RunFlow
CALL vs JUMP
The key difference is whether execution returns to the caller. Understanding this distinction is critical to designing your node graphs correctly.
| Property | RunScript | RunFlow |
|---|---|---|
| Behavior | Call - returns to caller | Jump - no return (in-script macro) |
| Scope | New script file | Same script (flow definitions) |
| Stack | CallStack (full saved state) | FlowCallStack (depth only) |
| Variables | New local scope | Shares parent scope |
| Max Depth | 20 | 50 |
RunScript (Call with Return)
RunScript nodes allow one script to call another script with full return support. When the called script finishes (reaches an End node), execution returns to the calling script and continues from the RunScript node's output edge. Think of it like a function call.
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 call stack as a
CallFrame. This frame captures: the current script path, the RunScript node's ID (as the return point), a reference to the current script asset, a deep copy of all local variables, and the current flow stack. - Load the new script - The target script's local variables are copied fresh from the script asset's defaults. 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
CallFrameis popped from the call stack. - Restore local variables - The calling script's local variables are restored from
SavedLocalVariables, exactly as they were before the call. - Restore the flow stack - Any active flow frames are restored from
SavedFlowStack. - Resume execution - The runtime continues from the RunScript node's output edge in the calling script.
RunFlow (In-Script Jump)
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.
When the runtime encounters a RunFlow node:
- Push a flow frame - A
FlowFrameis pushed onto the flow call stack. This frame only contains theFlowIdand exists purely for depth tracking. - Jump to the entry point - The runtime finds the
EntryFlownode whoseFlowIdmatches the RunFlow node's target, and execution continues from that entry point's output edge. - No return to flow caller - When the flow reaches an End node, the flow frame is popped. If inside a RunScript call, the End node triggers a script return (popping the call stack). Otherwise, execution ends. There is no "return to the RunFlow node" behavior like RunScript has.
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.
The flow stack is saved and restored when crossing script boundaries via RunScript. If script A has an active flow and calls script B, the flow stack is captured in the call frame. When script B ends and returns to script A, the flow stack is restored.
Call Stack
The call stack is a standard LIFO (last-in, first-out) stack managed internally by the StoryFlowExecutionContext. 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 events fire during script transitions, letting you react in your game code:
component.OnScriptStarted += (path) => Debug.Log($"Entered: {path}");
component.OnScriptEnded += (path) => Debug.Log($"Returned from: {path}"); Use these events to update UI indicators, load scene assets, trigger transitions, or log analytics when scripts change.
Depth Limits
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 event 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 node traversal (runaway ProcessNode loop) |
Handle depth errors gracefully
Subscribe to the OnError event 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.
component.OnError += (message) =>
{
Debug.LogError($"StoryFlow error: {message}");
// Gracefully end dialogue if still active
if (component.IsDialogueActive())
{
component.StopDialogue();
// Hide dialogue UI, notify the player, etc.
}
}; Variable Scope Across Scripts
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
StartDialogueor a RunScript node), its local variables are deep-copied 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 RunScript, 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
StoryFlowManagerare global. They are shared across all scripts and persist for the lifetime of the manager. 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)
int reputation = component.GetIntVariable("player_reputation", global: true);
// Read a local variable (current script only, reset on each call)
bool visited = component.GetBoolVariable("has_visited_before");
// 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.
Starting Specific Scripts
There are three ways to begin dialogue execution programmatically from your game code:
// 1. Start using the component's configured Script asset (set in Inspector)
component.StartDialogue();
// 2. Start a specific script by its path
component.StartDialogue("npcs/merchant");
// 3. Start with a direct asset reference
[SerializeField] private StoryFlowScriptAsset merchantScript;
component.StartDialogue(merchantScript);
The first overload uses the Script asset reference configured on the component in the Inspector (or the project's startup script if Script is not assigned). The second overload looks up a script by path from the project asset. The third accepts a direct StoryFlowScriptAsset reference, which is useful when you have an asset reference from a serialized field or ScriptableObject.
All three methods load the script's local variables fresh from the asset and begin execution at the Start node.