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 Godot, these scripts are stored inside a StoryFlowProject resource.

  • StoryFlowProject holds all scripts in a Dictionary keyed by normalized file path
  • The startup script property on the project defines which script is the entry point
  • Each StoryFlowScript contains its own nodes, connections, local variables, string table, and resolved media assets
Code
StoryFlowProject
├── startup_script: "scripts/main"
├── scripts: Dictionary
│   ├── "scripts/main"       → StoryFlowScript
│   ├── "scripts/chapter1"   → StoryFlowScript
│   ├── "scripts/chapter2"   → StoryFlowScript
│   └── "scripts/shop"       → StoryFlowScript
├── characters: Dictionary
└── resolved_assets: Dictionary

Starting a Script

There are two ways to begin dialogue execution from your game code:

  • start_dialogue() - Uses the script_path property configured on the StoryFlowComponent in the Inspector panel. This is the most common approach.
  • start_dialogue_with_script(path) - 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).
GDScript
# Start using the component's configured script
story_flow_component.start_dialogue()

# Start a specific script by path
story_flow_component.start_dialogue_with_script("scripts/chapter1")

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 call_stack as a StoryFlowCallFrame. 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'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 StoryFlowCallFrame is popped from the call_stack.
  2. Restore local variables - The calling script's local variables are restored from saved_variables in the call frame, exactly as they were before the call.
  3. Restore the flow stack - Any active flow IDs are restored from saved_flow_stack in the call frame.
  4. Resume execution - The runtime continues from the runScript node's output edge in the calling script.
GDScript
# The call frame class that captures script state
# (internal to the plugin - shown here for understanding)

class_name StoryFlowCallFrame

var script_path: String          # Path of the script that made the call
var return_node_id: String       # The runScript node to return to
var script_asset: StoryFlowScript  # Reference to the calling script asset
var saved_variables: Dictionary  # Snapshot of local variables at call time
var saved_flow_stack: Array[String]  # Active flow IDs at call time

Call Stack Details

The call stack (call_stack: Array[StoryFlowCallFrame]) 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 signals fire during script transitions:

  • script_started(script_path_name) - Fires when entering a new script. Use this to update UI indicators, load scene assets, or log analytics.
  • script_ended(script_path_name) - Fires when leaving a script (either via End node or return). Use this to clean up script-specific resources.
GDScript
# Listen for script transitions
func _ready() -> void:
    story_flow_component.script_started.connect(_on_script_started)
    story_flow_component.script_ended.connect(_on_script_ended)

func _on_script_started(script_path: String) -> void:
    print("Entered script: ", script_path)

func _on_script_ended(script_path: String) -> void:
    print("Left script: ", script_path)

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 - The flow ID is pushed onto the flow_call_stack: Array[String]. This stack exists purely for depth tracking - it does not store any state to restore.
  2. Jump to the entry point - The runtime finds the EntryFlow node whose flow ID matches the runFlow node's flow ID, 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 flow ID property that must match the flow ID on the corresponding runFlow node. When the runtime processes a runFlow node, it searches the current script for an EntryFlow node with a matching flow ID 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 start_dialogue(), start_dialogue_with_script(), 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 StoryFlowRuntime autoload 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.
GDScript
# Read a global variable (shared across all scripts)
var reputation: int = story_flow_component.get_int_variable("player_reputation")

# 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 emits the error_occurred signal 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

Connect to the error_occurred signal 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.

GDScript
# Catch runtime errors including depth limit violations
func _ready() -> void:
    story_flow_component.error_occurred.connect(_on_storyflow_error)

func _on_storyflow_error(error_message: String) -> void:
    push_error("StoryFlow error: " + error_message)

    # Gracefully end dialogue if it's still active
    if story_flow_component.is_dialogue_active():
        story_flow_component.stop_dialogue()
        # 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