Collect and kill quest objectives in Godot 4

Build a Godot 4 quest system where collect and kill objectives track progress from combat and inventory signals, then auto-complete, with zero coupling upstream.

The player picks up the fifth herb and the quest log should tick over to done. Somewhere else, three wolves die and a different quest closes. Neither the herb pickup nor the wolf's death knows a quest exists, and in a Godot 4 quest system built around collect and kill objectives, that ignorance is the whole point. The pickup code lives in your loot pipeline. The death lives in whatever your enemies do when their health hits zero. The quest system sits off to the side and gets told, after the fact, that a thing happened. Its job is to decide whether that thing counts, and to fire a signal once a count is satisfied.

So the design question is not how to track a number. Anyone can track a number. It is how the quest system hears about kills and pickups without reaching into combat or inventory to subscribe to them. The answer here is deliberately dumb. The rest of the game calls two functions on an autoload: QuestsLite.report_collect("herb", 1) and QuestsLite.report_kill("wolf", 1). The quest system owns no references to anything upstream. It just exposes a mailbox and waits.

The two data resources

An objective is a small Resource. It carries the kind of thing to watch, the id that names the target, the count you need, and nothing more. That is the entire type.

class_name QuestObjectiveLite
extends Resource

enum Type { COLLECT, KILL }

@export var id: String = ""
@export var type: Type = Type.COLLECT
## For COLLECT: the item id (string) to match.
## For KILL: the enemy id (string) to match.
@export var target_id: String = ""
@export var required: int = 1

A quest is just a bag of those, plus display text. Note the typed array. Godot 4.5 will reject a plain Array assigned into it, so when you build objectives in code you have to pack them into an Array[QuestObjectiveLite] first.

class_name QuestLite
extends Resource

@export var id: String = ""
@export var title: String = ""
@export_multiline var description: String = ""
@export var objectives: Array[QuestObjectiveLite] = []

Both are Resources on purpose. You can author a whole quest as a .tres in the Inspector, no code required, and the target_id string is the seam. The "herb" in the objective has to be the same "herb" your inventory item reports. Nobody imports anybody. They just agree on a string.

Registering and starting

The manager is an autoload node. When you register a quest it stashes the resource, marks it AVAILABLE, and pre-seeds a progress dictionary with a zero for every objective id. That pre-seed matters more than it looks, and I will come back to why.

enum State { AVAILABLE, ACTIVE, COMPLETE }

var _quests: Dictionary = {}    # id -> QuestLite
var _state: Dictionary = {}     # id -> State
var _progress: Dictionary = {}  # id -> { objective_id -> int }

func register(quest: QuestLite) -> void:
    if quest == null or String(quest.id) == "":
        return
    _quests[quest.id] = quest
    _state[quest.id] = State.AVAILABLE
    _progress[quest.id] = {}
    for o in quest.objectives:
        if o == null:
            continue
        _progress[quest.id][String(o.id)] = 0
    quest_registered.emit(quest.id)

Starting is a state flip guarded so you cannot start something twice. Only an AVAILABLE quest becomes ACTIVE, and that guard is the reason a completed quest never re-arms from a stray report.

func start_quest(quest_id: String) -> bool:
    if not _quests.has(quest_id):
        return false
    if int(_state.get(quest_id, State.AVAILABLE)) != State.AVAILABLE:
        return false
    _state[quest_id] = State.ACTIVE
    quest_started.emit(quest_id)
    return true

How a collect or kill report finds its objective

This is the part that does the work, and it is worth reading slowly. report_collect and report_kill are two-line wrappers. Both hand off to one private _report with the type baked in. Everything interesting happens in that shared body.

func report_collect(item_id: String, amount: int = 1) -> void:
    _report(QuestObjectiveLite.Type.COLLECT, item_id, amount)

func report_kill(enemy_id: String, amount: int = 1) -> void:
    _report(QuestObjectiveLite.Type.KILL, enemy_id, amount)

A report does not address a quest. It broadcasts. The manager walks every active quest, then every objective inside it, and advances the ones whose type and target_id both match. A kill report for "wolf" that lands during three concurrent quests will advance all three, provided all three are watching wolves. That fan-out is a feature. It means "kill 10 wolves for the hunter" and "kill 1 wolf, any wolf, for the tutorial" both progress off the same death, and neither knows the other exists.

