The dialogue window works (the dialogue system article). Items can be picked up (the inventory article). Saving works too (the JSON save article). Every part is on the table — and yet the most ordinary fetch quest in all of RPGs, "a villager asks you to collect 3 herbs and come back," refuses to come together. Where does the progress live? How do dialogue and inventory connect? Before you know it, every class in the project is wired directly into a QuestManager you're afraid to touch.
What's missing isn't a part — it's a home for progression state. In this article we model quests as four states and connect accepting, progressing, reporting, rewards, and save restoration into one line, without welding the parts to each other.
What you'll learn
- Thinking in four quest states (not started, active, ready to turn in, completed)
- Separating shared master data from per-player progress data
- The wiring: accept from a dialogue choice, update progress from inventory changes, switch the villager's dialogue by state
- Plugging the classic holes: pre-quest items, double rewards, and saves that break on asset rename
Tested with: Unity 2022.3 LTS / Unity 6
- Think of a Quest as Four States
- Separate Master Data from Progress Data
- Accepting from a Dialogue Choice
- Recounting Progress from Inventory Changes
- Turn-In, Rewards, and Preventing Double Payouts
- Save Only "IDs and Progress Values"
- Hands-On: The Herb Quest from Start to Finish
- Bonus: Good Things to Know Ahead of Time
- Summary
Think of a Quest as Four States
The first thing to decide isn't code — it's vocabulary for states. Follow "collect 3 herbs and return" through time and a quest is always in exactly one of these four:

- NotStarted: you haven't heard the request yet. The villager has a "!" overhead
- Active: accepted, objective unmet. Herbs 1/3
- ReadyToTurnIn: objective met, not yet reported. Herbs 3/3, villager shows "?"
- Completed: reported and rewarded. Never comes back
The key is distinguishing "collected" from "reported." Squash this into a single bool isCleared and you're guaranteed confusion: rewards granted the instant the third herb drops, or "what happens if they eat a herb before reporting?" With states, the villager's dialogue, the overhead icon, and the tracker UI are all decided just by looking at "which state are we in" (this "states, not flags" thinking is the same instinct as the state machine article).
Separate Master Data from Progress Data
Next, split the data in two. "What is this quest" and "how far has this player gotten" are different data.

Master data is a ScriptableObject asset. When the design changes, you edit the asset — no code involved.
using UnityEngine;
[CreateAssetMenu(menuName = "Game/Quest")]
public class QuestData : ScriptableObject
{
public string questId = "quest_herb"; // immutable ID used by saves (see below)
public string title = "Three Herbs";
[TextArea] public string description;
[Header("Objective")]
public ItemData targetItem; // what to collect (ItemData from the inventory article)
public int requiredCount = 3;
[Header("Reward")]
public ItemData rewardItem;
public int rewardCount = 1;
}
Progress data, on the other hand, is a plain C# class. It varies per player, and it's the only thing the save file ever contains.
public enum QuestState { NotStarted, Active, ReadyToTurnIn, Completed }
[System.Serializable]
public class QuestProgress
{
public string questId; // which quest this progress belongs to
public QuestState state;
public int currentCount; // for herbs: how many right now
}
This is exactly the same split as ItemData (the kind) versus InventorySlot (the possession) in the inventory article. Don't mix the ledger with the progress notes — honestly, quest system design is mostly won or lost right here.
Accepting from a Dialogue Choice
Quests start in conversation. Take the DialogueChoice from the dialogue system article (choice text plus a reference to the next dialogue) and add one thing: what happens when it's chosen.
using UnityEngine.Events;
[System.Serializable]
public class DialogueChoice
{
public string choiceText; // "I'll do it", etc.
public DialogueData nextDialogue; // the conversation this choice leads to
public UnityEvent onChosen; // ★new: fired the moment it's selected
}
Wire the villager's "I'll do it" choice to QuestManager.Accept(herbQuest) in the Inspector, and accepting is done. The dialogue system never learns that quests exist — all it does is "fire a UnityEvent when a choice is pressed," the same loose coupling as a button's onClick.
The villager, meanwhile, uses the quest state to switch which conversation to have at all. This is where the four states pay off.
public class QuestGiver : MonoBehaviour
{
[SerializeField] private QuestData quest;
[SerializeField] private DialogueData offerDialogue; // NotStarted: "Could you gather some herbs?" (with choices)
[SerializeField] private DialogueData inProgressDialogue; // Active: "Not enough yet, it seems"
[SerializeField] private DialogueData turnInDialogue; // ReadyToTurnIn: "Oh, thank you!"
[SerializeField] private DialogueData completedDialogue; // Completed: "You were a great help"
public DialogueData GetDialogue() =>
QuestManager.Instance.GetState(quest) switch
{
QuestState.NotStarted => offerDialogue,
QuestState.Active => inProgressDialogue,
QuestState.ReadyToTurnIn => turnInDialogue,
_ => completedDialogue,
};
}
When the player talks to the villager, pass GetDialogue()'s result to the dialogue engine — that's it. Deriving the conversation from state instead of burying branches inside the dialogue data structurally eliminates the classic bug where a villager offers you the same quest after you've cleared it.
Recounting Progress from Inventory Changes
Now the part that advances 1/3 → 2/3 when you pick a herb. The tempting move is currentCount++ on every pickup, but the recommendation is the recount approach — every time the inventory changes, re-tally the possession count from zero.

