Skip to main content

Images & Media

StoryFlow dialogues can include images for character portraits, inline illustrations, and full-screen backgrounds. This guide covers how image assets are imported, resolved, and displayed in the Godot plugin.

Image Assets in StoryFlow

Images in StoryFlow are referenced in dialogue nodes via setImage nodes or directly on dialogue node data. Rather than embedding image data inline, StoryFlow stores asset keys that map to entries in a resolved_assets dictionary. This means the node graph is lightweight and the actual textures are loaded separately.

There are three levels of asset resolution, checked in order:

  • Character-level assets - Each StoryFlowCharacter has its own resolved_assets dictionary holding that character's portrait, so a portrait is found first no matter which script is running.
  • Script-level assets - Defined per script file. Images used in a specific script's nodes (dialogue illustrations, background images, etc.) are resolved from the script's own asset dictionary.
  • Project-level assets - Imported from characters.json and global-variables.json and shared across all scripts. This is the final fallback for both image and audio resolution, and it is where every character-scoped and global asset lands.

At runtime, the execution context looks up asset keys in these dictionaries (character → script → project) to find the corresponding Texture2D resources.

Importing Images

When you import a StoryFlow project, the StoryFlowImporter processes all media files from the exported build directory. Media files are copied into your Godot project's resource directory and the loaded resource is stored in the appropriate resolved_assets dictionary.

GDScript
# Assets are collected from three places in the build:
#   - each script's own "assets" section, whether the script is inline in
#     project.storyflow / project.json or a standalone .json file
#   - characters.json
#   - global-variables.json

# Internal flow, per asset:
# 1. Copy the file into an images/, audio/ or media/ subdirectory of the
#    output directory, chosen by the asset's declared type
# 2. Load the resource eagerly: images and MP3 straight from the file
#    buffer, WAV, OGG and everything else via ResourceLoader.load()
# 3. Store the loaded Resource in the resolved_assets dictionary
# 4. Only if that load fails, store the file path instead. That is the
#    single case the runtime's lazy retry path handles later

