Godot 4 · 3D · GDScript · Game Systems

Cutscene
System Documentation

// personal reference · 3D in-engine scripted event sequences

01 What is a Cutscene System

An in-engine cutscene (also called scripted event or event scene) is a sequence of automated actions — character movement, expressions, dialog — that plays without player control, except when dialog appears.

Term Reference In-engine cutscene · Scripted sequence · Event scene · Story event — all refer to the same thing. Not a video file, uses actual game graphics and characters.

// Control flow

Automatic actions
Dialog (player presses next)
Automatic continues

The cutscene freezes when dialog opens and waits for the player to finish reading. Once dialog closes, automatic execution resumes.

02 Command Queue Concept CONCEPT

The entire cutscene is stored as an array of command dictionaries. The manager processes them one by one, executing each before moving to the next.

# Example: Event Scene 07 — all positions are Vector3(x, y, z)
var queue = [
    { "type": "move", "target": "guard", "to": Vector3(300, 0, 200), "speed": "walk", "parallel": false },
    { "type": "expression", "target": "guard", "emote": "exclamation", "parallel": false },
    { "type": "dialog", "dialog_id": 189, "parallel": false },
    { "type": "move", "target": "hero", "to": Vector3(600, 0, 200), "speed": "run", "parallel": false },
    { "type": "spawn", "target": "gate_guard", "at": Vector3(700, 0, 200), "parallel": false },
    { "type": "dialog", "dialog_id": 190, "parallel": false },
]
Same thinking as your other systems Array of dictionaries — same pattern as your dialog system and door table. You already understand this structure!

03 Command Types

type parameters control parallel support description
move target, to, speed automatic yes move character to position (walk/run)
wait duration automatic no pause for X seconds
expression target, emote automatic yes show emote bubble (!, ?, ♥ etc)
dialog dialog_id player (press next) no open dialog system, freeze until done
spawn target, at automatic yes make character appear at position
despawn target automatic yes hide or remove character
sync automatic no wait for ALL parallel actions to finish
camera target / position automatic yes pan camera to position or follow target

04 Sequential Execution AWAIT

By default all commands are sequential — each one waits until fully finished before the next starts. This uses Godot's await keyword.

# Sequential: guard walks, THEN dialog opens
{ "type": "move", "target": "guard", "to": Vector2(300,200), "parallel": false }
{ "type": "dialog", "dialog_id": 189, "parallel": false }

# In manager:
var move_action = target.move_to(cmd["to"], cmd["speed"])
await move_action  # ← waits here until character arrives
play_next()        # ← only then continues
How await works await pauses execution at that line until the signal fires or coroutine finishes. Nothing else is blocked — only this function waits. The rest of the game continues normally.

05 Parallel Execution PARALLEL

When parallel: true, the command starts and immediately moves to the next without waiting. Used when two characters should move at the same time.

# Parallel: both guards move at same time
{ "type": "move", "target": "guard_01", "to": Vector2(300,200), "speed": "walk", "parallel": true }
{ "type": "move", "target": "guard_02", "to": Vector2(500,200), "speed": "walk", "parallel": true }
{ "type": "sync" }  # ← wait for BOTH to finish
{ "type": "dialog", "dialog_id": 189, "parallel": false }

# In manager:
if is_parallel:
    target.move_to(...)  # start but dont await
    play_next()          # immediately continue to next command
else:
    await target.move_to(...)
    play_next()

06 Sync Command

Sync waits for all currently running parallel actions to finish before continuing. Essential after a group of parallel commands.

var parallel_actions: Array = []

# When parallel command starts, track it
func start_parallel(action):
    parallel_actions.append(action)

# Sync command waits for all parallel to finish
"sync":
    for action in parallel_actions:
        await action
    parallel_actions.clear()
    play_next()
Remember Always add sync after a group of parallel commands if the next command depends on them finishing. Forgetting sync causes dialog to open before characters arrive.

07 Combining with Dialog System

Dialog system must emit a signal when player finishes all lines. The cutscene manager awaits this signal — freezing until player presses next on the last line.

// Dialog system requirement

# DialogSystem.gd — must have this signal
extends Node

signal dialog_finished

func start(dialog_id: int):
    # load dialog lines by id
    # show dialog UI
    # when player presses next on LAST line:
    dialog_finished.emit()

// Cutscene manager handles dialog command

"dialog":
    DialogSystem.start(cmd["dialog_id"])
    await DialogSystem.dialog_finished  # ← freeze here
    play_next()                          # ← resume after player done

// Control flow with dialog

AUTOMATIC
guard walks to hero position
AUTOMATIC
exclamation mark appears above guard
PLAYER CONTROL — cutscene freezes
dialog 189 opens → player reads → presses next → dialog_finished emits
AUTOMATIC — resumes
hero runs toward gate
AUTOMATIC
gate guard spawns from side
PLAYER CONTROL — cutscene freezes
dialog 190 opens → player reads → presses next → dialog_finished emits
AUTOMATIC — resumes
cutscene ends, player control returns