The increment approach springs a leak for every new path items can take: herbs owned before accepting don't count, batch pickups drift, and nobody remembers to write the rollback for herbs that get consumed. With recounting, whatever route the items took, the answer is always simply the true count.
The connector is an event channel. The inventory adds a single onInventoryChanged.Raise() line at the end of AddItem / RemoveItem. The inventory never learns that quests exist.
// QuestManager side (excerpt)
[SerializeField] private VoidEventChannelSO onInventoryChanged;
private void OnEnable() { onInventoryChanged.OnEventRaised += Recount; }
private void OnDisable() { onInventoryChanged.OnEventRaised -= Recount; }
private void Recount()
{
foreach (var quest in allQuests)
{
var p = GetProgress(quest.questId);
if (p.state != QuestState.Active && p.state != QuestState.ReadyToTurnIn) continue;
p.currentCount = inventory.CountItem(quest.targetItem);
p.state = p.currentCount >= quest.requiredCount
? QuestState.ReadyToTurnIn
: QuestState.Active; // consumed a herb? drop back out of "ready to turn in"
OnQuestChanged?.Invoke(quest, p); // notify the UI and the villager icon
}
}
CountItem is a small helper that sums the slots from the inventory article (it counts correctly even when the same item is split across multiple slots).
// helper added to Inventory
public int CountItem(ItemData item)
{
int total = 0;
foreach (var slot in slots)
if (slot.item == item) total += slot.count;
return total;
}
tips: The recount only runs when the inventory actually changes, so even dozens of quests are no performance concern. This is nothing like polling every quest in
Update()each frame.
Turn-In, Rewards, and Preventing Double Payouts
Talking to the villager once you're ready, and collecting the reward. The code is short, but the state guard at the top is the real substance.
public void TurnIn(QuestData quest)
{
var p = GetProgress(quest.questId);
if (p.state != QuestState.ReadyToTurnIn) return; // ★mashing and duplicate calls die here
p.state = QuestState.Completed; // ★advance the state BEFORE paying out
inventory.RemoveItem(quest.targetItem, quest.requiredCount); // hand over the herbs
inventory.AddItem(quest.rewardItem, quest.rewardCount); // receive the reward
OnQuestChanged?.Invoke(quest, p);
}
Mind the order: advance the state to Completed first, hand out the reward second. Reverse it, and a second TurnIn arriving mid-payout (button mashing, a dialogue event double-firing) pays the reward twice. It's the same "cut it off at the entrance, commit the state first" pattern as the death handling in the health system article — dead flag first, death event second.
Save Only "IDs and Progress Values"
Quest state must be saved. And the one thing you must never do here is put asset references or asset names in the save.

QuestData asset names will change during development — that's a certainty. If saves key off the name, the moment you rename, every player's quest resets to not-started. Save only the questId string and the progress values, and on load, look the ledger (allQuests) back up by ID.
[System.Serializable]
public class QuestSaveData
{
public List<QuestProgress> quests = new(); // just questId, state, currentCount
}
QuestProgress was designed in this shape (ID + numbers) from the start, so it's one extra list on the SaveData from the JSON save article. One caution on the load side: don't re-subscribe to events after restoring — leave subscriptions to the OnEnable/OnDisable pair, and have the load simply rebuild the display once from the restored state. Adding subscriptions on every load is the classic path to UI updates firing two and three times.
tips:
questIdis an ID you've "promised to the outside" — once shipped, it never changes. Titles and descriptions can change all they like. This line between "display can change, ID cannot" is exactly the compatibility thinking from the save data versioning article.
Hands-On: The Herb Quest from Start to Finish
The parts are ready — let's run it end to end. An RPG fetch quest, an adventure game's token hunt, a survival game's delivery contract — "an NPC asks, you gather, you return and report" is the same structure in every genre.

