【Unity】Making "Bring Me 3 Herbs" Actually Work: Building a Quest and Objective System

Created: 2026-07-16

You have dialogue, an inventory, and saving — yet the most ordinary fetch quest in all of RPGs, "collect 3 herbs and return to the villager," won't come together. What's missing is a home for progression state. Model quests with four states, separate master data from progress data, accept from a dialogue choice, update objectives from inventory changes, handle turn-in and rewards, and restore everything from a save — while plugging the classic holes: items collected before accepting, double rewards from button mashing, and saves that break when an asset gets renamed.

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.

Receiving a quest from a villager and gathering herbs

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

Sponsored

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:

Flow of the four quest states. NotStarted moves to Active on accept, to ReadyToTurnIn on completion of the objective, and to Completed on report — one way. Only consuming items can move ReadyToTurnIn back to Active
  • 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.

Separation diagram. Left: a QuestData ScriptableObject asset — the shared ledger holding ID, title, target item, required count, and reward. Right: QuestProgress — a per-player progress note holding only the ID, state, and current count

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.

Wiring diagram. A dialogue choice tells QuestManager to accept; the inventory notifies an event channel on every change; QuestManager recounts and notifies the tracker UI and the villager's dialogue of state changes. Everything connects through events

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.

Sponsored

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.

Comparison of save contents. Left: saving asset names — renaming the asset severs the link and breaks the save. Right: saving the quest_herb ID string with state and count, then looking the ledger back up by ID on load — nothing breaks

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: questId is 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.

Flow of the hands-on: on the left the villager shows an exclamation mark and the quest is accepted through a dialogue choice; in the middle the player picks field herbs and the tracker UI advances to 2/3; on the right, at 3/3 the villager's icon becomes a question mark, the player reports, and receives the reward

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, and ReadyToTurnIn requires 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 List stops 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?