08 Full Cutscene Manager Code SYSTEM

# CutsceneManager.gd — Autoload
extends Node

var queue: Array = []
var is_playing: bool = false
var parallel_actions: Array = []

func play(commands: Array):
    queue = commands.duplicate()
    is_playing = true
    play_next()

func play_next():
    if queue.is_empty():
        is_playing = false
        on_cutscene_end()
        return

    var cmd = queue.pop_front()
    var is_parallel = cmd.get("parallel", false)

    match cmd["type"]:

        "move":
            var target = get_node(cmd["target"])
            var action = target.move_to(cmd["to"], cmd["speed"])
            if is_parallel:
                parallel_actions.append(action)
                play_next()
            else:
                await action
                play_next()

        "wait":
            await get_tree().create_timer(cmd["duration"]).timeout
            play_next()

        "expression":
            var target = get_node(cmd["target"])
            target.show_emote(cmd["emote"])
            if is_parallel:
                play_next()
            else:
                await get_tree().create_timer(1.0).timeout
                play_next()

        "dialog":
            DialogSystem.start(cmd["dialog_id"])
            await DialogSystem.dialog_finished
            play_next()

        "spawn":
            var target = get_node(cmd["target"])
            target.position = cmd["at"]
            target.visible = true
            play_next()

        "despawn":
            var target = get_node(cmd["target"])
            target.visible = false
            play_next()

        "sync":
            for action in parallel_actions:
                await action
            parallel_actions.clear()
            play_next()

func on_cutscene_end():
    # return player control
    # re-enable player input
    print("Cutscene finished")

09 Character Move Code

# Character.gd — attach to hero, guard, NPC etc
extends CharacterBody3D

const WALK_SPEED = 80.0
const RUN_SPEED = 180.0

func move_to(destination: Vector3, speed_type: String):
    var speed = WALK_SPEED if speed_type == "walk" else RUN_SPEED

    # set animation
    $AnimationPlayer.play(speed_type)

    # move until close enough — Vector3.move_toward
    while global_position.distance_to(destination) > 0.1:
        global_position = global_position.move_toward(destination, speed * get_process_delta_time())
        await get_tree().process_frame

    # snap to exact position
    global_position = destination

    # return to idle animation
    $AnimationPlayer.play("idle")

func show_emote(emote_type: String):
    $EmoteBubble.texture = load("res://emotes/" + emote_type + ".png")
    $EmoteBubble.visible = true
    await get_tree().create_timer(1.5).timeout
    $EmoteBubble.visible = false

10 Full Example — Event Scene 07

Guard approaches hero → expression → dialog → hero runs → gate guard spawns → dialog.

# EventScene07.gd
func _ready():
    # disable player input during cutscene
    $Hero.set_process_input(false)

    CutsceneManager.play([
        # guard walks toward hero — Vector3(x, y, z)
        { "type": "move", "target": "Guard", "to": Vector3(300,0,200), "speed": "walk", "parallel": false },

        # exclamation mark appears
        { "type": "expression", "target": "Guard", "emote": "exclamation", "parallel": false },

        # dialog opens — player reads and presses next
        { "type": "dialog", "dialog_id": 189, "parallel": false },

        # hero runs to gate
        { "type": "move", "target": "Hero", "to": Vector3(600,0,200), "speed": "run", "parallel": false },

        # gate guard appears from side
        { "type": "spawn", "target": "GateGuard", "at": Vector3(700,0,200), "parallel": false },
        { "type": "wait", "duration": 0.3, "parallel": false },

        # second dialog
        { "type": "dialog", "dialog_id": 190, "parallel": false },
    ])

    # re-enable player after cutscene
    await CutsceneManager.cutscene_ended
    $Hero.set_process_input(true)

// Parallel example — two guards surround hero

CutsceneManager.play([
    # both guards move at same time — Vector3(x, y, z)
    { "type": "move", "target": "Guard01", "to": Vector3(250,0,200), "speed": "walk", "parallel": true },
    { "type": "move", "target": "Guard02", "to": Vector3(550,0,200), "speed": "walk", "parallel": true },

    # wait for both to arrive
    { "type": "sync" },

    # both show exclamation at same time
    { "type": "expression", "target": "Guard01", "emote": "exclamation", "parallel": true },
    { "type": "expression", "target": "Guard02", "emote": "exclamation", "parallel": false },

    { "type": "dialog", "dialog_id": 191, "parallel": false },
])

11 Summary

Build Order Suggestion Start with move + dialog only. Test with a simple 2-command scene. Add expression, spawn, sync later once the core queue works correctly.