Every RPG lives or dies by its quest system. You can have the most gorgeous pixel art and the tightest combat loop in the world, but if your quests feel like a glorified checkbox — 'go here, kill that, come back' — players will bounce. I've shipped two RPGs with Unity, and both times the quest system was the thing I rewrote the most. So let me save you some pain and walk you through the architecture I've landed on.
TL;DR: We'll build a quest system using ScriptableObjects for data definitions, a runtime QuestManager for tracking active/completed quests, multiple objective types (kill, collect, talk), dialogue integration, a minimal quest tracker UI, and save/load support. The full implementation is available in our RPG Essentials Kit.
If you've read our ScriptableObjects guide, you already know why they're perfect for data-driven design. Quest definitions are a textbook use case — designers can create and tweak quests in the editor without touching a line of code. Let's start there.
Quest Data Architecture with ScriptableObjects
The foundation of any solid quest system is separating what a quest is from how it's tracked at runtime. A QuestDefinition ScriptableObject holds the static data: title, description, objectives, rewards. The runtime system references these definitions but maintains its own state. This means you can have ten players all running the same quest definition with completely independent progress.
using UnityEngine;
using System.Collections.Generic;
[CreateAssetMenu(fileName = "NewQuest", menuName = "RPG/Quest Definition")]
public class QuestDefinition : ScriptableObject
{
public string questId;
public string questTitle;
[TextArea(3, 6)]
public string description;
public Sprite icon;
public List<QuestObjective> objectives = new List<QuestObjective>();
public QuestReward reward;
[Header("Prerequisites")]
public List<string> requiredCompletedQuests;
public int requiredPlayerLevel;
}
[System.Serializable]
public class QuestObjective
{
public string objectiveId;
public string description;
public ObjectiveType type;
public string targetId; // enemy ID, item ID, or NPC ID
public int requiredAmount;
}
[System.Serializable]
public class QuestReward
{
public int experiencePoints;
public int gold;
public List<string> itemIds;
}
public enum ObjectiveType
{
Kill,
Collect,
Talk,
Reach,
Interact
}Notice the requiredCompletedQuests list — that's how you create quest chains without hardcoding dependencies. The QuestManager will check this list before allowing a quest to be accepted. The Quest System script on our site already handles this prerequisite checking, but it's worth understanding the flow yourself.
QuestManager: Runtime Tracking
The QuestManager is a singleton that owns all quest state at runtime. It tracks which quests are available, active, and completed. Each active quest gets a runtime data object that mirrors the definition's objectives but adds a currentAmount counter.
using UnityEngine;
using System;
using System.Collections.Generic;
using System.Linq;
public class QuestManager : MonoBehaviour
{
public static QuestManager Instance { get; private set; }
[SerializeField] private QuestDefinition[] allQuests;
private Dictionary<string, ActiveQuest> activeQuests = new();
private HashSet<string> completedQuestIds = new();
public event Action<ActiveQuest> OnQuestAccepted;
public event Action<ActiveQuest> OnQuestCompleted;
public event Action<string, int, int> OnObjectiveProgress; // objectiveId, current, required
void Awake()
{
if (Instance != null) { Destroy(gameObject); return; }
Instance = this;
DontDestroyOnLoad(gameObject);
}
public bool CanAcceptQuest(QuestDefinition def)
{
if (activeQuests.ContainsKey(def.questId)) return false;
if (completedQuestIds.Contains(def.questId)) return false;
foreach (string reqId in def.requiredCompletedQuests)
{
if (!completedQuestIds.Contains(reqId)) return false;
}
return true;
}
public void AcceptQuest(QuestDefinition def)
{
if (!CanAcceptQuest(def)) return;
var active = new ActiveQuest(def);
activeQuests[def.questId] = active;
OnQuestAccepted?.Invoke(active);
}
public void ReportProgress(ObjectiveType type, string targetId, int amount = 1)
{
foreach (var kvp in activeQuests)
{
ActiveQuest quest = kvp.Value;
foreach (var obj in quest.objectives)
{
if (obj.IsComplete) continue;
if (obj.definition.type != type) continue;
if (obj.definition.targetId != targetId) continue;
obj.currentAmount = Mathf.Min(
obj.currentAmount + amount,
obj.definition.requiredAmount);
OnObjectiveProgress?.Invoke(
obj.definition.objectiveId,
obj.currentAmount,
obj.definition.requiredAmount);
}
if (quest.AllObjectivesComplete && !quest.turnedIn)
CompleteQuest(quest);
}
}
private void CompleteQuest(ActiveQuest quest)
{
quest.turnedIn = true;
activeQuests.Remove(quest.definition.questId);
completedQuestIds.Add(quest.definition.questId);
// Grant rewards here
OnQuestCompleted?.Invoke(quest);
}
}
public class ActiveQuest
{
public QuestDefinition definition;
public List<ActiveObjective> objectives = new();
public bool turnedIn;
public bool AllObjectivesComplete => objectives.All(o => o.IsComplete);
public ActiveQuest(QuestDefinition def)
{
definition = def;
foreach (var objDef in def.objectives)
objectives.Add(new ActiveObjective(objDef));
}
}
public class ActiveObjective
{
public QuestObjective definition;
public int currentAmount;
public bool IsComplete => currentAmount >= definition.requiredAmount;
public ActiveObjective(QuestObjective def) { definition = def; }
}The key design choice here is the ReportProgress method. Instead of quests polling the world every frame, game systems push events into the QuestManager. When an enemy dies, the combat system calls ReportProgress(ObjectiveType.Kill, enemy.id). When a player picks up an item, the Inventory System calls ReportProgress(ObjectiveType.Collect, item.id). This keeps coupling minimal.
Objective Types: Kill, Collect, Talk
The three core objective types cover about 90% of RPG quests. Kill objectives hook into your combat system — whenever something dies, report it. Our Health System script fires an OnDeath event that's perfect for this. Collect objectives integrate with your inventory. Talk objectives tie directly into dialogue.
// Hook into enemy death — attach to enemies or listen globally
public class EnemyQuestReporter : MonoBehaviour
{
[SerializeField] private string enemyId;
private void OnEnable()
{
var health = GetComponent<HealthSystem>();
if (health != null)
health.OnDeath += HandleDeath;
}
private void HandleDeath()
{
QuestManager.Instance.ReportProgress(
ObjectiveType.Kill, enemyId);
}
}
// Hook into item pickup
public class ItemPickup : MonoBehaviour
{
[SerializeField] private string itemId;
private void OnTriggerEnter(Collider other)
{
if (!other.CompareTag("Player")) return;
QuestManager.Instance.ReportProgress(
ObjectiveType.Collect, itemId);
Destroy(gameObject);
}
}For 'talk to NPC' objectives, you'll want to fire the report when a dialogue conversation ends, not when it starts. Otherwise the player could spam-click through dialogue and get credit. We'll cover this in the dialogue integration section.
Dialogue Integration
A quest system without dialogue integration feels hollow. Players need to actually talk to quest givers, hear the story, and feel like their actions matter. If you're using our Dialogue System, hooking quests into conversations is straightforward.
The Dialogue Trigger script fires events at the start and end of dialogue sequences. We'll listen for the end event and report progress for Talk objectives. You can also use dialogue nodes to offer and accept quests mid-conversation.
using UnityEngine;
public class QuestDialogueBridge : MonoBehaviour
{
[SerializeField] private string npcId;
[SerializeField] private QuestDefinition questToOffer;
private DialogueTrigger dialogueTrigger;
void Start()
{
dialogueTrigger = GetComponent<DialogueTrigger>();
if (dialogueTrigger != null)
dialogueTrigger.OnDialogueEnded += HandleDialogueEnd;
}
private void HandleDialogueEnd()
{
// Report talk objective completion
QuestManager.Instance.ReportProgress(
ObjectiveType.Talk, npcId);
// Offer a new quest if applicable
if (questToOffer != null &&
QuestManager.Instance.CanAcceptQuest(questToOffer))
{
QuestManager.Instance.AcceptQuest(questToOffer);
}
}
}Want quest-giving NPCs to show an icon above their head? Add a Tooltip System trigger that displays a '!' when the NPC has an available quest and a '?' when they can receive a turn-in. It's a small detail that makes your game feel significantly more polished.
Quest UI and Tracker
Players need two things from quest UI: a tracker on screen showing current objectives, and a full quest log they can open. The tracker is the more important one — if objectives aren't visible during gameplay, players forget what they're doing and disengage. Here's a minimal tracker that listens to QuestManager events:
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using System.Collections.Generic;
public class QuestTracker : MonoBehaviour
{
[SerializeField] private Transform entryContainer;
[SerializeField] private GameObject entryPrefab;
private Dictionary<string, TextMeshProUGUI> trackerEntries = new();
void OnEnable()
{
QuestManager.Instance.OnQuestAccepted += AddTrackerEntry;
QuestManager.Instance.OnQuestCompleted += RemoveTrackerEntry;
QuestManager.Instance.OnObjectiveProgress += UpdateProgress;
}
void OnDisable()
{
QuestManager.Instance.OnQuestAccepted -= AddTrackerEntry;
QuestManager.Instance.OnQuestCompleted -= RemoveTrackerEntry;
QuestManager.Instance.OnObjectiveProgress -= UpdateProgress;
}
private void AddTrackerEntry(ActiveQuest quest)
{
var go = Instantiate(entryPrefab, entryContainer);
var tmp = go.GetComponentInChildren<TextMeshProUGUI>();
tmp.text = FormatQuest(quest);
trackerEntries[quest.definition.questId] = tmp;
}
private void RemoveTrackerEntry(ActiveQuest quest)
{
if (trackerEntries.TryGetValue(quest.definition.questId, out var tmp))
{
Destroy(tmp.transform.parent.gameObject);
trackerEntries.Remove(quest.definition.questId);
}
}
private void UpdateProgress(string objectiveId, int current, int required)
{
// Rebuild all entries — simple but effective for small quest counts
// For production, map objectiveId to its parent quest for targeted updates
}
private string FormatQuest(ActiveQuest quest)
{
string text = $"<b>{quest.definition.questTitle}</b>\n";
foreach (var obj in quest.objectives)
{
string check = obj.IsComplete ? "<color=#4ade80>\u2713</color>" : "\u25CB";
text += $" {check} {obj.definition.description}";
if (obj.definition.requiredAmount > 1)
text += $" ({obj.currentAmount}/{obj.definition.requiredAmount})";
text += "\n";
}
return text.TrimEnd();
}
}The entry prefab is just a UI panel with a TextMeshPro component — nothing fancy. You can style it however you want. The key is that it subscribes to QuestManager events and updates reactively rather than polling.
Saving Quest Progress
Nobody wants to lose quest progress. Serializing quest state is straightforward if you planned for it — and painful if you didn't. The trick is that QuestDefinition ScriptableObjects are references, not data, so you only need to save IDs and counters. Our Save System and JSON Save Utility make this clean:
using System;
using System.Collections.Generic;
[Serializable]
public class QuestSaveData
{
public List<ActiveQuestData> activeQuests = new();
public List<string> completedQuestIds = new();
}
[Serializable]
public class ActiveQuestData
{
public string questId;
public List<ObjectiveProgress> objectives = new();
}
[Serializable]
public class ObjectiveProgress
{
public string objectiveId;
public int currentAmount;
}
// Add these methods to QuestManager:
public QuestSaveData GetSaveData()
{
var data = new QuestSaveData();
data.completedQuestIds = new List<string>(completedQuestIds);
foreach (var kvp in activeQuests)
{
var questData = new ActiveQuestData { questId = kvp.Key };
foreach (var obj in kvp.Value.objectives)
{
questData.objectives.Add(new ObjectiveProgress
{
objectiveId = obj.definition.objectiveId,
currentAmount = obj.currentAmount
});
}
data.activeQuests.Add(questData);
}
return data;
}
public void LoadSaveData(QuestSaveData data)
{
completedQuestIds = new HashSet<string>(data.completedQuestIds);
activeQuests.Clear();
foreach (var questData in data.activeQuests)
{
var def = Array.Find(allQuests, q => q.questId == questData.questId);
if (def == null) continue;
var active = new ActiveQuest(def);
foreach (var objProgress in questData.objectives)
{
var obj = active.objectives.Find(
o => o.definition.objectiveId == objProgress.objectiveId);
if (obj != null)
obj.currentAmount = objProgress.currentAmount;
}
activeQuests[def.questId] = active;
}
}When saving, call GetSaveData() and serialize the result to JSON. When loading, deserialize and pass it to LoadSaveData(). The system re-creates ActiveQuest instances from their definitions and restores the progress counters. Quest definitions themselves never need to be serialized — they're assets.
Putting It All Together
Here's the big picture. Your game has QuestDefinition assets in a Resources or Addressables folder. The QuestManager loads them at startup. NPCs with the QuestDialogueBridge component offer quests through dialogue. As the player kills enemies, collects items, and talks to NPCs, progress flows into ReportProgress. The QuestTracker UI updates in real time. When the player saves, quest state gets serialized alongside everything else.
- QuestDefinition (ScriptableObject) — static quest data, objectives, and rewards
- QuestManager (singleton) — runtime tracking, prerequisite checks, progress reporting
- QuestDialogueBridge — connects NPC dialogue to quest acceptance and Talk objectives
- QuestTracker (UI) — on-screen display of active objectives with live progress
- QuestSaveData — serializable snapshot for persistent storage
The entire system I've walked through here is included in our Quest System script, and it's part of the larger RPG Essentials Kit which bundles it with the dialogue, inventory, and save systems. If you're building an RPG, that kit will save you weeks of integration work.
One thing I'd add for a production game: quest branching. Some quests should have different outcomes based on player choice — did you spare the bandit or kill him? You can model this by adding an outcomeId field to QuestDefinition and using it as a prerequisite key. That way, 'Spare the Bandit' and 'Slay the Bandit' are separate completed quest IDs that gate different follow-up quests. It's elegant and doesn't require any changes to the core tracking logic.