Been putting off writing a quest system for months because every time I started planning it I ended up in database-y design territory: relational tables in disguise, awkward state machines, JSON config files that need a separate loader. Overcomplicated every time.
Eventually sat down and did it the obvious Godot 4 way: Resource subclasses all the way down. Took maybe a day and it's been solid. The whole thing is three files.
The QuestObjective resource is just data plus an increment() method:
class_name QuestObjective
extends Resource
@export var id: StringName
@export var description: String
@export var required_count: int = 1
var current_count: int = 0
var is_complete: bool = false
func increment() -> bool:
if is_complete:
return false
current_count = min(current_count + 1, required_count)
if current_count >= required_count:
is_complete = true
return true
return false
The QuestData resource holds the objectives array and fires signals when things complete:
class_name QuestData
extends Resource
@export var quest_id: StringName
@export var title: String
@export var objectives: Array[QuestObjective] = []
signal objective_completed(objective: QuestObjective)
signal quest_completed(quest: QuestData)
func progress_objective(obj_id: StringName) -> void:
for obj in objectives:
if obj.id == obj_id and obj.increment():
objective_completed.emit(obj)
if objectives.all(func(o): return o.is_complete):
quest_completed.emit(self)
return
And the autoload manager is thin, tracking active quests, routing progress calls, keeping completed IDs so nothing gets re-added:
extends Node
signal quest_started(quest: QuestData)
signal quest_completed(quest: QuestData)
var active: Array[QuestData] = []
var completed_ids: Array[StringName] = []
func start(quest: QuestData) -> void:
if quest.quest_id in completed_ids: return
if active.any(func(q): return q.quest_id == quest.quest_id): return
active.append(quest)
quest.quest_completed.connect(_on_completed.bind(quest))
quest_started.emit(quest)
func progress(quest_id: StringName, obj_id: StringName) -> void:
for q in active:
if q.quest_id == quest_id:
q.progress_objective(obj_id)
return
func _on_completed(quest: QuestData) -> void:
active.erase(quest)
completed_ids.append(quest.quest_id)
quest_completed.emit(quest)
In practice it's just QuestManager.progress("find_the_key", "key_collected") from anywhere. No scene dependencies, no nodes that might not exist yet.
One gotcha: Resource instances are shared by default. If you load a quest .tres from disk and hand it directly to the manager, every place in the project that loaded the same resource is sharing state. You need to call .duplicate(true) on start if the resource will be reused across playthroughs. Bit of a footgun the first time you hit it.
Anyone else structuring quest state this way? Specifically curious about save/load. Right now I'm serializing the completed_ids array and each active quest's objective progress counts separately, then rebuilding state on load. Works, but it feels slightly fragile. Is there a cleaner pattern here?