A quest where you take a request from a villager, gather herbs, and report back for a reward is built by splitting it into a quest sheet that doesn't change and a progress record that changes as you play. The quest sheet is written in a custom resource .tres, and the progress record in a dictionary on an Autoload.
Give the progress record four states that only move forward. For the reward, move the state to "rewarded" first, then give it. That way, mismatches like picking up herbs without the villager's lines changing, or getting the reward every time you talk, don't happen.
This article explains how to separate the quest sheet from the progress record and build one quest where you gather herbs and report back.
What You'll Learn
- Why the quest sheet (
.tres) and the progress record (an Autoload's dictionary) are kept separate- The four states, not started, active, completed, and rewarded, and a design where they only move forward
- The flow that counts pickups and switches to "report back"
- The order that gives the reward only once
- Hands-on: gather three herbs, report back, and receive the reward only once
Tested with: Godot 4.7
The quest sheet and the progress record
A quest has parts that don't change and parts that do. "Gather three herbs" and "the reward is 100G" are the same for anyone who takes the quest. "Two gathered so far" and "already received the reward" change as you play.
Call the unchanging part the quest sheet, and write it in a custom resource .tres. Call the changing part the progress record, and write it in a dictionary on an Autoload (a node that stays around even when you switch scenes). A dictionary is a Dictionary that holds pairs of names and values (arrays and dictionaries). The quest sheet isn't rewritten during play.

The reason to split them lies in how .tres files behave. When loaded normally, every place that loads the same .tres is looking at the same single piece of data. If you write "gathered count" into the quest sheet, the count for every other villager using the same quest sheet moves along with it. Keep the quest sheet read-only and write the count in the record. How to make a .tres and how sharing works are covered in Custom Resource Basics.

In other engines: In the sense of holding definition data as an asset, Godot's custom resource (
.tres) plays a role similar to Unity's ScriptableObject or UE's Data Asset.
A quest's four states
Give the progress record one state. A quest is always in one of these four.
| State | Meaning |
|---|---|
NOT_STARTED | Haven't heard the request yet |
ACTIVE | Accepted and gathering (Herbs 1 / 3) |
COMPLETED | All gathered. Not reported yet |
REWARDED | Reported and received the reward |
What you want to keep separate is COMPLETED and REWARDED. If you only keep a single "is it complete," the state where you want the player to report and the state where the reward has already been given look the same. The villager's lines can't tell "hear the report" from "already paid" either.

Put everything that changes the state into one function, and accept only changes that move forward. Changes to the same state or back to an earlier one do nothing. That stops accepting twice and claiming the reward twice right at the entrance.
How to count pickups
When a herb is picked up, tell the Autoload QuestManager "picked up one herb." If there's a quest in progress that's gathering herbs, QuestManager increases the count by one. When it reaches the required number, it moves the state to COMPLETED.

QuestManager emits the quest_changed signal (a "something changed" notification) every time the record changes. The on-screen display updates when it receives it. The display side only reads the current state and count. How to connect signals is covered in Signals Basics.
The herb doesn't know what's in the quest. It just asks "is anyone gathering herbs right now," and if so, reports and disappears. When no one's gathering, it stays where it is without being picked up. If it disappeared when picked up before accepting, that one wouldn't be counted anywhere.
The order for giving the reward
When the player reports, give the reward only when the state is COMPLETED. At that point, first move the state to REWARDED, then return the reward amount. If they talk a second time, the state is already REWARDED, so the reward is 0.

Set it to received first, and however many times the player reports afterward, report() returns 0. If you add the money first, it's still completed between adding the money and moving the state forward. If something connected to a signal emitted when the money increases calls the report again, the reward is given twice in that window.
Hands-On: Gather three herbs and receive the reward only once
Fetch quests in an RPG, deliveries in a farming game, material gathering in an exploration game — the "gather, report, get a reward" shape is the same. Talk to the villager to accept the quest, pick up three herbs, report back, and receive 100G. Talking again doesn't give another reward.

① What to place
Start with a new 2D project.

