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:

  1. references a @export var active_quest: QuestData
  2. on _ready(), checks required_flags against ProgressState
  3. exposes signal quest_completed(quest_id: String)
  4. when completion conditions are met (for example picking up an item area), calls ProgressState.set_flag(active_quest.completion_flag, true) and emits quest_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:

  • CanvasLayer root (layer above gameplay)
  • PanelContainer with a VBoxContainer
  • Label for speaker name (optional)
  • RichTextLabel for body text
  • Label hint 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, call ProgressState.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_quest starts, set text to quest.title
  • when quest_completed fires, 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: CanvasLayer visible off, or interact action not mapped in this scene.
  • Quest never completes: completion_flag never 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:

  • QuestData Resources 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.