# Quest Systems How to build a quest/mission system in Unity 6000.5+: data authored as assets, a small runtime state machine that is easy to test, objectives that listen to game events, a tracker UI, rewards, failure, and saving. It builds on [[Inventory Systems]] (Collect objectives and item rewards) and plugs into dialogue and stats. **Target:** Unity **6000.5+**. For a Quest 2 headset build use **6000.3.27f2+** ([[0.Choosing a Unity Version]]); see section 12. > [!note] Verification status > The quest runtime is plain C# and standard Unity features; the APIs it relies on that I checked on 2026-11-09 against the 6000.5 Scripting Reference are the same ones cited in [[Inventory Systems]]: `JsonUtility.ToJson` (wrap arrays in a class; Unity-object references serialize as session-only instance IDs, so the save format stores quest ids), `Application.persistentDataPath`, UI Toolkit `IPointerEvent` and pointer capture. Design guidance references Scott Rogers' *Level Up!* via the vault's [[Level Up! (Review)]] (headings quoted there from the publisher TOCs). **Not verified:** exact UI Toolkit styling API names used in the tracker (written in the same style as the checked inventory view), the behaviour of every Test Framework attribute, and the interaction of `ScriptableObject` event channels with disabled Domain Reload (explained as a pitfall, not tested). **None of the code has been compiled or play-tested in Unity.** The pure-C# `QuestLog` plus the tests in section 11 are the fastest way to catch mistakes. ## Parts of this note 1. [Design first](#1-design-first) 2. [Architecture](#2-architecture) 3. [Data: quest definitions](#3-data-quest-definitions) 4. [Events: the quest event channel](#4-events-the-quest-event-channel) 5. [The runtime: QuestProgress and QuestLog](#5-the-runtime-questprogress-and-questlog) 6. [QuestManager: inventory, rewards, timers](#6-questmanager-inventory-rewards-timers) 7. [Emitters and quest givers in the world](#7-emitters-and-quest-givers-in-the-world) 8. [UI Toolkit quest tracker](#8-ui-toolkit-quest-tracker) 9. [Saving and loading](#9-saving-and-loading) 10. [Worked example: "Wolf Trouble"](#10-worked-example-wolf-trouble) 11. [EditMode tests](#11-editmode-tests) 12. [Integration, VR/Quest, pitfalls, Level Up!, patterns, exercises](#12-integration-vrquest-pitfalls-level-up-patterns-exercises) ## 1. Design first A quest is a **promise with a progress meter**: "do X, get Y." Before coding decide: | Question | Options | Effect on the system | |---|---|---| | Objective kinds | Kill, collect, reach, talk, use, survive, escort, custom | `ObjectiveType` and who emits events | | Order | All at once / strictly sequential | `ordered` flag, "current objective" logic | | Progress model | **Event-based** (killed 3 wolves: cumulative) vs **state-based** (carrying 3 pelts: can go down) | Different update paths (sections 4-5) | | Turn-in | Auto-complete / return to giver | `ReadyToTurnIn` status | | Failure | None / timer / ally dies / area left | `failTriggers`, timer; and whether failure is *recoverable* | | Gating | Level, prior quests, items | `minLevel`, `prerequisites` | | Rewards | Items, XP, gold, unlocks | `ItemReward`, `xpReward` | | Guidance | Journal text only / map marker / compass / NPC hints | UI, markers | Tie each decision to **Level Up!** ([[Level Up! (Review)]]): - **Level 9 (level design): goal setting, foreshadowing, "following procedure".** The review describes "show the player the goal, then the way". A quest is that promise made explicit: the tracker shows the goal; the world (landmarks, sightlines) shows the way. Rogers' headings "Walking is never, ever gameplay!" and "If it ever feels long or boring, then it is" are a warning against fetch quests that are only walking. - **Level 4 (the GDD and the beat chart).** The beat chart's rows (setting, story beat, new mechanics, enemies, power-ups) are exactly where quests are planned. Add a "quests" column and each cell becomes a `QuestDefinition` asset (see [[ScriptableObject Architecture]]). - **Level 12 (puzzles).** "Show 'em the door and then send 'em after the key": a quest whose first objective is *Reach the locked door* and second is *Collect the key* is the pattern. Use [[Inventory Systems]] key items. - **Level 13 (rewards).** "The best rewards are hard won" and "How to Win at Losing": a **failed** quest should not be a dead end. The log below lets a failed quest be accepted again. - **Level 3 (story).** Quests are the delivery vehicle for the story's beats; write objective text in the player's language, not the designer's. ## 2. Architecture ``` Authored assets (ScriptableObjects) Runtime (plain C#) Scene (MonoBehaviours) QuestDefinition (objectives, rewards) QuestProgress (counts, status) QuestManager (owns QuestLog, glue) QuestDatabase (id -> definition) QuestLog (rules, events) QuestEventEmitter (world -> channel) QuestEventChannel (SO event bus) QuestSave (JSON DTO) QuestGiver (accept / turn in / marker) QuestTrackerView (UI Toolkit) ``` Principles: 1. **Definitions are assets; progress is plain data.** Same Flyweight rule as items: never store counts in the `ScriptableObject`. 2. **Quests do not poll the world.** Emitters raise **events** ("an enemy `wolf` died"); the log updates matching objectives. Polling `FindObjectsOfType` every frame does not scale. 3. **State-based objectives read the source of truth.** *Collect 3 pelts* is `Inventory.Count`, not "pelt picked up" events, so dropping a pelt correctly takes progress away. 4. **The log is a state machine** with five statuses: `NotStarted`, `Active`, `ReadyToTurnIn`, `Completed`, `Failed`. Every transition is a method that raises events after state is consistent. 5. **Observer all the way up:** the UI, audio and giver markers subscribe to `QuestLog` events and never poll. Create `Assets/Quests/` with `Scripts/`, `Definitions/`, `UI/`. Scripts use `namespace Quests` and `using Inventories;` (from [[Inventory Systems]]). If you do not use that note, delete the inventory references (marked in comments) and the `ItemReward` list. ## 3. Data: quest definitions ### 3.1 `QuestDefinition.cs` ```csharp using System; using System.Collections.Generic; using Inventories; // ItemDefinition: remove if you do not use the inventory tutorial using UnityEngine; namespace Quests { public enum ObjectiveType { Kill, Collect, Reach, Talk, Custom } [Serializable] public struct ObjectiveDefinition { [Tooltip("Unique within the quest. Used in saves; never change after release.")] public string id; public ObjectiveType type; [Tooltip("Kill: enemy id. Collect: ItemDefinition.id. Reach: zone id. Talk: npc id. Empty = any.")] public string targetId; [Min(1)] public int required; [Tooltip("Player-facing text, e.g. 'Defeat wolves'. The tracker appends the count.")] public string description; public bool optional; [Tooltip("Hidden until completed (surprise objectives).")] public bool hidden; [Tooltip("Collect only: remove the items from the inventory at turn-in.")] public bool consumeOnTurnIn; } [Serializable] public struct ItemReward { public ItemDefinition item; [Min(1)] public int count; } [Serializable] public struct QuestTrigger { public ObjectiveType type; public string targetId; } [CreateAssetMenu(menuName = "Quests/Quest", fileName = "Quest_")] public sealed class QuestDefinition : ScriptableObject { [Header("Identity")] [Tooltip("Stable id used in saves. Lowercase, no spaces. NEVER change after release.")] public string id; public string title; [TextArea] public string summary; [Header("Gating")] [Min(1)] public int minLevel = 1; public List<QuestDefinition> prerequisites = new List<QuestDefinition>(); public bool repeatable; [Header("Rules")] [Tooltip("Objectives must be done top to bottom for kill/reach/talk events.")] public bool ordered; [Tooltip("0 = no limit.")] [Min(0f)] public float timeLimitSeconds; [Tooltip("Skip the turn-in step: complete as soon as all required objectives are done.")] public bool autoComplete; [Header("Objectives")] public List<ObjectiveDefinition> objectives = new List<ObjectiveDefinition>(); [Tooltip("Any matching event fails the quest (e.g. Custom 'escort_died').")] public List<QuestTrigger> failTriggers = new List<QuestTrigger>(); [Header("Rewards")] public int xpReward; public List<ItemReward> itemRewards = new List<ItemReward>(); void OnValidate() { if (string.IsNullOrWhiteSpace(id)) id = name.ToLowerInvariant().Replace(' ', '_'); if (string.IsNullOrWhiteSpace(title)) title = name; for (int i = 0; i < objectives.Count; i++) { var o = objectives[i]; bool dirty = false; if (o.required < 1) { o.required = 1; dirty = true; } if (string.IsNullOrWhiteSpace(o.id)) { o.id = quot;obj{i + 1}"; dirty = true; } if (dirty) objectives[i] = o; // structs are copies: write back } } } } ``` **Notes.** `ObjectiveDefinition` is a **data struct** rather than a ScriptableObject-per-objective or `[SerializeReference]` polymorphism: it is simple to author and easy to serialize, and four or five objective types cover most games. (If you outgrow it, `[SerializeReference]` with abstract objective classes works but you would likely need a custom drawer to pick the type: [[Custom Inspectors and Editor Tooling]].) `OnValidate` writes modified structs **back** into the list because `objectives[i]` returns a copy. ### 3.2 `QuestDatabase.cs` ```csharp using System.Collections.Generic; using UnityEngine; namespace Quests { [CreateAssetMenu(menuName = "Quests/Quest Database", fileName = "QuestDatabase")] public sealed class QuestDatabase : ScriptableObject { public List<QuestDefinition> quests = new List<QuestDefinition>(); Dictionary<string, QuestDefinition> _byId; public QuestDefinition Get(string id) { if (string.IsNullOrEmpty(id)) return null; if (_byId == null) Rebuild(); _byId.TryGetValue(id, out var q); return q; } void Rebuild() { _byId = new Dictionary<string, QuestDefinition>(quests.Count); foreach (var q in quests) { if (q == null || string.IsNullOrEmpty(q.id)) continue; if (!_byId.TryAdd(q.id, q)) Debug.LogError(quot;[QuestDatabase] Duplicate quest id '{q.id}'", this); } } void OnValidate() => _byId = null; } } ``` ## 4. Events: the quest event channel ### 4.1 `QuestEvent.cs` and `QuestEventChannel.cs` ```csharp using System; using UnityEngine; namespace Quests { public readonly struct QuestEvent { public readonly ObjectiveType type; public readonly string targetId; public readonly int amount; public QuestEvent(ObjectiveType type, string targetId, int amount = 1) { this.type = type; this.targetId = targetId; this.amount = amount; } } /// <summary>ScriptableObject event bus: emitters and the QuestManager share this asset without knowing each other.</summary> [CreateAssetMenu(menuName = "Quests/Event Channel", fileName = "QuestEventChannel")] public sealed class QuestEventChannel : ScriptableObject { public event Action<QuestEvent> Raised; public void Raise(QuestEvent e) => Raised?.Invoke(e); public void Raise(ObjectiveType type, string targetId, int amount = 1) => Raised?.Invoke(new QuestEvent(type, targetId, amount)); } } ``` **Why a channel asset?** An enemy prefab can emit `Kill/"wolf"` without referencing the quest system; the quest system listens without referencing any enemy. The same pattern appears as event channels in [[ScriptableObject Architecture]]. **Pitfall:** subscribers must unsubscribe in `OnDisable`/`OnDestroy`. A `ScriptableObject` can outlive Play Mode when Enter Play Mode Options disables Domain Reload, so leaked handlers from a previous run can still be attached. ## 5. The runtime: QuestProgress and QuestLog ### 5.1 `QuestProgress.cs` ```csharp using System; namespace Quests { public enum QuestStatus { NotStarted, Active, ReadyToTurnIn, Completed, Failed } public sealed class QuestProgress { public readonly QuestDefinition def; public QuestStatus status; public readonly int[] counts; public float timeLeft; public QuestProgress(QuestDefinition def) { this.def = def; counts = new int[def.objectives.Count]; timeLeft = def.timeLimitSeconds; status = QuestStatus.Active; } public bool IsDone(int i) => counts[i] >= def.objectives[i].required; /// <summary>For ordered quests: the first incomplete objective (or -1 when all are done).</summary> public int CurrentIndex() { for (int i = 0; i < counts.Length; i++) if (!IsDone(i)) return i; return -1; } public bool AllRequiredDone() { for (int i = 0; i < counts.Length; i++) if (!def.objectives[i].optional && !IsDone(i)) return false; return true; } public bool AcceptsEventFor(int i) => !def.ordered || i == CurrentIndex(); } } ``` ### 5.2 `QuestLog.cs`: the state machine ```csharp using System; using System.Collections.Generic; namespace Quests { /// <summary> /// Rules and state for all quests. Pure C#; raises events after state is consistent. /// Event-based objectives (kill, reach, talk, custom) accumulate via Report. /// State-based objectives (collect) are SET from a source of truth via SetCollected. /// </summary> public sealed class QuestLog { readonly Dictionary<string, QuestProgress> _quests = new Dictionary<string, QuestProgress>(); readonly HashSet<string> _completed = new HashSet<string>(); readonly List<QuestProgress> _scratch = new List<QuestProgress>(); public event Action<QuestProgress> Changed; // any change to a quest public event Action<QuestProgress> StatusChanged; // status transitions public event Action<QuestProgress, int> ObjectiveProgressed; // quest, objective index public IEnumerable<QuestProgress> Quests => _quests.Values; public IReadOnlyCollection<string> CompletedIds => _completed; // ---------- queries ---------- public QuestStatus GetStatus(QuestDefinition def) { if (_quests.TryGetValue(def.id, out var p)) return p.status; return _completed.Contains(def.id) ? QuestStatus.Completed : QuestStatus.NotStarted; } public QuestProgress Get(QuestDefinition def) => _quests.TryGetValue(def.id, out var p) ? p : null; public bool IsCompleted(QuestDefinition def) => _completed.Contains(def.id); public bool CanAccept(QuestDefinition def, int playerLevel) { var st = GetStatus(def); bool startable = st == QuestStatus.NotStarted || st == QuestStatus.Failed || (st == QuestStatus.Completed && def.repeatable); if (!startable || playerLevel < def.minLevel) return false; foreach (var pre in def.prerequisites) if (pre != null && !_completed.Contains(pre.id)) return false; return true; } // ---------- transitions ---------- public bool Accept(QuestDefinition def, int playerLevel, Func<string, int> collectedCount = null) { if (!CanAccept(def, playerLevel)) return false; var p = new QuestProgress(def); _quests[def.id] = p; if (collectedCount != null) for (int i = 0; i < def.objectives.Count; i++) if (def.objectives[i].type == ObjectiveType.Collect) p.counts[i] = Math.Min(def.objectives[i].required, collectedCount(def.objectives[i].targetId)); StatusChanged?.Invoke(p); Evaluate(p); Changed?.Invoke(p); return true; } /// <summary>Event-based progress: kill, reach, talk, custom. Collect events are ignored (see SetCollected).</summary> public void Report(QuestEvent e) { if (e.type == ObjectiveType.Collect) return; _scratch.Clear(); foreach (var p in _quests.Values) if (p.status == QuestStatus.Active) _scratch.Add(p); foreach (var p in _scratch) { if (p.status != QuestStatus.Active) continue; // may have changed during this loop if (MatchesAny(p.def.failTriggers, e)) { Fail(p); continue; } bool changed = false; for (int i = 0; i < p.counts.Length; i++) { var o = p.def.objectives[i]; if (o.type != e.type || p.IsDone(i)) continue; if (!TargetMatches(o.targetId, e.targetId)) continue; if (!p.AcceptsEventFor(i)) continue; p.counts[i] = Math.Min(o.required, p.counts[i] + Math.Max(1, e.amount)); ObjectiveProgressed?.Invoke(p, i); changed = true; } if (changed) { Evaluate(p); Changed?.Invoke(p); } } } /// <summary>State-based progress: set Collect objectives for this item id to the current total held.</summary> public void SetCollected(string itemId, int total) { _scratch.Clear(); foreach (var p in _quests.Values) if (p.status == QuestStatus.Active || p.status == QuestStatus.ReadyToTurnIn) _scratch.Add(p); foreach (var p in _scratch) { bool changed = false; for (int i = 0; i < p.counts.Length; i++) { var o = p.def.objectives[i]; if (o.type != ObjectiveType.Collect || o.targetId != itemId) continue; int v = Math.Min(o.required, Math.Max(0, total)); if (v == p.counts[i]) continue; p.counts[i] = v; // may go DOWN: dropping an item un-completes ObjectiveProgressed?.Invoke(p, i); changed = true; } if (changed) { Evaluate(p); Changed?.Invoke(p); } } } /// <summary>Moves ReadyToTurnIn -> Completed. The caller grants rewards afterwards.</summary> public bool TryTurnIn(QuestDefinition def) { if (!_quests.TryGetValue(def.id, out var p) || p.status != QuestStatus.ReadyToTurnIn) return false; Complete(p); return true; } public bool Abandon(QuestDefinition def) { if (!_quests.TryGetValue(def.id, out var p)) return false; if (p.status != QuestStatus.Active && p.status != QuestStatus.ReadyToTurnIn) return false; _quests.Remove(def.id); p.status = QuestStatus.NotStarted; StatusChanged?.Invoke(p); Changed?.Invoke(p); return true; } public void Fail(QuestProgress p) { if (p.status != QuestStatus.Active && p.status != QuestStatus.ReadyToTurnIn) return; p.status = QuestStatus.Failed; StatusChanged?.Invoke(p); Changed?.Invoke(p); } public void Tick(float deltaTime) { _scratch.Clear(); foreach (var p in _quests.Values) if (p.status == QuestStatus.Active && p.def.timeLimitSeconds > 0f) _scratch.Add(p); foreach (var p in _scratch) { p.timeLeft -= deltaTime; if (p.timeLeft <= 0f) { p.timeLeft = 0f; Fail(p); } else Changed?.Invoke(p); // lets a tracker redraw the countdown (throttle in the UI) } } // ---------- internals ---------- void Evaluate(QuestProgress p) { bool all = p.AllRequiredDone(); if (p.status == QuestStatus.Active && all) { if (p.def.autoComplete) Complete(p); else { p.status = QuestStatus.ReadyToTurnIn; StatusChanged?.Invoke(p); } } else if (p.status == QuestStatus.ReadyToTurnIn && !all) { p.status = QuestStatus.Active; // e.g. the player dropped a collected item StatusChanged?.Invoke(p); } } void Complete(QuestProgress p) { p.status = QuestStatus.Completed; _quests.Remove(p.def.id); // finished quests live only in the completed set _completed.Add(p.def.id); StatusChanged?.Invoke(p); Changed?.Invoke(p); } static bool TargetMatches(string objectiveTarget, string eventTarget) => string.IsNullOrEmpty(objectiveTarget) || objectiveTarget == eventTarget; static bool MatchesAny(List<QuestTrigger> triggers, QuestEvent e) { foreach (var t in triggers) if (t.type == e.type && TargetMatches(t.targetId, e.targetId)) return true; return false; } // ---------- save support ---------- public void RestoreProgress(QuestProgress p) { _quests[p.def.id] = p; } public void RestoreCompleted(string id) { _completed.Add(id); } public void ClearAll() { _quests.Clear(); _completed.Clear(); } } } ``` **How to read this** - **`Report` vs `SetCollected`.** Kill/reach/talk accumulate (cumulative events). Collect is **set from truth**. That one distinction removes a whole class of bugs ("I dropped the pelts but the quest stayed complete", "I got the pelts before accepting the quest"). - **Snapshots (`_scratch`).** Handlers raised inside the loop might accept or fail quests, which would modify `_quests` during enumeration. We iterate a copy and recheck `status` each time. - **`Evaluate` runs after each change.** Active -> ReadyToTurnIn when every non-optional objective is done; back to Active if progress regresses (state-based). With `autoComplete` it skips turn-in. - **Completed quests leave `_quests`** and live in `_completed`, which keeps the active set small and makes prerequisite checks cheap. - **Failed quests stay** in `_quests` as `Failed` until re-accepted (`CanAccept` allows it): the Level 13 "win at losing" rule. - **Ordered quests** only credit kill/reach/talk events to the *current* objective, but Collect is still updated by state (so a player who already holds the key sees it counted when that step arrives). If you prefer strict gating, add `AcceptsEventFor` to `SetCollected`. - `Tick` raises `Changed` every frame for timed quests; **throttle redraws in the UI** (section 8 only rebuilds once per 0.25 s). ## 6. QuestManager: inventory, rewards, timers The manager is the *glue*: it owns the `QuestLog`, subscribes to the channel and to the inventory, and grants rewards. ### 6.1 `QuestManager.cs` ```csharp using System; using System.Collections.Generic; using Inventories; // remove with the inventory parts if you do not use that tutorial using UnityEngine; namespace Quests { public sealed class QuestManager : MonoBehaviour { [SerializeField] QuestEventChannel channel; [SerializeField] QuestDatabase quests; [SerializeField] ItemDatabase items; [SerializeField] InventoryHolder inventory; [SerializeField, Min(1)] int playerLevel = 1; public QuestLog Log { get; } = new QuestLog(); public int PlayerLevel { get => playerLevel; set => playerLevel = Mathf.Max(1, value); } public QuestDatabase Database => quests; public event Action<int> XpAwarded; public event Action<QuestDefinition> QuestCompleted; public event Action<QuestDefinition, string> TurnInBlocked; // quest, reason readonly List<QuestProgress> _tmp = new List<QuestProgress>(); void OnEnable() { if (channel != null) channel.Raised += OnQuestEvent; } void Start() { // Inventory.Awake has run by now, so holder.Inventory exists. if (inventory != null) inventory.Inventory.Changed += SyncCollectObjectives; Log.StatusChanged += OnStatusChanged; } void OnDisable() { if (channel != null) channel.Raised -= OnQuestEvent; } void OnDestroy() { if (inventory != null && inventory.Inventory != null) inventory.Inventory.Changed -= SyncCollectObjectives; Log.StatusChanged -= OnStatusChanged; } void Update() => Log.Tick(Time.deltaTime); // ---------- commands ---------- public bool Accept(QuestDefinition q) => Log.Accept(q, playerLevel, CountOfItem); public bool Abandon(QuestDefinition q) => Log.Abandon(q); /// <summary>Turn in a quest: checks there is room for rewards, completes it, consumes items, grants rewards.</summary> public bool TurnIn(QuestDefinition q) { var progress = Log.Get(q); if (progress == null || progress.status != QuestStatus.ReadyToTurnIn) return false; // 1) Conservative room check BEFORE changing anything (ignores the room freed by consumed items). if (inventory != null) { foreach (var r in q.itemRewards) { if (r.item != null && inventory.Inventory.RoomFor(r.item) < r.count) { TurnInBlocked?.Invoke(q, "Your bag is too full for the reward."); return false; } } } // 2) Move the quest to Completed FIRST. Consuming items next triggers SyncCollectObjectives; // if the quest were still active, the counts would drop and it would revert to Active. if (!Log.TryTurnIn(q)) return false; // 3) Consume collected items flagged consumeOnTurnIn. if (inventory != null) { foreach (var o in q.objectives) { if (o.type != ObjectiveType.Collect || !o.consumeOnTurnIn) continue; var def = items.Get(o.targetId); if (def != null) inventory.Inventory.Remove(def, o.required); } } // 4) Grant rewards. if (inventory != null) foreach (var r in q.itemRewards) if (r.item != null) inventory.Give(r.item, r.count); if (q.xpReward > 0) XpAwarded?.Invoke(q.xpReward); QuestCompleted?.Invoke(q); return true; } // ---------- glue ---------- void OnQuestEvent(QuestEvent e) => Log.Report(e); int CountOfItem(string itemId) { if (inventory == null || items == null) return 0; var def = items.Get(itemId); return def == null ? 0 : inventory.Inventory.Count(def); } void SyncCollectObjectives() { _tmp.Clear(); foreach (var p in Log.Quests) _tmp.Add(p); foreach (var p in _tmp) { if (p.status != QuestStatus.Active && p.status != QuestStatus.ReadyToTurnIn) continue; foreach (var o in p.def.objectives) if (o.type == ObjectiveType.Collect) Log.SetCollected(o.targetId, CountOfItem(o.targetId)); } } void OnStatusChanged(QuestProgress p) { if (p.status == QuestStatus.Completed && p.def.autoComplete) { // autoComplete quests skip TurnIn(), so grant rewards here if (inventory != null) foreach (var r in p.def.itemRewards) if (r.item != null) inventory.Give(r.item, r.count); if (p.def.xpReward > 0) XpAwarded?.Invoke(p.def.xpReward); QuestCompleted?.Invoke(p.def); } } } } ``` **Why the ordering in `TurnIn` matters.** `Inventory.Remove` raises `Changed`, which triggers `SyncCollectObjectives`, which calls `SetCollected` with the *smaller* count. If the quest were still `ReadyToTurnIn` at that moment, it would revert to `Active` and the turn-in would be undone mid-flight. So the order is: **check room, mark completed, then consume and reward.** This is a good classroom example of events causing re-entrancy. **Connect XP.** `XpAwarded` is a plain event: subscribe a small adapter to your leveling component from [[3.Leveling and XP Curves]] (`stats.AddXp(amount)`). Likewise `PlayerLevel` can be fed from the same system's level-up event. ## 7. Emitters and quest givers in the world ### 7.1 `QuestEventEmitter.cs` ```csharp using UnityEngine; namespace Quests { /// <summary> /// Raises one quest event. Call Emit() from a UnityEvent (enemy death, dialogue end, switch), /// or tick "emitOnTriggerEnter" for reach-a-place objectives. /// </summary> public sealed class QuestEventEmitter : MonoBehaviour { [SerializeField] QuestEventChannel channel; [SerializeField] ObjectiveType type = ObjectiveType.Reach; [SerializeField] string targetId = ""; [SerializeField, Min(1)] int amount = 1; [SerializeField] bool emitOnTriggerEnter; [SerializeField] string requiredTag = "Player"; [SerializeField] bool onlyOnce = true; bool _fired; public void Emit() { if (onlyOnce && _fired) return; _fired = true; if (channel != null) channel.Raise(type, targetId, amount); } void OnTriggerEnter(Collider other) { if (!emitOnTriggerEnter) return; if (!string.IsNullOrEmpty(requiredTag) && !other.CompareTag(requiredTag)) return; Emit(); } } } ``` Examples: put one on a shrine trigger (`Reach`, id `shrine`); put one on a wolf prefab with `Kill`, `wolf`, `onlyOnce` true, and call `Emit()` from the wolf's death `UnityEvent` or from the death state in [[Enemy AI State Machine]]. For `Talk` objectives, have the dialogue system's "conversation ended" hook call `Emit()` on an emitter with `Talk`/`elder` (see [[0.Dialogue System]] or [[Conversation Engine Design for Unity 6]]; adapt to the hook your version exposes). ### 7.2 `QuestGiver.cs` ```csharp using System.Collections.Generic; using UnityEngine; namespace Quests { public sealed class QuestGiver : MonoBehaviour { public enum Marker { None, Available, InProgress, TurnIn } [SerializeField] QuestManager manager; [SerializeField] List<QuestDefinition> quests = new List<QuestDefinition>(); [SerializeField] GameObject availableMarker, inProgressMarker, turnInMarker; public Marker Current { get; private set; } void Start() { manager.Log.Changed += _ => RefreshMarker(); manager.Log.StatusChanged += _ => RefreshMarker(); RefreshMarker(); } void OnDestroy() { // Lambdas cannot be unsubscribed: in production keep named handlers. Fine here because Log and giver share a lifetime. } public Marker ComputeMarker() { bool avail = false, progress = false; foreach (var q in quests) { switch (manager.Log.GetStatus(q)) { case QuestStatus.ReadyToTurnIn: return Marker.TurnIn; case QuestStatus.Active: progress = true; break; default: if (manager.Log.CanAccept(q, manager.PlayerLevel)) avail = true; break; } } return avail ? Marker.Available : progress ? Marker.InProgress : Marker.None; } void RefreshMarker() { Current = ComputeMarker(); if (availableMarker) availableMarker.SetActive(Current == Marker.Available); if (inProgressMarker) inProgressMarker.SetActive(Current == Marker.InProgress); if (turnInMarker) turnInMarker.SetActive(Current == Marker.TurnIn); } /// <summary>Call from your interaction system. Returns a line of text for the dialogue UI.</summary> public string Interact() { // 1) turn-in beats everything foreach (var q in quests) if (manager.Log.GetStatus(q) == QuestStatus.ReadyToTurnIn) return manager.TurnIn(q) ? quot;Thank you. ({q.title} complete)" : "Come back when you have room to carry it."; // 2) offer the first acceptable quest foreach (var q in quests) if (manager.Log.CanAccept(q, manager.PlayerLevel)) return manager.Accept(q) ? quot;New quest: {q.title}\n{q.summary}" : "Not now."; // 3) reminder of an active quest foreach (var q in quests) if (manager.Log.GetStatus(q) == QuestStatus.Active) return quot;Still working on {q.title}?"; return "Nothing for you today."; } } } ``` The three-marker idea (available / in progress / turn-in) is the classic way to answer "who should I talk to?" without a UI. Marker child objects can be a floating `!` and `?` in world space; in VR make them diegetic (a lantern, a glow) rather than billboard text. ## 8. UI Toolkit quest tracker A compact tracker in a corner showing active quests with progress. Built in code like the inventory view in [[Inventory Systems]] (`UIDocument` with a `PanelSettings` asset, see [[2.UI Toolkit Project Setup]]). ### 8.1 `QuestTrackerView.cs` ```csharp using System.Collections.Generic; using System.Text; using UnityEngine; using UnityEngine.UIElements; namespace Quests { [RequireComponent(typeof(UIDocument))] public sealed class QuestTrackerView : MonoBehaviour { [SerializeField] QuestManager manager; [SerializeField] float minRedrawInterval = 0.25f; VisualElement _root, _panel; bool _dirty; float _lastDraw; readonly List<QuestProgress> _sorted = new List<QuestProgress>(); void OnEnable() { _root = GetComponent<UIDocument>().rootVisualElement; _panel = new VisualElement { name = "quest-tracker", pickingMode = PickingMode.Ignore }; var s = _panel.style; s.position = Position.Absolute; s.left = 24; s.top = 24; s.maxWidth = 360; _root.Add(_panel); manager.Log.Changed += OnLogChanged; _dirty = true; } void OnDisable() { if (manager != null) manager.Log.Changed -= OnLogChanged; _panel?.RemoveFromHierarchy(); } void OnLogChanged(QuestProgress _) => _dirty = true; void Update() { if (!_dirty || Time.unscaledTime - _lastDraw < minRedrawInterval) return; _dirty = false; _lastDraw = Time.unscaledTime; Redraw(); } void Redraw() { _panel.Clear(); _sorted.Clear(); foreach (var q in manager.Log.Quests) if (q.status == QuestStatus.Active || q.status == QuestStatus.ReadyToTurnIn) _sorted.Add(q); _sorted.Sort((a, b) => string.CompareOrdinal(a.def.title, b.def.title)); foreach (var q in _sorted) { var box = new VisualElement(); box.style.marginBottom = 10; box.style.paddingLeft = box.style.paddingRight = 10; box.style.paddingTop = box.style.paddingBottom = 6; box.style.backgroundColor = new Color(0f, 0f, 0f, 0.45f); box.style.borderTopLeftRadius = box.style.borderTopRightRadius = box.style.borderBottomLeftRadius = box.style.borderBottomRightRadius = 6; var title = new Label(q.status == QuestStatus.ReadyToTurnIn ? quot;{q.def.title} (turn in)" : q.def.title); title.style.unityFontStyleAndWeight = FontStyle.Bold; title.style.color = q.status == QuestStatus.ReadyToTurnIn ? new Color(0.5f, 1f, 0.5f) : Color.white; box.Add(title); int current = q.CurrentIndex(); for (int i = 0; i < q.def.objectives.Count; i++) { var o = q.def.objectives[i]; bool done = q.IsDone(i); if (o.hidden && !done) continue; if (q.def.ordered && !done && i != current) continue; // reveal sequential steps one at a time var line = new Label(ObjectiveText(o, q.counts[i], done)); line.style.color = done ? new Color(0.6f, 0.9f, 0.6f) : new Color(0.9f, 0.9f, 0.9f); line.style.whiteSpace = WhiteSpace.Normal; box.Add(line); } if (q.def.timeLimitSeconds > 0f && q.status == QuestStatus.Active) { var t = new Label(quot;Time left: {Mathf.CeilToInt(q.timeLeft)} s"); t.style.color = q.timeLeft < 30f ? new Color(1f, 0.5f, 0.4f) : new Color(1f, 0.85f, 0.4f); box.Add(t); } _panel.Add(box); } } static string ObjectiveText(ObjectiveDefinition o, int count, bool done) { var sb = new StringBuilder(); sb.Append(done ? "[x] " : "[ ] ").Append(o.description); if (o.required > 1) sb.Append(quot; ({count}/{o.required})"); if (o.optional) sb.Append(" (optional)"); return sb.ToString(); } } } ``` **Design points.** Redraws are *throttled and dirty-flagged* (the timer would otherwise force a rebuild every frame). Sequential quests reveal only the current step (Level 9: show the goal, then the way). Hidden objectives appear only when done, which makes them pleasant surprises. Add a text-to-speech or audio stinger on `ObjectiveProgressed` using the pooled service from [[Interactable Audio]]; Rogers' "sound as feedback" applies to quest updates as much as to hits. ## 9. Saving and loading ### 9.1 `QuestSave.cs` ```csharp using System; using System.Collections.Generic; using System.IO; using UnityEngine; namespace Quests { [Serializable] public sealed class QuestSave { public int version = 1; public List<string> completed = new List<string>(); public List<SavedQuest> active = new List<SavedQuest>(); [Serializable] public sealed class SavedQuest { public string id; public int status; // QuestStatus as int public int[] counts; // arrays inside a class are fine for JsonUtility public float timeLeft; } public static QuestSave Capture(QuestLog log) { var s = new QuestSave(); foreach (var id in log.CompletedIds) s.completed.Add(id); foreach (var p in log.Quests) s.active.Add(new SavedQuest { id = p.def.id, status = (int)p.status, counts = (int[])p.counts.Clone(), timeLeft = p.timeLeft }); return s; } /// <summary>Unknown quest ids (removed quests) and resized objective lists are tolerated.</summary> public void Restore(QuestLog log, QuestDatabase db) { log.ClearAll(); foreach (var id in completed) log.RestoreCompleted(id); foreach (var sq in active) { var def = db.Get(sq.id); if (def == null) { Debug.LogWarning(quot;[QuestSave] Unknown quest id '{sq.id}', skipped."); continue; } var p = new QuestProgress(def) { status = (QuestStatus)sq.status, timeLeft = sq.timeLeft }; for (int i = 0; i < p.counts.Length && sq.counts != null && i < sq.counts.Length; i++) p.counts[i] = Mathf.Clamp(sq.counts[i], 0, def.objectives[i].required); log.RestoreProgress(p); } } public void WriteToDisk(string fileName) { string path = Path.Combine(Application.persistentDataPath, fileName); string tmp = path + ".tmp"; File.WriteAllText(tmp, JsonUtility.ToJson(this, true)); if (File.Exists(path)) File.Replace(tmp, path, null); else File.Move(tmp, path); } public static QuestSave ReadFromDisk(string fileName) { string path = Path.Combine(Application.persistentDataPath, fileName); return File.Exists(path) ? JsonUtility.FromJson<QuestSave>(File.ReadAllText(path)) : null; } } } ``` **Order of restoring matters:** restore the **inventory first**, then the quest log, then call `QuestManager`'s `SyncCollectObjectives` (invoke any inventory change or add a public `Resync()`), so collect objectives reflect what the player actually holds. Quests are saved by id, never by object reference (references serialize as session-only instance IDs with `JsonUtility`; see [[Inventory Systems]]). For the wider save architecture see [[High Scores, Saves and Player Stats]]. ## 10. Worked example: "Wolf Trouble" **Content** - Items (`ItemDefinition`, see [[Inventory Systems]]): `wolf_pelt` (stackable 20, category Resource), `healing_potion` (with `HealEffect`). - Quest `Quest_WolfTrouble`: id `wolf_trouble`, `minLevel` 1, `ordered` **false**, objectives: 1. `kill_wolves`: Kill, target `wolf`, required 5, "Defeat wolves". 2. `pelts`: Collect, target `wolf_pelt`, required 3, "Bring wolf pelts", `consumeOnTurnIn` true. 3. Optional `alpha`: Kill, target `alpha_wolf`, required 1, "Defeat the alpha", optional, hidden. Rewards: xp 50, 1 x healing potion. - Quest `Quest_FindShrine`: id `find_shrine`, objective Reach `shrine` x1, `autoComplete` true, xp 20. `Quest_WolfTrouble.prerequisites` = [Quest_FindShrine] to demonstrate gating. - Assets `QuestEventChannel` and `QuestDatabase`; list both quests. **Scene** 1. `Player`: `InventoryHolder`. `Managers`: `QuestManager` (assign channel, database, `ItemDatabase`, player holder). 2. `Elder` NPC: `QuestGiver` with `Quest_FindShrine` and `Quest_WolfTrouble` and a child `!` / `?` marker objects. 3. `Shrine` trigger: `QuestEventEmitter` (Reach, `shrine`, emit on trigger enter, tag Player). 4. Wolf prefab: `QuestEventEmitter` (Kill, `wolf`, `onlyOnce`); call `Emit()` from its death event. Make it also drop `wolf_pelt` via `WorldPickup` (or `holder.Give` in a loot script). 5. UI: `QuestTrackerView` on the `UIDocument`. **Expected play-through** 1. Interact with the Elder: `Interact()` accepts `Find the Shrine` first (the other is gated by the prerequisite). 2. Walk into the shrine trigger: Reach event -> objective done -> `autoComplete` -> rewards. Elder's marker changes to `!` for Wolf Trouble. 3. Accept Wolf Trouble. Kill wolves (progress 1/5 ... 5/5), pick up pelts (inventory `Changed` -> `SetCollected` -> 3/3). Drop one pelt: the objective drops back to 2/3 and the quest reverts to Active. Pick it up again: Ready to turn in. 4. Return to the Elder: `TurnIn` checks bag room, completes, removes 3 pelts, gives the potion, awards XP. ## 11. EditMode tests ```csharp using Quests; using NUnit.Framework; using UnityEngine; public class QuestLogTests { static QuestDefinition Make(string id, params ObjectiveDefinition[] objs) { var q = ScriptableObject.CreateInstance<QuestDefinition>(); q.id = id; q.title = id; q.minLevel = 1; q.objectives.AddRange(objs); return q; } static ObjectiveDefinition Kill(string target, int n) => new ObjectiveDefinition { id = "k_" + target, type = ObjectiveType.Kill, targetId = target, required = n, description = "kill" }; static ObjectiveDefinition Collect(string target, int n) => new ObjectiveDefinition { id = "c_" + target, type = ObjectiveType.Collect, targetId = target, required = n, description = "collect" }; [Test] public void Kill_Progress_ThenReadyToTurnIn() { var log = new QuestLog(); var q = Make("q", Kill("wolf", 3)); Assert.IsTrue(log.Accept(q, 1)); log.Report(new QuestEvent(ObjectiveType.Kill, "wolf")); log.Report(new QuestEvent(ObjectiveType.Kill, "bear")); // ignored log.Report(new QuestEvent(ObjectiveType.Kill, "wolf", 2)); Assert.AreEqual(QuestStatus.ReadyToTurnIn, log.GetStatus(q)); Assert.IsTrue(log.TryTurnIn(q)); Assert.AreEqual(QuestStatus.Completed, log.GetStatus(q)); } [Test] public void Collect_IsStateBased_CanRegress() { var log = new QuestLog(); var q = Make("q", Collect("pelt", 3)); log.Accept(q, 1, id => 0); log.SetCollected("pelt", 3); Assert.AreEqual(QuestStatus.ReadyToTurnIn, log.GetStatus(q)); log.SetCollected("pelt", 2); // dropped one Assert.AreEqual(QuestStatus.Active, log.GetStatus(q)); } [Test] public void Collect_CountsItemsHeldBeforeAccepting() { var log = new QuestLog(); var q = Make("q", Collect("pelt", 2)); log.Accept(q, 1, id => id == "pelt" ? 5 : 0); Assert.AreEqual(QuestStatus.ReadyToTurnIn, log.GetStatus(q)); } [Test] public void Ordered_OnlyCreditsCurrentObjective() { var log = new QuestLog(); var q = Make("q", Kill("a", 1), Kill("b", 1)); q.ordered = true; log.Accept(q, 1); log.Report(new QuestEvent(ObjectiveType.Kill, "b")); // not yet Assert.AreEqual(0, log.Get(q).counts[1]); log.Report(new QuestEvent(ObjectiveType.Kill, "a")); log.Report(new QuestEvent(ObjectiveType.Kill, "b")); Assert.AreEqual(QuestStatus.ReadyToTurnIn, log.GetStatus(q)); } [Test] public void Prerequisite_BlocksUntilCompleted() { var log = new QuestLog(); var first = Make("first", Kill("x", 1)); var second = Make("second", Kill("y", 1)); second.prerequisites.Add(first); Assert.IsFalse(log.CanAccept(second, 1)); log.Accept(first, 1); log.Report(new QuestEvent(ObjectiveType.Kill, "x")); log.TryTurnIn(first); Assert.IsTrue(log.CanAccept(second, 1)); } [Test] public void FailTrigger_FailsQuest_AndAllowsRetry() { var log = new QuestLog(); var q = Make("q", Kill("wolf", 3)); q.failTriggers.Add(new QuestTrigger { type = ObjectiveType.Custom, targetId = "escort_died" }); log.Accept(q, 1); log.Report(new QuestEvent(ObjectiveType.Custom, "escort_died")); Assert.AreEqual(QuestStatus.Failed, log.GetStatus(q)); Assert.IsTrue(log.CanAccept(q, 1)); // win at losing: try again } [Test] public void Timer_FailsQuest() { var log = new QuestLog(); var q = Make("q", Kill("wolf", 3)); q.timeLimitSeconds = 10f; log.Accept(q, 1); log.Tick(6f); Assert.AreEqual(QuestStatus.Active, log.GetStatus(q)); log.Tick(5f); Assert.AreEqual(QuestStatus.Failed, log.GetStatus(q)); } [Test] public void MinLevel_Gates() { var log = new QuestLog(); var q = Make("q", Kill("wolf", 1)); q.minLevel = 5; Assert.IsFalse(log.CanAccept(q, 4)); Assert.IsTrue(log.CanAccept(q, 5)); } } ``` Run in *Window > General > Test Runner > EditMode* (Test Framework from Package Manager; a dedicated testing tutorial is queued). The tests double as **executable documentation** of the rules: a student can read them before the code. ## 12. Integration, VR/Quest, pitfalls, Level Up!, patterns, exercises ### 12.1 Integration map - **Inventory:** [[Inventory Systems]] - Collect objectives, item rewards, key items (`questItem`). - **Dialogue:** [[0.Dialogue System]], [[Conversation Engine Design for Unity 6]], [[Organic Enemy Sensing]] for NPC reactions; make dialogue conditions read `Log.GetStatus`, and make dialogue endings call a `QuestEventEmitter` (`Talk`). - **Stats and XP:** [[Stat-Driven RPG Systems]], especially [[3.Leveling and XP Curves]] for `XpAwarded` and `PlayerLevel`. - **Enemies:** [[Enemy AI State Machine]] - emit `Kill` from the death state. - **Levels and scenes:** [[Level Manager]], [[SceneManagement in Unity 6]] - the manager and log should persist across scenes (bootstrap scene or `DontDestroyOnLoad`), and `Reach` zones live in scenes. - **Save:** [[High Scores, Saves and Player Stats]]. - **Navigation/guidance:** [[NavMesh in Unity 6 (AI Navigation 2.x)]] can compute a path to the active objective for a breadcrumb trail. - **Audio:** [[Interactable Audio]] for objective/complete stingers; [[Music Mood Mixer]] to raise tension on a timed quest. - **Cutscenes:** [[Timeline Basics]] for quest-complete moments. - **Editor tooling:** [[Custom Inspectors and Editor Tooling]] - a validator that checks duplicate ids, Collect targets that exist in the `ItemDatabase`, and prerequisite cycles is the single most useful editor tool for this system. ### 12.2 VR and Quest - **Build target.** Quest 2 projects: **Unity 6.3 LTS (6000.3.27f2+)**, stack in [[2.Quest 2 SDK Stack]]. The quest code is version-agnostic. - **Diegetic tracker.** Replace the screen-corner panel with a **wrist panel** or a physical *journal* object ([[6.Diegetic Terminal and Wrist Display]], [[5.Diegetic UI Approaches]], [[8.UI Toolkit for VR]]); never lock text to the player's face. - **Guidance without markers.** In VR prefer **audio cues** (spatialized NPC calls, [[Spatial Audio and Reverb Zones]]), landmarks and lighting, which is the review's Level 9 theme-park landmark idea ([[Lighting and Baking]]). - **Givers.** NPC interaction by hand-pointing or gaze dwell; reuse `QuestGiver.Interact()` from an XR interaction event. - **Persistence on headsets.** Save on `OnApplicationPause(true)` as well as quit, as in the inventory note. Worked scene to extend: [[7.Quest 2 Worked Example Room]] (a "find the key, open the door" quest). ### 12.3 Pitfalls | Pitfall | Fix | |---|---| | Polling objectives in `Update` | Use events; the log reacts to `Report` and inventory `Changed` | | Collect as an event | Collect is **state** (`SetCollected`), so dropping items regresses progress | | Consuming items before completing | Complete first, then consume (section 6 explains the re-entrancy trap) | | Mutating `_quests` while iterating | Iterate snapshots (`_scratch`) | | Renaming quest/objective ids after release | Never; saves reference them. Add new ids, deprecate old ones | | Event channel leaks | Unsubscribe in `OnDisable`; beware Domain Reload disabled in Enter Play Mode Options | | Redrawing the tracker every frame | Dirty flag plus minimum interval (section 8) | | Fetch quests that are only walking | Level 9 warning: add a decision, a risk or a discovery to each objective | | Soft-locks | Key items are `questItem` (cannot be dropped); failed quests are re-acceptable; avoid objectives that depend on a killable NPC unless a fail trigger exists | | Saves breaking when content changes | Skip unknown ids and clamp counts (as in `QuestSave.Restore`) | | Giver offers a locked quest | Use `CanAccept` for markers and offers, not just `NotStarted` | ### 12.4 Level Up! tie-ins (recap) Level 9 goal setting and "show the door, then the key" (also Level 12); Level 4 beat chart as the quest planning table; Level 13 hard-won rewards and winning at losing (failed quests are retryable); Level 3 for the writing; and the general *Level Up!* point that the player must always understand what to do next, which is the tracker's job. Cross-check wording with [[Level Up! (Review)]] before quoting. ### 12.5 Game Programming Patterns - **Observer / Event Queue** - `QuestEventChannel`, `QuestLog` events. - **State** - `QuestStatus` transitions (compare with [[Enemy AI State Machine]]). - **Type Object** - `QuestDefinition` and `ObjectiveDefinition` make quest "kinds" data. - **Flyweight** - the definition is shared; `QuestProgress` is the per-player state. - **Command** - `Accept`, `TurnIn`, `Abandon` as discrete operations (add an undo log for debugging). - **Dirty Flag** - tracker redraws. - **Service Locator** - `QuestManager` is looked up by givers; inject it via Inspector fields as above rather than a static. See [[Game Programming Patterns (Review)]]. ### 12.6 Exercises 1. **Branching:** add `ObjectiveType.Custom` choices ("spare" vs "slay") and `followUp` quests chosen by which objective completed; show the branch in a test. 2. **Escort:** add an NPC with health that raises `Custom/"escort_died"` on death; verify the fail trigger and the retry path. 3. **Timed rescue:** use `timeLimitSeconds` and drive [[Music Mood Mixer]] tension from `timeLeft`. 4. **Journal UI:** a full-screen UI Toolkit journal with Active / Completed / Failed tabs from `Log.Quests` and `CompletedIds`. 5. **Validator:** an Editor window that scans all `QuestDefinition` assets for duplicate ids, missing item ids and prerequisite cycles ([[Custom Inspectors and Editor Tooling]]). 6. **Repeatable bounty:** `repeatable` plus a daily reset using a timestamp saved with `QuestSave`. 7. **Diegetic tracker:** put the tracker on a wrist-mounted world-space panel in a 6000.3.27f2+ Quest project. ## Related (existing) - [[Inventory Systems]] - the inventory this system reads and rewards - [[ScriptableObject Architecture]] - definitions as assets, event channels - [[Stat-Driven RPG Systems]] / [[3.Leveling and XP Curves]] - XP and level gating - [[0.Dialogue System]] / [[Conversation Engine Design for Unity 6]] - NPC conversations - [[Enemy AI State Machine]] - kill events - [[UI Toolkit and Diegetic UI]] - tracker UI background - [[High Scores, Saves and Player Stats]] - saves - [[Game Programming Patterns (Review)]], [[Level Up! (Review)]] ## Official docs (index) - `JsonUtility.ToJson`: https://docs.unity3d.com/6000.5/Documentation/ScriptReference/JsonUtility.ToJson.html - `Application.persistentDataPath`: https://docs.unity3d.com/6000.5/Documentation/ScriptReference/Application-persistentDataPath.html - `ISerializationCallbackReceiver`: https://docs.unity3d.com/6000.5/Documentation/ScriptReference/ISerializationCallbackReceiver.html - `SerializeReference` (polymorphic alternative; the page documents the rules, a type-picker drawer is custom work): https://docs.unity3d.com/6000.5/Documentation/ScriptReference/SerializeReference.html