- In the FileSystem dock, right-click → Create New → Script... to create
quest_data.gdand write the code below. Then right-click theres://folder → Create New → Resource..., chooseQuestData, and save it asherb_quest.tres.QuestDatashows up in the list becauseclass_name QuestDatain the code gives the type a name. The values areidherb_quest,titleHerb Gathering,target_itemherb,required3, andreward_gold100 herb.tscn: The root is an Area2D (named Herb). As children, a Sprite2D (icon.svgin Texture, Scale 0.25) and a CollisionShape2D (CircleShape2D, Radius 16). Attachherb.gdmain.tscn: The root is a Node2D (named Main). Place the following as its children- Player (CharacterBody2D, position (200, 300); it's top-down, so set Motion Mode to Floating). As children, a Sprite2D (
icon.svgin Texture, Scale 0.5) and a CollisionShape2D (CircleShape2D, Radius 32). Create aplayergroup with+in the Groups dock and add it - Villager (Area2D, position (600, 300)). As children, a Sprite2D (
icon.svgin Texture, Scale 0.5), a CollisionShape2D (CircleShape2D, Radius 80; the range where you can talk), and Speech (Label, Position (-110, -70), Text empty) - Drag
herb.tscnfrom the FileSystem dock three times and place them at (350, 120), (850, 480), and (1000, 150) - Hud (Label, Position (16, 16))
- Player (CharacterBody2D, position (200, 300); it's top-down, so set Motion Mode to Floating). As children, a Sprite2D (
- Autoload: Register
quest_manager.gdunder the nameQuestManager(Project Settings, Globals tab → Autoload)
Only change Scale on the Sprite2Ds. Leave Player, Villager, Herb, and the CollisionShape2Ds at (1, 1). Talk with Space (the built-in ui_accept) and move with the arrow keys.
② Put it together
The quest sheet's type. The state names live here too.
# quest_data.gd (the quest sheet's type)
class_name QuestData
extends Resource
enum State { NOT_STARTED, ACTIVE, COMPLETED, REWARDED } # 0, 1, 2, 3 from the top. Only moves in this order
@export var id := "" # The name used to look up the record. One per quest sheet
@export var title := ""
@export var target_item := "" # The item to gather
@export var required := 3 # The required count
@export var reward_gold := 100 # The reward
The one that records progress.
# quest_manager.gd (register as an Autoload named QuestManager)
extends Node
signal quest_changed(quest: QuestData)
var progress := {} # Quest sheet id → {"state": state, "count": gathered count}
var _active: Array[QuestData] = [] # Accepted quest sheets
func get_state(quest: QuestData) -> QuestData.State:
# If there's no record, read from an empty dictionary and return not started
return progress.get(quest.id, {}).get("state", QuestData.State.NOT_STARTED)
func get_count(quest: QuestData) -> int:
return progress.get(quest.id, {}).get("count", 0)
# The only place that changes state. It only moves forward
func _advance(quest: QuestData, to: QuestData.State) -> bool:
if to <= get_state(quest):
return false # Reject changes to the same state or back to an earlier one
if not progress.has(quest.id):
progress[quest.id] = {"count": 0}
progress[quest.id]["state"] = to
quest_changed.emit(quest)
return true
func accept(quest: QuestData) -> void:
if _advance(quest, QuestData.State.ACTIVE):
_active.append(quest)
func is_collecting(item: String) -> bool:
for quest in _active:
if quest.target_item == item and get_state(quest) == QuestData.State.ACTIVE:
return true
return false
func add_item(item: String) -> void:
for quest in _active:
if quest.target_item != item or get_state(quest) != QuestData.State.ACTIVE:
continue
progress[quest.id]["count"] += 1
if get_count(quest) >= quest.required:
_advance(quest, QuestData.State.COMPLETED) # All gathered. Only reporting is left
else:
quest_changed.emit(quest)
# Returns the reward amount. Only when completed, and only once
func report(quest: QuestData) -> int:
if get_state(quest) != QuestData.State.COMPLETED:
return 0
_advance(quest, QuestData.State.REWARDED) # Mark it as received first
_active.erase(quest)
return quest.reward_gold
The herb just reports that it was picked up and disappears.
# herb.gd (attach to Herb)
extends Area2D
@export var item_id := "herb"
func _ready() -> void:
body_entered.connect(_on_body_entered)
func _on_body_entered(body: Node2D) -> void:
if body.is_in_group("player") and QuestManager.is_collecting(item_id):
QuestManager.add_item(item_id) # Report the pickup
queue_free()
Main handles movement, talking, and the display.
# main.gd (attach to Main)
extends Node2D
const HERB_QUEST := preload("res://herb_quest.tres") # The quest sheet. Read-only
@onready var player: CharacterBody2D = $Player
@onready var villager: Area2D = $Villager
@onready var speech: Label = $Villager/Speech
@onready var hud: Label = $Hud
var gold := 0
func _ready() -> void:
QuestManager.quest_changed.connect(_update_hud)
_update_hud(HERB_QUEST) # For the first display, read it ourselves
func _physics_process(_delta: float) -> void:
player.velocity = Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down") * 200.0
player.move_and_slide()
func _unhandled_input(event: InputEvent) -> void: # Receives input nothing else used
if event.is_action_pressed("ui_accept") and villager.overlaps_body(player): # Is the player overlapping the villager's range?
_talk(HERB_QUEST)
# Lines are decided by reading the current state
func _talk(quest: QuestData) -> void:
match QuestManager.get_state(quest): # Branch by state
QuestData.State.NOT_STARTED:
QuestManager.accept(quest)
speech.text = "Collect %d herbs for me." % quest.required
QuestData.State.ACTIVE:
speech.text = "Not yet? (%d / %d)" % [QuestManager.get_count(quest), quest.required]
QuestData.State.COMPLETED:
gold += QuestManager.report(quest) # The reward, only once
speech.text = "Thank you. Here's %dG" % quest.reward_gold
QuestData.State.REWARDED:
speech.text = "You helped me the other day"
_update_hud(quest)
func _update_hud(quest: QuestData) -> void:
var line := ""
match QuestManager.get_state(quest):
QuestData.State.NOT_STARTED:
line = "Talk to the Villager"
QuestData.State.ACTIVE:
line = "%s %d / %d" % [quest.title, QuestManager.get_count(quest), quest.required]
QuestData.State.COMPLETED:
line = "%s Report Back" % quest.title
QuestData.State.REWARDED:
line = "%s Complete" % quest.title
hud.text = "%s\nGold %dG" % [line, gold]
③ Run it and check

Open main.tscn and run it with F6. Check three things.
First, the upper left shows "Talk to the Villager" and "Gold 0G". Touching a herb at this point doesn't pick it up; it stays where it is. Approach the villager until the player's center is around (500, 300), stop, and press Space. The villager says "Collect 3 herbs for me.", and the upper left changes to "Herb Gathering 0 / 3".
Next, pick up the herbs. Herbs you touched before accepting can be picked up if you touch them again. Each one you pick up raises it to "1 / 3", "2 / 3", and on the third it changes to "Herb Gathering Report Back".
Finally, go back to the villager and press Space. The villager says "Thank you. Here's 100G", and the upper left becomes "Herb Gathering Complete" and "Gold 100G". Press Space again and it changes to "You helped me the other day", with gold still at 100G.
Next, change required in herb_quest.tres from 3 to 2. Play from the start again with F6. It changes to "Herb Gathering Report Back" after picking up two, and the third herb stays put even when touched. You only changed one number on the quest sheet and didn't open any code. Once you've checked, set it back to 3.
There are two key points.
- Lines and display are decided by reading the state: Neither the villager nor the upper-left display holds a count or state of its own. They just ask
QuestManagerand branch on the four states. How to build dialogue branches is covered in Dialogue System Basics - One entrance for changing state:
_advance()rejects changes that go backward, so neither accepting twice nor claiming the reward twice can happen
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Talking doesn't make the villager speak | The villager's range (radius 80) and the player's body (radius 32) circles don't overlap. Or a CollisionShape2D has no shape | Put a CircleShape2D in both, and press Space once their centers are closer than 112 |
| Touching herbs doesn't pick them up | Player isn't in the player group. Or the text of target_item and item_id differs | Add it to player in the Groups dock. Make both herb_quest.tres and the herbs herb |
| Running it shows "Identifier "QuestManager" not declared in the current scope." | quest_manager.gd isn't registered as an Autoload, or the name is different | Register it under Project Settings' Globals → Autoload with the name QuestManager |
Bonus: Good to Know First
- Save the record and the gold: Don't save the quest sheet
.tres; write theQuestManager.progressdictionary and the gold to JSON. States read from JSON come back as decimals like3.0, which don't match in amatch, so convert them back to integers withint(). After loading, put the in-progress quest sheets back into_active. How to write it is covered in Save/Load Basics - Watch progress on the Autoload: While running, switch the Scene dock to Remote and select
QuestManager. The Inspector'sprogressshows the current state (a number from 0 to 3) and count. There's nothing in the villager's node - The next quest is one more quest sheet: Even for a quest with a different item and count,
QuestManagerworks as is. When you add more villagers, extend Main to pass the quest sheet of whoever you talked to
Summary
- Separate the quest sheet from the record: The unchanging quest sheet is a
.tresyou only read. The changing progress goes in an Autoload's dictionary - Four states: Not started, active, completed, and rewarded. Keep completed and rewarded separate
- Only forward: Use a single function to change state, and reject changes that go backward
- Report on pickup: The herb just tells
QuestManager. When the count is met, it's "Report back" - Set it to received, then give: Move the state forward first, then give the reward
For your game's first quest, what will the player gather, and who will they report to?