// personal reference · 3D in-engine scripted event sequences
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.
The cutscene freezes when dialog opens and waits for the player to finish reading. Once dialog closes, automatic execution resumes.
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 },
]
| 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 |
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
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()
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()
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.
# 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()
"dialog":
DialogSystem.start(cmd["dialog_id"])
await DialogSystem.dialog_finished # ← freeze here
play_next() # ← resume after player done
# 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")
# 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
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)
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 },
])