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 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:

  1. 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.
  2. 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.
  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 CallFrame is popped from the call stack.
  2. Restore local variables - The calling script's local variables are restored from SavedLocalVariables, exactly as they were before the call.
  3. Restore the flow stack - Any active flow frames are restored from SavedFlowStack.
  4. 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:

  1. Push a flow frame - A FlowFrame is pushed onto the flow call stack. 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 target, and execution continues from that entry point's output edge.
  3. 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:

C#
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.

C#
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 StartDialogue or 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 StoryFlowManager are 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.
C#
// 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:

C#
// 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.

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