func _report(type: int, target_id: String, amount: int) -> void:
    if amount <= 0:
        return
    for quest_id in _quests.keys():
        if not is_active(String(quest_id)):
            continue
        var quest: QuestLite = _quests[quest_id]
        for o in quest.objectives:
            if o == null or int(o.type) != type:
                continue
            if String(o.target_id) != target_id:
                continue
            var key := String(o.id)
            var current := int(_progress[quest_id].get(key, 0))
            if current >= int(o.required):
                continue
            var new_val := min(int(o.required), current + amount)
            _progress[quest_id][key] = new_val
            objective_progressed.emit(String(quest_id), key, new_val, int(o.required))
            if new_val >= int(o.required):
                objective_completed.emit(String(quest_id), key)
        _check_completion(String(quest_id))

Four small decisions in there earn their place. The amount <= 0 guard drops negative and zero reports before they can corrupt a count. The inactive skip means reports fired before you start a quest, or after it is done, cost one dictionary lookup and vanish. You can call report_kill all day and nothing accumulates until the quest is ACTIVE. The current >= required check makes an already-finished objective ignore further reports, so an overkill does not push the counter past its cap or re-emit the completion signal. And the min(required, current + amount) clamp means a bulk pickup of five herbs against a two-herb objective lands at two, not seven, so your UI never has to render "7/2".

Completing the quest

Objective completion and quest completion are separate events, fired at different moments. An objective completes inline the instant its count hits required, right there in _report. The quest is checked once per report, after the objective loop, by scanning for any objective still short of its target.

func _check_completion(quest_id: String) -> void:
    var quest: QuestLite = _quests[quest_id]
    for o in quest.objectives:
        if o == null:
            continue
        if int(_progress[quest_id].get(String(o.id), 0)) < int(o.required):
            return
    _state[quest_id] = State.COMPLETE
    quest_completed.emit(quest_id)

Early return on the first unmet objective, then flip to COMPLETE and emit only if the loop survives to the end. Because _progress was pre-seeded at registration with a zero per objective, that .get(..., 0) never has to invent a default for a real objective. Every id it looks up already exists. This is why the pre-seed in register was worth flagging: it turns completion checking into a plain comparison with nothing to guard against a missing key.

The signals are your only reward hook. The manager grants nothing itself. You listen and do the granting in your own code, which keeps loot and currency out of the quest system entirely.

QuestsLite.objective_progressed.connect(func(qid, oid, current, required):
    hud.update_tracker(qid, oid, current, required))

QuestsLite.quest_completed.connect(func(qid):
    give_reward_for(qid)   # your currency, your items, whatever you grant
    play_fanfare())

One detail on that objective_progressed signature: it carries four arguments, quest_id, objective_id, current, and required. If you only want the id and connect a two-arg handler, bind the extras off with .unbind(2). Or use the demo's trick of .unbind(4) when your refresh callback wants none of them. A signature mismatch on connect is a runtime error, not a warning.

Wiring it to your game

This is the honest boundary of the Lite core, and it is a design choice rather than a missing feature. The manager does not subscribe to anything. It has no idea your inventory or your combat system exist. You call report_collect and report_kill from the code that already knows a pickup or a death happened.

# in your loot pickup, right after the item lands in the bag:
func on_item_picked_up(item: Resource, n: int) -> void:
    inventory.add_item(item, n)
    QuestsLite.report_collect(String(item.id), n)

# in your enemy's death handler:
func _on_died() -> void:
    emit_signal("died")
    QuestsLite.report_kill(enemy_id, 1)

Two call sites, and the coupling only ever points one direction: game code knows about quests, quests know about nobody. If you would rather your pickup code not name the quest system directly, put a signal bus autoload in the middle. Have inventory emit item_acquired(id, n), have combat emit enemy_killed(id), and connect those to report_collect and report_kill in one place. Now the two systems share no names at all. They both only know the bus. The manager stays a pure sink either way, and that is what lets you test it with four button presses and no game attached.

That bus-wiring is exactly the line between this free core and the paid version. The Lite manager is the full progress engine, honestly: registration, the fan-out matcher, the clamp, and the completion signals at both the objective and quest level. What it does not ship is the shared Events autoload that auto-listens for item_acquired and enemy_killed so you write zero glue, plus the six other objective types (USE, CURRENCY, TALK, REACH, FLAG, CUSTOM), quest chains via prerequisites, and rewards that grant themselves. You can build the bus yourself in an afternoon on top of what is here, and the two report functions are the only surface you need to hit.

Or use mine

Quests Lite, free on GitHub · Quests System, $4.99

All Godot posts · Home