Imported assets are placed in the output directory you configured during import (typically under res://storyflow/). Media is grouped by asset type into images/, audio/ and media/ subdirectories and keyed by file name, not by its path inside the StoryFlow build.

Media in an exported game

A Godot export packs the imported version of a media file (a CompressedTexture2D, an AudioStreamWAV and so on) rather than the raw bytes the importer normally reads. Since v1.2.2, when the raw source file is not readable the importer reaches the imported resource through ResourceLoader and Godot's path remap instead, which is what makes media appear in an exported build. See Exporting Your Game.

Image Persistence Between Dialogues

One of the most useful image behaviors in StoryFlow is image persistence. Images can carry over from one dialogue node to the next without being explicitly re-assigned. This lets you set an image once and have it remain visible across a sequence of dialogue nodes.

How image persistence works

The persistence logic follows three rules:

  • Dialogue has an image assigned - That image is used, and it becomes the new persistent image for subsequent nodes.
  • Dialogue has NO image and image reset is false - The persistent image from the previous dialogue carries over. The player continues to see the same image.
  • Dialogue has NO image and image reset is true - The image is cleared (set to null). No image is shown.

The current image is stored on StoryFlowDialogueState.image as a Texture2D. Your UI reads this field each time dialogue_updated fires and displays the texture accordingly.

GDScript
@onready var dialogue_image: TextureRect = %DialogueImage

func _on_dialogue_updated(state: StoryFlowDialogueState) -> void:
    # state.image is either:
    # - A valid Texture2D (this node's image, or persisted from a previous node)
    # - null (no image, or image was reset)

    if state.image:
        dialogue_image.texture = state.image
        dialogue_image.visible = true
    else:
        dialogue_image.visible = false

SetImage Node

The setImage node sets an image variable value during script execution. The image can then be referenced by dialogue nodes or passed through connections to other nodes in the graph.

Like other Set* nodes in StoryFlow, the setImage node has a special behavior when it has no outgoing edge:

  1. First, it checks if execution is inside a forEach loop - if so, the loop continues to the next iteration.
  2. Otherwise, it checks if the incoming edge came from a Dialogue node - if so, it returns to that dialogue to re-render with the updated image.

This return-to-dialogue behavior is what enables live updates. For example, a player clicks an option on a dialogue node, the graph flows through a setImage node to change the displayed image, and because there is no outgoing edge, execution returns to the dialogue which re-renders with the new image immediately.

SetBackgroundImage Node

The setBackgroundImage node is distinct from the dialogue image field. While the dialogue image is typically an inline illustration or scene image shown alongside text, the background image is intended for full-screen backgrounds, scene transitions, or environment changes.

When the runtime processes a setBackgroundImage node, it emits the background_image_changed signal:

GDScript
# Signal signature
signal background_image_changed(image_path: String)

# Connect to the signal on your StoryFlow component
@onready var storyflow: StoryFlowComponent = %StoryFlowComponent
@onready var background_rect: TextureRect = %BackgroundRect

func _ready() -> void:
    storyflow.background_image_changed.connect(_on_background_image_changed)

func _on_background_image_changed(image_path: String) -> void:
    # Load the texture from the resolved path
    var texture := load(image_path) as Texture2D

    if texture and background_rect:
        background_rect.texture = texture
        background_rect.visible = true

Dialogue image vs. background image

The dialogue image field on StoryFlowDialogueState is designed for inline illustrations that appear within the dialogue UI - think character CGs or scene images next to the text. The setBackgroundImage node is for full-screen or large-scale visuals that exist outside the dialogue box, such as a visual novel background or environment art. Handle them separately in your UI.

Resolved Assets System

The Resolved Assets system is the bridge between StoryFlow's string-based asset references and Godot's loaded resource objects. It operates at three levels using Dictionary maps:

  • StoryFlowCharacter.resolved_assets - Per-character assets. Character portrait images and character-specific media are checked first.
  • StoryFlowScript.resolved_assets - Per-script assets. Images that are used only within a specific script's nodes are resolved from this dictionary.
  • StoryFlowProject.resolved_assets - Project-wide assets shared across all scripts. Fallback for assets not found at the character or script level.

Each dictionary entry maps an asset key (String) to a loaded Resource. The importer resolves assets eagerly, so in the normal case the entry already holds the resource by the time the runtime asks for it. A String file path is stored only when that eager load failed. Resolution covers that case with a lazy retry: on first access, a string value is loaded and the dictionary entry is replaced with the loaded resource, so the retry happens at most once.

GDScript
# Simplified internal asset resolution logic
# (this happens inside the plugin automatically)

func resolve_asset(asset_key: String, character: StoryFlowCharacter,
        script: StoryFlowScript, project: StoryFlowProject) -> Resource:
    # 1. Check character resolved assets first
    if character and character.resolved_assets.has(asset_key):
        return _load_or_return(character.resolved_assets, asset_key)

    # 2. Check script resolved assets
    if script and script.resolved_assets.has(asset_key):
        return _load_or_return(script.resolved_assets, asset_key)

    # 3. Fall back to project resolved assets
    if project and project.resolved_assets.has(asset_key):
        return _load_or_return(project.resolved_assets, asset_key)

    return null

At runtime, when the execution context encounters an asset reference (for example, a dialogue node's image key), it looks up the key in the character's resolved_assets first, then the script's, then falls back to the project's. This three-tier lookup ensures that character-specific assets take priority, followed by script-specific assets, while shared assets remain accessible from any script.

Displaying Images in Your UI

To display images in your dialogue UI, read the image fields from StoryFlowDialogueState and apply them to TextureRect nodes.

Dialogue Image

The main dialogue image is available at StoryFlowDialogueState.image. This is a Texture2D that may be null if no image is assigned and the image was reset.

GDScript
# In your dialogue UI's update function
@onready var scene_image: TextureRect = %SceneImage
@onready var character_portrait: TextureRect = %CharacterPortrait

func _on_dialogue_updated(state: StoryFlowDialogueState) -> void:
    # Dialogue illustration / scene image
    if state.image:
        scene_image.texture = state.image
        scene_image.visible = true
    else:
        scene_image.visible = false

    # Character portrait (separate from dialogue image)
    if state.character and state.character.image:
        character_portrait.texture = state.character.image
        character_portrait.visible = true
    else:
        character_portrait.visible = false

Character Portraits

Character portrait images are separate from the dialogue image field. They are accessed through StoryFlowDialogueState.character.image, which is also a Texture2D. Every asset declared in characters.json is imported into the project-level resolved_assets, and the portrait is additionally referenced from the character's own dictionary, which is where portrait lookups check first.

For UIs that need to fetch a specific portrait outside the active dialogue flow, the StoryFlowComponent exposes get_character_portrait(path, asset_key). Passing an explicit asset_key is the canonical way to load alternate poses (for example, "happy", "angry") when those keys live in custom image-typed character variables. With asset_key left empty, the function returns the character's current portrait and reflects any setCharacterVar("Image", ...) mutations performed during dialogue. See Resolving Portraits at Runtime on the characters page for the full cascade and examples.

TextureRect Setup

In your dialogue scene, add TextureRect nodes for both the dialogue image and the character portrait. Set the texture property at runtime when the dialogue state updates. Toggle visibility based on whether the texture is valid to avoid displaying blank image boxes.

GDScript
# Typical TextureRect configuration for dialogue images
# Set these in the Inspector or via code:

# For scene/dialogue images (stretch to fit a content area)
@onready var scene_image: TextureRect = %SceneImage
# scene_image.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED
# scene_image.expand_mode = TextureRect.EXPAND_IGNORE_SIZE

# For character portraits (maintain aspect ratio)
@onready var portrait: TextureRect = %Portrait
# portrait.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
# portrait.expand_mode = TextureRect.EXPAND_FIT_WIDTH_PROPORTIONAL

Handling null images gracefully

Always check for null before setting the texture property. When state.image is null, hide the TextureRect rather than leaving a blank space. This keeps your dialogue UI clean when transitioning between nodes that have images and nodes that do not.

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