Here is the full QuestManager. It's just the previous sections assembled — nothing new.
using System;
using System.Collections.Generic;
using UnityEngine;
public class QuestManager : MonoBehaviour
{
public static QuestManager Instance { get; private set; }
[SerializeField] private Inventory inventory;
[SerializeField] private List<QuestData> allQuests; // the ledger (every quest)
[SerializeField] private VoidEventChannelSO onInventoryChanged;
private readonly Dictionary<string, QuestProgress> progressById = new();
// "state changed" notification the UI and villager icons subscribe to
public event Action<QuestData, QuestProgress> OnQuestChanged;
private void Awake() { Instance = this; }
private void OnEnable() { onInventoryChanged.OnEventRaised += Recount; }
private void OnDisable() { onInventoryChanged.OnEventRaised -= Recount; }
public QuestState GetState(QuestData quest) => GetProgress(quest.questId).state;
public void Accept(QuestData quest)
{
var p = GetProgress(quest.questId);
if (p.state != QuestState.NotStarted) return; // double-accept guard
p.state = QuestState.Active;
OnQuestChanged?.Invoke(quest, p);
Recount(); // count herbs owned before accepting, right now
}
private void Recount()
{
foreach (var quest in allQuests)
{
var p = GetProgress(quest.questId);
if (p.state != QuestState.Active && p.state != QuestState.ReadyToTurnIn) continue;
p.currentCount = inventory.CountItem(quest.targetItem);
p.state = p.currentCount >= quest.requiredCount
? QuestState.ReadyToTurnIn
: QuestState.Active;
OnQuestChanged?.Invoke(quest, p);
}
}
public void TurnIn(QuestData quest)
{
var p = GetProgress(quest.questId);
if (p.state != QuestState.ReadyToTurnIn) return;
p.state = QuestState.Completed;
inventory.RemoveItem(quest.targetItem, quest.requiredCount);
inventory.AddItem(quest.rewardItem, quest.rewardCount);
OnQuestChanged?.Invoke(quest, p);
}
private QuestProgress GetProgress(string questId)
{
if (!progressById.TryGetValue(questId, out var p))
{
p = new QuestProgress { questId = questId, state = QuestState.NotStarted };
progressById[questId] = p;
}
return p;
}
}
The tracker UI (the "Herbs 2/3" in the screen corner) just subscribes to OnQuestChanged and rewrites its text; the villager's overhead icon flips between "!" and "?" off the same event. The display side never changes state; only QuestManager changes state. As long as that one-way street holds, the screen and the conversations can never disagree.
Press Play and poke at the places that used to break. Pick up one herb before accepting — if the tracker starts at 1/3, the recount is doing its job. Gather all three, watch the villager's dialogue turn into "thank you!", then mash the turn-in conversation — if the inventory holds exactly one reward, the state guard held. Next, consume a herb without reporting — 3/3 should fall back to 2/3, and the villager should return to "not enough yet." Finally, save and restart the game — if the accepted state, the count, and the villager's reaction all survive, you've closed the loop.
Two takeaways. Because we split state into four, the dialogue, the UI, and the icons all lined up by merely reading it. And because progress is recounted instead of incremented, no path of gaining or losing items can poke a hole in it.
Bonus: Good Things to Know Ahead of Time
- Kill and reach-location quests are the same shape: "slay 5 slimes" raises from the enemy's death event, "reach the cave" from a trigger zone — only
Recount's data source changes. One caveat: kill counts can't be recounted the way an inventory can, so that one number does live in the progress data as an increment - Multi-objective quests: "3 herbs and 2 mushrooms" turns
QuestData's objective into an array, andReadyToTurnInrequires all of them. The shape stretches without changing - Where QuestManager lives: if it spans scenes, review DontDestroyOnLoad and the singleton pitfalls. Don't forget a design for discarding progress when returning to the title screen
- Past a few dozen quests: managing the ledger as an Inspector
Liststops scaling — move to Addressables or folder-wide loading
Summary
- Hold quests as four states (NotStarted, Active, ReadyToTurnIn, Completed) — distinguish "collected" from "reported"
- Separate the ledger (QuestData) from the progress notes (QuestProgress) — design changes become asset edits
- Progress is a recount, not an increment: pre-quest items, batch pickups, and consumption rollbacks all come out right through one path
- Rewards: state guard first, payout second. Saves: ID strings and progress values only
- Dialogue, UI, and icons read state; only QuestManager writes it
With "three herbs" running, your game now has the spine of a story: request, pursuit, fulfillment. What will your first quest ask of the player?