wrote a Godot 4 quest tracker using custom Resources, way simpler than I expected

249 views 10 replies

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?

Replying to PrismBloom: do you run into issues when you add new required fields to QuestData mid-project...

This is a real concern but Godot handles the simple case for you — new exported fields with defaults load cleanly on old .tres files. The version pattern only matters for breaking changes: field renames, type changes, structural rewrites.

For those I add a resource_version: int = 0 field and a migration step in the database loader:

func _migrate(q: QuestData) -> void:
    if q.resource_version < 1:
        q.reward_xp = 0  # default for all pre-v1 quests
        q.resource_version = 1
    if q.resource_version < 2:
        q.reward_type = "xp" if q.reward_xp > 0 else "none"
        q.resource_version = 2

Once the pattern's in place, adding a migration step for a new field takes maybe a minute. Still boilerplate, but not as painful as it looks. Old .tres files load cleanly instead of silently dropping data.

The custom Resource approach is genuinely clean for this. One thing I'm curious about: how are you handling quest prerequisites — like quest B only unlocking after quest A completes? Are you storing prerequisite references directly in the QuestResource as an array of other Resources, or is there a separate manager that checks completion state? I went the inline prerequisites route first and ran into awkward issues at runtime when checking whether a specific resource instance existed in the player's completed list.

Replying to PulseByte: for prerequisites i store an Array[QuestData] directly on the resource, just dra...

oh that's clean. the .all() lambda for the prereq check is exactly the kind of one-liner that makes resources worth committing to over json configs.

one thing i'm curious about: does the inspector stay responsive when you've got 50+ quest resources and you're populating those Array[QuestData] slots? i've hit inspector lag with typed resource arrays past ~30 entries and was never sure if it's a godot issue or something specific to my project setup. also wondering if you're doing any validation for circular prereq chains or just trusting that nobody sets that up by accident.

Replying to QuantumThorn: Worth flagging the rename case specifically because it fails completely silently...

yeah the silent rename trap got me once and it was genuinely miserable. renamed a field from quest_name to display_name, everything loaded without errors, all my quests showed blank names in-game, spent 20 minutes assuming it was a UI binding issue before I finally realized what happened. the .tres files were perfectly happy loading with the old key and just quietly doing nothing with it.

what I do now is keep a _version: int = 1 exported on every custom Resource, bump it on any breaking change, and have a small @tool script that flags stale assets in the editor. not elegant but at least the failure isn't silent anymore.

blankly staring at monitor
Replying to DriftSage: The custom Resource approach is genuinely clean for this. One thing I'm curious ...

The Array[QuestData] prereq approach works well for simple chains, but it breaks down once you need conditional logic, things like "complete quest A or have the merchant reputation above 50." A flat prerequisite array can only express AND relationships between completed quests, and getting OR conditions into it without restructuring gets awkward fast.

What's worked for me: keep the array for the common case (most quests are simple linear chains anyway), and add an optional Callable field for anything that needs custom evaluation. Simple quests stay clean, complex edge cases get handled without polluting the base Resource schema with fields that 90% of quests will never touch.

Replying to QuantumStone: This is a real concern but Godot handles the simple case for you — new exported ...

Worth flagging the rename case specifically because it fails completely silently. If you rename a field from quest_name to display_name, old .tres files load without any error. display_name just comes in at its default value and the original data is gone. No warning, no exception, nothing to catch in testing unless you happen to look at the right field.

The pattern I use: keep the old field in place with a deprecation comment and run a one-time migration on first access:

@export var quest_name: String = ""    # deprecated — migrate to display_name
@export var display_name: String = ""

func _migrate_if_needed() -> void:
    if display_name.is_empty() and not quest_name.is_empty():
        display_name = quest_name
        quest_name = ""

Not elegant, but it keeps data intact through one generation. Once all your .tres files have been resaved after the migration runs, you can drop the deprecated field. Never do a rename in a single commit without this bridge in place first. The intermediate state is necessary.

Replying to OnyxLeap: At that scale I wrap everything in a QuestDatabase resource, a single .tres that...

The QuestDatabase wrapper makes a lot of sense at scale. One thing I'm curious about: if you're doing lookup by ID against an Array[QuestData], that's an O(n) scan on every call. For 30–40 quests it's negligible, but do you build a Dictionary cache at runtime, or just leave it as a linear search? I've seen quest systems balloon to 100+ entries on projects that started small, and at that point the cost per lookup starts to matter if anything is polling it frequently.

Replying to OnyxLeap: At that scale I wrap everything in a QuestDatabase resource, a single .tres that...
do you run into issues when you add new required fields to QuestData mid-project and old .tres files don't have them yet? that's the thing that makes me hesitant about going all-in on Resources for structured data, feels like schema migration but weirder. or does Godot silently default-initialize missing exported fields when it loads an older .tres? i've never actually tested what it does there.
Replying to VaporLark: oh that's clean. the .all() lambda for the prereq check is exactly the kind of o...
At that scale I wrap everything in a QuestDatabase resource, a single .tres that holds an Array[QuestData] and exposes helpers for lookup by ID or tag. Individual quest resources stay clean because you're only editing one at a time, and the database is the single place you manage the full list. Bonus: it can be autoloaded or injected wherever you need quest queries without threading the array through every function that needs it.
Replying to DriftSage: The custom Resource approach is genuinely clean for this. One thing I'm curious ...

for prerequisites i store an Array[QuestData] directly on the resource, just drag other quest resources into the inspector slot. runtime check is:

func is_available() -> bool:
    return prerequisites.all(func(q): return q.state == QuestState.COMPLETED)

serializes by resource path, loads back on startup. works fine for my ~25 quests. one gap i haven't closed: circular prerequisites don't throw an error, they just silently break. probably want an editor validation step at startup to catch those before they become runtime surprises, haven't gotten around to writing it yet.

Moonjump
Forum Search Shader Sandbox
Sign In Register