godot-quest-system

SKILL.md

Quest System

Resource-based data, signal-driven updates, and AutoLoad coordination define scalable quest architectures.

Available Scripts

quest_manager.gd

Expert AutoLoad quest tracker with objective progression and reward distribution.

quest_graph_manager.gd

Runtime manager for graph-based quests. Tracks objectives and node progression.

NEVER Do in Quest Systems

  • NEVER store quest data in nodes — Quest progress in player.gd variables? Loss on scene reload. Use Resource-based Quests OR AutoLoad singleton.
  • NEVER use strings for quest IDs without registryupdate_objective("kil_bandts", ...) typo = silent failure. Use constants OR validate IDs against registry.
  • NEVER forget to disconnect signals — Quest completed but signal still connected? Quest completes again on next update = duplicate rewards. Disconnect in _on_quest_completed().
  • NEVER poll for objective updates — Checking if enemy_count == 10 every frame = wasteful. Use signals: enemy.died.connect(quest_manager.on_enemy_killed).
  • NEVER skip save/load for quests — Player completes quest 5, game restarts, quest resets? Frustration. MUST persist active_quests + completed_quests arrays.
  • NEVER use all() for objectives without null checkobjectives.all(func(obj): return obj.is_complete()) with null objectives? Crash. Validate array contents first.

# quest.gd
class_name Quest
extends Resource

signal progress_updated(objective_id: String, progress: int)signal completed

@export var quest_id: String
@export var quest_name: String
@export_multiline var description: String
@export var objectives: Array[QuestObjective] = []
@export var rewards: Array[QuestReward] = []
@export var required_level: int = 1

func is_complete() -> bool:
    return objectives.all(func(obj): return obj.is_complete())

func check_completion() -> void:
    if is_complete():
        completed.emit()

Quest Objectives

# quest_objective.gd
class_name QuestObjective
extends Resource

enum Type { KILL, COLLECT, TALK, REACH }

@export var objective_id: String
@export var type: Type
@export var target: String  # Enemy name, item ID, NPC name, location
@export var required_amount: int = 1
@export var current_amount: int = 0

func progress(amount: int = 1) -> void:
    current_amount += amount
    current_amount = mini(current_amount, required_amount)

func is_complete() -> bool:
    return current_amount >= required_amount

Quest Manager

# quest_manager.gd (AutoLoad)
extends Node

signal quest_accepted(quest: Quest)
signal quest_completed(quest: Quest)
signal objective_updated(quest: Quest, objective: QuestObjective)

var active_quests: Array[Quest] = []
var completed_quests: Array[String] = []

func accept_quest(quest: Quest) -> void:
    if quest.quest_id in completed_quests:
        return
    
    active_quests.append(quest)
    quest.completed.connect(func(): _on_quest_completed(quest))
    quest_accepted.emit(quest)

func _on_quest_completed(quest: Quest) -> void:
    active_quests.erase(quest)
    completed_quests.append(quest.quest_id)
    
    # Give rewards
    for reward in quest.rewards:
        reward.grant()
    
    quest_completed.emit(quest)

func update_objective(quest_id: String, objective_id: String, amount: int = 1) -> void:
    for quest in active_quests:
        if quest.quest_id == quest_id:
            for obj in quest.objectives:
                if obj.objective_id == objective_id:
                    obj.progress(amount)
                    objective_updated.emit(quest, obj)
                    quest.check_completion()
                    return

func get_active_quest(quest_id: String) -> Quest:
    for quest in active_quests:
        if quest.quest_id == quest_id:
            return quest
    return null

Quest Triggers

# Example: Kill quest integration
# enemy.gd
func _on_died() -> void:
    QuestManager.update_objective("kill_bandits", "kill_bandit", 1)

# Example: Collection integration
# item_pickup.gd
func _on_collected() -> void:
    QuestManager.update_objective("gather_herbs", "collect_herb", 1)

# Example: Talk integration
# npc.gd
func interact() -> void:
    DialogueManager.start_dialogue(dialogue_id)
    QuestManager.update_objective("find_elder", "talk_to_elder", 1)

Quest UI

# quest_ui.gd
extends Control

@onready var quest_list := $QuestList

func _ready() -> void:
    QuestManager.quest_accepted.connect(_on_quest_accepted)
    QuestManager.objective_updated.connect(_on_objective_updated)
    refresh_ui()

func refresh_ui() -> void:
    for child in quest_list.get_children():
        child.queue_free()
    
    for quest in QuestManager.active_quests:
        var quest_entry := create_quest_entry(quest)
        quest_list.add_child(quest_entry)

func create_quest_entry(quest: Quest) -> Control:
    var entry := VBoxContainer.new()
    
    var title := Label.new()
    title.text = quest.quest_name
    entry.add_child(title)
    
    for obj in quest.objectives:
        var obj_label := Label.new()
        obj_label.text = "%s: %d/%d" % [obj.target, obj.current_amount, obj.required_amount]
        entry.add_child(obj_label)
    
    return entry

Best Practices

  1. Signal-Driven - Emit events, systems listen
  2. Save Progress - Track completed quests
  3. Validation - Check prerequisites before accepting

Reference

  • Related: godot-dialogue-system, godot-save-load-systems

Related

Weekly Installs
49
GitHub Stars
35
First Seen
Feb 10, 2026
Installed on
gemini-cli48
codex48
opencode47
kimi-cli46
amp46
github-copilot46