Lesson Goal
Your level now has geometry and combat from Lessons 4 through 6.
This lesson adds reasons to move: a tiny quest pipeline and a dialogue surface you can extend without rewriting scenes every time a writer changes a line.
By the end of this lesson, you will:
- model a quest as data (Resource) with explicit states
- drive objective text on the HUD from a single autoload or level controller
- show one-page NPC dialogue with next-line input
- emit signals when objectives complete so combat and level scripts can react
Step 1 - Define the smallest useful quest model
Create a new script quest_data.gd that extends Resource:
extends Resource
class_name QuestData
@export var id: String = "find_key"
@export var title: String = "Find the cellar key"
@export var description: String = "Old Marta mentioned a rusted key by the well."
@export var required_flags: PackedStringArray = ["talked_to_marta"]
@export var completion_flag: String = "has_cellar_key"
You are not building a AAA quest editor. You are building a contract: designers edit Resources in the inspector, code checks flags.
Mini task:
Create res://data/quests/cellar_key.tres and assign the script above with your own copy.
Step 2 - Track world flags in one place
Add an autoload ProgressState (or reuse your project’s name) with:
extends Node
signal flag_changed(name: String, value: bool)
var flags: Dictionary = {}
func set_flag(name: String, value: bool) -> void:
flags[name] = value
flag_changed.emit(name, value)
func has_flag(name: String) -> bool:
return flags.get(name, false)
Every system that cares about story state listens to flag_changed or queries has_flag.
Common mistake:
Spreading var key_found across five scripts. Centralize flags early or debugging narrative breaks becomes impossible.
Step 3 - Implement a QuestRunner node
Add a Node to your level called QuestRunner with a script that:
- references a
@export var active_quest: QuestData - on
_ready(), checksrequired_flagsagainstProgressState - exposes
signal quest_completed(quest_id: String) - when completion conditions are met (for example picking up an item area), calls
ProgressState.set_flag(active_quest.completion_flag, true)and emitsquest_completed
Keep completion rules boring and explicit for this lesson: one interactable or one Area2D pickup.
Step 4 - Minimal dialogue UI
Create ui/dialogue_panel.tscn:
CanvasLayerroot (layer above gameplay)PanelContainerwith aVBoxContainerLabelfor speaker name (optional)RichTextLabelfor body textLabelhint line:Press Interact to continue
Script dialogue_panel.gd:
extends CanvasLayer
signal line_finished
signal dialogue_closed
var lines: PackedStringArray = []
var index: int = 0
@onready var body: RichTextLabel = %Body
func start_dialogue(speaker: String, text_lines: PackedStringArray) -> void:
lines = text_lines
index = 0
visible = true
_show_line()
func _unhandled_input(event: InputEvent) -> void:
if not visible:
return
if event.is_action_pressed("interact"):
get_viewport().set_input_as_handled()
index += 1
if index >= lines.size():
visible = false
dialogue_closed.emit()
else:
line_finished.emit()
_show_line()
func _show_line() -> void:
body.text = lines[index]
Wire interact to the same action you use for doors. If you use a different name, stay consistent with Lesson 4.
Step 5 - NPC trigger zone
On an NPC CharacterBody2D or Area2D:
- detect player enter with
body_entered(mask player layer only) - on first interaction, call
dialogue_panel.start_dialogue("Marta", PackedStringArray(["...", "..."])) - after
dialogue_closed, callProgressState.set_flag("talked_to_marta", true)
Pro tip:
Use a has_talked local bool on the NPC so spamming interact does not re-open the same cutscene unless you want repeatable barks.
Step 6 - HUD objective line
Add a Label to your HUD that subscribes to QuestRunner or ProgressState:
- when
active_queststarts, set text toquest.title - when
quest_completedfires, show a short “Quest complete” flash then clear or chain the next quest id
Keep typography large enough to read on 1366x768.
Troubleshooting
- Dialogue never shows:
CanvasLayervisible off, or interact action not mapped in this scene. - Quest never completes:
completion_flagnever set; add a print in the pickup handler. - Flags desync after reload: you have not saved yet; Lesson 11 will add persistence. For now, document that playtests reset flags on run.
Common Mistakes to Avoid
- storing quest progress only in UI nodes
- parsing free-form text files before you have a working Resource pipeline
- coupling dialogue timing to animation frames before the text flow is stable
Mini Challenge
Add a second quest that only becomes available when has_cellar_key is true. Gate the NPC line with an if ProgressState.has_flag(...) branch and a different PackedStringArray of lines.
FAQ
Should I use Dialogue Manager or custom?
For this course slice, custom keeps ownership clear. Plugins are fair game after you understand signals and data.
JSON vs Resource?
Resources are editor-friendly. JSON helps if non-Godot writers edit files. Pick one for v1.
Quick Recap
You now have:
QuestDataResources for designer-friendly objectives- centralized flags for narrative state
- a reusable dialogue panel driven by input
- HUD text tied to quest lifecycle
Next, you will add inventory and items so keys and pickups have a home in data, not just flags.
For more signal patterns, revisit Scene Management and Signals in the Godot guide. For a deeper dialogue write-up, see How to Build a Simple Dialogue and Quest System in Godot 4.
Bookmark this lesson before writers send new drafts—you will remap lines, not architecture.