Godot Quest Basics: Gather Herbs, Report Back, and Receive the Reward Only Once

Created: 2026-09-23Last updated: 2026-10-10

Separate the quest sheet (.tres) from the progress record (an Autoload), and build one quest where you gather herbs, report back, and receive the reward only once.

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.

A quest sheet showing three herbs and a coin, a figure walking and picking up herbs, and beyond it a notepad where progress is written

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

Sponsored

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 quest sheet .tres (item to gather, required count, reward) is only read. The Autoload's record holds a state and gathered count for each quest id

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.

Two villagers using the same quest sheet .tres. Writing the gathered count into the quest sheet from one makes the other's count move by the same amount

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.

StateMeaning
NOT_STARTEDHaven't heard the request yet
ACTIVEAccepted and gathering (Herbs 1 / 3)
COMPLETEDAll gathered. Not reported yet
REWARDEDReported 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.

From not started, accepting moves to active; gathering all three moves to completed; reporting moves to rewarded. The arrows only go forward

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.

Picking up a herb notifies QuestManager, the count goes 1/3, 2/3, and on the third it becomes completed and "Report back" appears on screen

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.

Reporting while completed first sets it to received, then gives 100G. A second report gives nothing because it's already received

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.

Sponsored

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.

Talking to the villager to accept the quest, picking up three herbs in the field, and returning to the villager to receive 100G

① What to place

Start with a new 2D project.

main.tscn with Player, Villager, three herbs, and Hud; the herb scene; the quest sheet .tres; and the QuestManager Autoload
  • In the FileSystem dock, right-click → Create New → Script... to create quest_data.gd and write the code below. Then right-click the res:// folder → Create New → Resource..., choose QuestData, and save it as herb_quest.tres. QuestData shows up in the list because class_name QuestData in the code gives the type a name. The values are id herb_quest, title Herb Gathering, target_item herb, required 3, and reward_gold 100
  • herb.tscn: The root is an Area2D (named Herb). As children, a Sprite2D (icon.svg in Texture, Scale 0.25) and a CollisionShape2D (CircleShape2D, Radius 16). Attach herb.gd
  • main.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.svg in Texture, Scale 0.5) and a CollisionShape2D (CircleShape2D, Radius 32). Create a player group with + in the Groups dock and add it
    • Villager (Area2D, position (600, 300)). As children, a Sprite2D (icon.svg in 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.tscn from the FileSystem dock three times and place them at (350, 120), (850, 480), and (1000, 150)
    • Hud (Label, Position (16, 16))
  • Autoload: Register quest_manager.gd under the name QuestManager (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

The upper-left display changes in order from "Talk to the Villager" to "Herb Gathering 0/3," "Herb Gathering Report Back," and "Herb Gathering Complete, Gold 100G"

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 QuestManager and 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

SymptomCauseFix
Talking doesn't make the villager speakThe villager's range (radius 80) and the player's body (radius 32) circles don't overlap. Or a CollisionShape2D has no shapePut a CircleShape2D in both, and press Space once their centers are closer than 112
Touching herbs doesn't pick them upPlayer isn't in the player group. Or the text of target_item and item_id differsAdd 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 differentRegister 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 the QuestManager.progress dictionary and the gold to JSON. States read from JSON come back as decimals like 3.0, which don't match in a match, so convert them back to integers with int(). 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's progress shows 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, QuestManager works as is. When you add more villagers, extend Main to pass the quest sheet of whoever you talked to
Sponsored

Summary

  • Separate the quest sheet from the record: The unchanging quest sheet is a .tres you 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?

Read nextOn to Scene
Scene

Flexible Object Checks with Node Groups

Read this note
Godot Notes in this section104