Unity Quest System With ScriptableObjects: Objectives and Rewards

Illustration: a glowing scroll quest log with checkbox objectives and a reward star

“Defeat 5 slimes”, “Collect 3 herbs”, “Talk to the blacksmith”. Quests give players goals and give your game structure. This guide builds a simple, data-driven Unity quest system with ScriptableObjects: quest definitions as assets, kill and collect objectives that listen to game events, a quest log that tracks progress, rewards, and a basic UI. It’s small enough to understand in one sitting and easy to extend.

Data vs Runtime State

The key design decision: separate what a quest is from how far the player has got.

  • QuestData (ScriptableObject asset): title, description, objectives, rewards. Never changes at runtime.
  • QuestProgress (plain C# object): which quest, current counts, state. Created when a quest starts, saved and loaded.

Writing progress into ScriptableObjects is a common mistake. In the Editor, changes to an asset during Play Mode persist after you stop playing, so your quest is “half done” every time you test. In builds, they reset on restart. Keep assets read-only. The ScriptableObjects guide explains this trap in detail.

Quest and Objective Data

using UnityEngine;

public enum ObjectiveType { Kill, Collect, Talk, Reach }

[System.Serializable]
public class ObjectiveData
{
    public ObjectiveType type;
    public string targetId;          // "slime", "herb", "blacksmith", "village_gate"
    public int requiredAmount = 1;
    public string description;       // "Defeat slimes"
}

[CreateAssetMenu(menuName = "Quests/Quest")]
public class QuestData : ScriptableObject
{
    public string id;
    public string title;
    [TextArea] public string description;
    public ObjectiveData[] objectives;
    public int goldReward;
    public int xpReward;
    public QuestData[] prerequisites;
}

Create quests with Create > Quests > Quest. Each objective names a target by ID. Strings keep the example simple; for a larger game, use ScriptableObject references for enemy types and items so typos can’t break a quest.

Game Events Quests Listen To

Quests shouldn’t be wired into every enemy and pickup. Instead, gameplay announces things that happened, and the quest system listens:

using System;

public static class GameEvents
{
    public static event Action<string> EnemyKilled;     // enemy type id
    public static event Action<string, int> ItemCollected;
    public static event Action<string> NpcTalkedTo;
    public static event Action<string> LocationReached;

    public static void RaiseEnemyKilled(string id) => EnemyKilled?.Invoke(id);
    public static void RaiseItemCollected(string id, int n) => ItemCollected?.Invoke(id, n);
    public static void RaiseNpcTalkedTo(string id) => NpcTalkedTo?.Invoke(id);
    public static void RaiseLocationReached(string id) => LocationReached?.Invoke(id);
}

An enemy calls GameEvents.RaiseEnemyKilled("slime") when it dies. A pickup calls RaiseItemCollected("herb", 1). They don’t know quests exist, so you can add or remove quests without touching gameplay code. For more on this pattern, see the events and delegates guide.

Runtime Quest Progress

using System;

public enum QuestState { Active, Completed, TurnedIn }

[Serializable]
public class QuestProgress
{
    public string questId;
    public int[] counts;
    public QuestState state;

    [NonSerialized] public QuestData data;

    public QuestProgress(QuestData d)
    {
        data = d;
        questId = d.id;
        counts = new int[d.objectives.Length];
        state = QuestState.Active;
    }

    public bool Advance(ObjectiveType type, string target, int amount)
    {
        if (state != QuestState.Active) return false;
        bool changed = false;
        for (int i = 0; i < data.objectives.Length; i++)
        {
            var o = data.objectives[i];
            if (o.type != type || o.targetId != target || counts[i] >= o.requiredAmount) continue;
            counts[i] = Math.Min(o.requiredAmount, counts[i] + amount);
            changed = true;
        }
        if (changed && AllDone()) state = QuestState.Completed;
        return changed;
    }

    public bool AllDone()
    {
        for (int i = 0; i < counts.Length; i++)
            if (counts[i] < data.objectives[i].requiredAmount) return false;
        return true;
    }
}

Only IDs and numbers are serializable here, which makes saving simple. The data reference is restored on load by looking up the ID.

The Quest Log

using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;

public class QuestLog : MonoBehaviour
{
    public static QuestLog Instance { get; private set; }

    [SerializeField] QuestData[] allQuests;   // every quest in the game, for ID lookup

    public readonly List<QuestProgress> Quests = new();
    public event Action Changed;

    void Awake() => Instance = this;

    void OnEnable()
    {
        GameEvents.EnemyKilled     += id => Progress(ObjectiveType.Kill, id, 1);
        GameEvents.ItemCollected   += (id, n) => Progress(ObjectiveType.Collect, id, n);
        GameEvents.NpcTalkedTo     += id => Progress(ObjectiveType.Talk, id, 1);
        GameEvents.LocationReached += id => Progress(ObjectiveType.Reach, id, 1);
    }

    public bool CanStart(QuestData q) =>
        Quests.All(p => p.questId != q.id) &&
        q.prerequisites.All(pre => Quests.Any(p => p.questId == pre.id && p.state == QuestState.TurnedIn));

    public void StartQuest(QuestData q)
    {
        if (!CanStart(q)) return;
        Quests.Add(new QuestProgress(q));
        Changed?.Invoke();
    }

    void Progress(ObjectiveType type, string id, int amount)
    {
        bool any = false;
        foreach (var p in Quests) any |= p.Advance(type, id, amount);
        if (any) Changed?.Invoke();
    }

    public void TurnIn(QuestProgress p)
    {
        if (p.state != QuestState.Completed) return;
        p.state = QuestState.TurnedIn;
        // give p.data.goldReward and p.data.xpReward to the player here
        Changed?.Invoke();
    }

    public QuestData Find(string id) => allQuests.FirstOrDefault(q => q.id == id);
}

The lambdas in OnEnable keep the example short, but they can’t be unsubscribed. If the QuestLog can be destroyed (for example on scene change), use named methods and unsubscribe in OnDisable. Better still, keep the QuestLog on a persistent object; the game manager patterns guide shows how.

Collect objectives here count pickups as they happen. If items can also be dropped or sold, count the inventory instead when checking progress. The inventory system tutorial has a matching inventory to query.

NPCs start quests with QuestLog.Instance.StartQuest(questData) and turn them in with TurnIn, typically from a dialogue choice.

A Simple Quest UI

Subscribe to Changed and rebuild a list of entries. For each active quest show the title and each objective as “Defeat slimes 3/5”:

foreach (var p in QuestLog.Instance.Quests)
{
    if (p.state == QuestState.TurnedIn) continue;
    var entry = Instantiate(entryPrefab, listParent);
    entry.title.text = p.data.title + (p.state == QuestState.Completed ? " (Complete)" : "");
    var lines = new System.Text.StringBuilder();
    for (int i = 0; i < p.counts.Length; i++)
        lines.AppendLine($"{p.data.objectives[i].description} {p.counts[i]}/{p.data.objectives[i].requiredAmount}");
    entry.objectives.text = lines.ToString();
}

Clear the old entries before rebuilding, or pool them. Rebuilding only when Changed fires keeps it cheap. A short “Quest updated” popup when progress changes is a nice touch.

Saving Quest Progress

Because QuestProgress holds only IDs, counts and state, it serializes straight to JSON. Wrap the list in a class and use JsonUtility:

[System.Serializable]
class QuestSave { public List<QuestProgress> quests; }

string json = JsonUtility.ToJson(new QuestSave { quests = QuestLog.Instance.Quests });
// on load: parse, then set p.data = QuestLog.Instance.Find(p.questId) for each

Write it to Application.persistentDataPath along with the rest of your save. The JSON save system guide shows the complete approach.

📚 Design better quests

A quest system is only as good as the quests in it. Our game design book list includes books on narrative and mission design.

Troubleshooting

Quest progress persists after stopping Play Mode

You’re writing progress into the ScriptableObject. Keep progress in runtime objects only.

Objectives never advance

The target ID in the event doesn’t match the objective’s targetId exactly, including capitals. Log both.

Progress counts double

The QuestLog subscribed twice (OnEnable ran twice or two QuestLogs exist). Unsubscribe in OnDisable and keep one instance.

NullReferenceException on p.data after loading

data is NonSerialized. Look it up by questId after loading.

Quest can be started twice

Check CanStart before starting, which rejects quests already in the log.

FAQ

Why ScriptableObjects for quests?

Designers can create and edit quests as assets in the Editor without touching code, and many systems can reference the same quest.

How do I make quest chains?

Use prerequisites, as shown, so a quest only becomes available after earlier ones are turned in.

Can objectives be optional or ordered?

Add an optional flag or an order index to ObjectiveData and check it in Advance and AllDone.

Should I use strings for IDs?

Fine for small games. For bigger ones, reference ScriptableObject assets for enemy types and items to avoid typos.

How do I show a marker on the quest target?

Give the objective a location ID and let a marker system find the matching scene object.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *