# ScriptableObject Architecture **Target:** Unity 6000.5 and above (Unity 6). No packages required. ScriptableObjects (SOs) are assets that hold data and behaviour outside of any scene. Used deliberately, they let you decouple systems: a health bar, an audio cue and a save system can all react to "player health changed" without referencing each other. This tutorial builds the four classic SO patterns, then ties them together in a worked example. ## Overview | Pattern | What it solves | |---|---| | Data container | Shared, designer-editable config (stats, item definitions) without duplicating it on every prefab | | Variable reference | A float/int that lives in an asset, so many objects read the same value | | Event channel | Fire-and-forget events between systems that never reference each other | | Runtime set | A live list of "all enemies currently alive" without `FindObjectsByType` | Folder layout used below: ``` Assets/ _Project/ Scripts/SO/ # ScriptableObject classes Scripts/Runtime/ # MonoBehaviours Data/ # .asset instances ``` ## Step 1 - Data container A data container is read-only config. Treat it as **immutable at runtime**: if you change an SO's fields in Play Mode in the Editor, the change is written to the asset and persists after you exit Play Mode (see Pitfalls). ```csharp using UnityEngine; [CreateAssetMenu(fileName = "EnemyStats", menuName = "Game/Data/Enemy Stats")] public class EnemyStats : ScriptableObject { [Header("Identity")] public string displayName = "Grunt"; public Sprite icon; [Header("Combat")] [Min(1)] public int maxHealth = 100; [Min(0f)] public float moveSpeed = 3.5f; [Min(0)] public int contactDamage = 10; // Validation runs in the Editor whenever a value changes in the Inspector. private void OnValidate() { if (string.IsNullOrWhiteSpace(displayName)) displayName = name; } } ``` 1. Create `EnemyStats.cs` in `Scripts/SO/` with the code above. 2. In `Data/`, right-click **Create > Game > Data > Enemy Stats**. Make `Grunt.asset` and `Brute.asset`. 3. Every enemy prefab references one of these assets instead of storing its own copies of the numbers. 100 grunts = one asset in memory. > **See also:** Nystrom, [Type Object](https://gameprogrammingpatterns.com/type-object.html) and [Flyweight](https://gameprogrammingpatterns.com/flyweight.html), plus [[Game Programming Patterns (Review)]] ## Step 2 - Variable assets and references A variable asset holds a runtime value. The trick is separating the **initial value** (serialized, designer-owned) from the **runtime value** (not serialized, reset every play session). ```csharp using System; using UnityEngine; [CreateAssetMenu(fileName = "FloatVariable", menuName = "Game/Variables/Float")] public class FloatVariable : ScriptableObject { [SerializeField] private float initialValue; [NonSerialized] private float runtimeValue; public event Action<float> Changed; public float Value { get => runtimeValue; set { if (Mathf.Approximately(runtimeValue, value)) return; runtimeValue = value; Changed?.Invoke(runtimeValue); } } public float InitialValue => initialValue; // OnEnable runs when the asset is loaded and again when entering Play Mode // (with Domain Reload on). We also hook playModeStateChanged so the reset works // when Enter Play Mode Options disable Domain Reload. private void OnEnable() => ResetValue(); public void ResetValue() => runtimeValue = initialValue; #if UNITY_EDITOR [UnityEditor.InitializeOnLoadMethod] private static void HookPlayMode() { UnityEditor.EditorApplication.playModeStateChanged += state => { if (state != UnityEditor.PlayModeStateChange.ExitingEditMode) return; foreach (var guid in UnityEditor.AssetDatabase.FindAssets("t:FloatVariable")) { var path = UnityEditor.AssetDatabase.GUIDToAssetPath(guid); var v = UnityEditor.AssetDatabase.LoadAssetAtPath<FloatVariable>(path); if (v != null) v.ResetValue(); } }; } #endif } ``` A **reference** lets a field be either a hard-coded constant or a shared variable, chosen in the Inspector: ```csharp using System; using UnityEngine; [Serializable] public class FloatReference { public bool useConstant = true; public float constantValue; public FloatVariable variable; public FloatReference() { } public FloatReference(float value) { useConstant = true; constantValue = value; } public float Value { get => useConstant || variable == null ? constantValue : variable.Value; set { if (useConstant || variable == null) constantValue = value; else variable.Value = value; } } public static implicit operator float(FloatReference r) => r.Value; } ``` Explanation: `[NonSerialized]` keeps `runtimeValue` out of the asset file, so Play Mode changes never leak into version control. `initialValue` is the only thing saved. ## Step 3 - Event channels An event channel is an asset that systems raise and listen to. Neither side knows the other exists, only the asset. ```csharp using System; using UnityEngine; // Generic base so you can make typed channels with one line each. public abstract class EventChannel<T> : ScriptableObject { [TextArea] public string description; private event Action<T> Raised; public void Raise(T payload) => Raised?.Invoke(payload); public void Subscribe(Action<T> listener) => Raised += listener; public void Unsubscribe(Action<T> listener) => Raised -= listener; // Clear stale listeners if Domain Reload is disabled. private void OnDisable() => Raised = null; } ``` ```csharp using UnityEngine; [CreateAssetMenu(menuName = "Game/Events/Int Event Channel")] public class IntEventChannel : EventChannel<int> { } ``` ```csharp using UnityEngine; public struct Unit { public static readonly Unit Default = default; } [CreateAssetMenu(menuName = "Game/Events/Void Event Channel")] public class VoidEventChannel : EventChannel<Unit> { public void Raise() => Raise(Unit.Default); } ``` An optional Inspector-friendly listener that forwards to a `UnityEvent` (useful for designers wiring sounds/VFX without code): ```csharp using UnityEngine; using UnityEngine.Events; public class IntEventListener : MonoBehaviour { [SerializeField] private IntEventChannel channel; [SerializeField] private UnityEvent<int> response; private void OnEnable() { if (channel) channel.Subscribe(OnRaised); } private void OnDisable() { if (channel) channel.Unsubscribe(OnRaised); } private void OnRaised(int value) => response?.Invoke(value); } ``` Rule: **always subscribe in `OnEnable` and unsubscribe in `OnDisable`.** SOs outlive scenes, so a destroyed listener that never unsubscribed will throw `MissingReferenceException` the next time the channel fires. > **See also:** Nystrom, [Observer](https://gameprogrammingpatterns.com/observer.html), plus [[Game Programming Patterns (Review)]] ## Step 4 - Runtime sets A runtime set tracks live objects. Objects add themselves on enable and remove themselves on disable. ```csharp using System; using System.Collections.Generic; using UnityEngine; public abstract class RuntimeSet<T> : ScriptableObject { [NonSerialized] private readonly List<T> items = new(); public IReadOnlyList<T> Items => items; public int Count => items.Count; public event Action<int> CountChanged; public void Add(T item) { if (items.Contains(item)) return; items.Add(item); CountChanged?.Invoke(items.Count); } public void Remove(T item) { if (items.Remove(item)) CountChanged?.Invoke(items.Count); } private void OnDisable() { items.Clear(); CountChanged = null; } } ``` ```csharp using UnityEngine; [CreateAssetMenu(menuName = "Game/Runtime Sets/Enemy Set")] public class EnemyRuntimeSet : RuntimeSet<Enemy> { } ``` > **See also:** Nystrom, [Service Locator](https://gameprogrammingpatterns.com/service-locator.html) (runtime sets as a lightweight registry), plus [[Game Programming Patterns (Review)]] ## Step 5 - Worked example: enemies, player health and a HUD Goal: the player takes damage from enemies; a HUD label shows health and enemies remaining; a "wave cleared" event fires when the set empties. No script references another script directly. **Assets to create in `Data/`:** 1. `PlayerHealth` (Float Variable), initial value 100. 2. `PlayerDamaged` (Int Event Channel). 3. `WaveCleared` (Void Event Channel). 4. `ActiveEnemies` (Enemy Set). 5. `Grunt` (Enemy Stats) from Step 1. ```csharp using UnityEngine; [RequireComponent(typeof(Collider))] public class Enemy : MonoBehaviour { [SerializeField] private EnemyStats stats; [SerializeField] private EnemyRuntimeSet activeEnemies; [SerializeField] private IntEventChannel playerDamaged; private int health; private void OnEnable() { health = stats.maxHealth; // copy config into local runtime state activeEnemies.Add(this); } private void OnDisable() => activeEnemies.Remove(this); public void TakeDamage(int amount) { health -= amount; if (health <= 0) Destroy(gameObject); } private void OnTriggerEnter(Collider other) { if (other.CompareTag("Player")) playerDamaged.Raise(stats.contactDamage); } } ``` ```csharp using UnityEngine; public class PlayerHealthController : MonoBehaviour { [SerializeField] private FloatVariable health; [SerializeField] private IntEventChannel playerDamaged; private void OnEnable() => playerDamaged.Subscribe(ApplyDamage); private void OnDisable() => playerDamaged.Unsubscribe(ApplyDamage); private void ApplyDamage(int amount) { health.Value = Mathf.Max(0f, health.Value - amount); if (health.Value <= 0f) Debug.Log("Player died"); } } ``` ```csharp using UnityEngine; public class WaveWatcher : MonoBehaviour { [SerializeField] private EnemyRuntimeSet activeEnemies; [SerializeField] private VoidEventChannel waveCleared; private void OnEnable() => activeEnemies.CountChanged += OnCountChanged; private void OnDisable() => activeEnemies.CountChanged -= OnCountChanged; private void OnCountChanged(int count) { if (count == 0) waveCleared.Raise(); } } ``` HUD using UI Toolkit (add a **UI Document** with a UXML containing two `Label`s named `health-label` and `enemies-label`): ```csharp using UnityEngine; using UnityEngine.UIElements; [RequireComponent(typeof(UIDocument))] public class HudPresenter : MonoBehaviour { [SerializeField] private FloatVariable playerHealth; [SerializeField] private EnemyRuntimeSet activeEnemies; private Label healthLabel; private Label enemiesLabel; private void OnEnable() { var root = GetComponent<UIDocument>().rootVisualElement; healthLabel = root.Q<Label>("health-label"); enemiesLabel = root.Q<Label>("enemies-label"); playerHealth.Changed += OnHealth; activeEnemies.CountChanged += OnEnemies; OnHealth(playerHealth.Value); OnEnemies(activeEnemies.Count); } private void OnDisable() { playerHealth.Changed -= OnHealth; activeEnemies.CountChanged -= OnEnemies; } private void OnHealth(float v) => healthLabel.text = quot;HP {v:0}"; private void OnEnemies(int c) => enemiesLabel.text = quot;Enemies {c}"; } ``` **Wire it up:** 1. Player GameObject (tag `Player`, with a Rigidbody + Collider) gets `PlayerHealthController` → assign `PlayerHealth` and `PlayerDamaged`. 2. Enemy prefab (trigger collider) gets `Enemy` → assign `Grunt`, `ActiveEnemies`, `PlayerDamaged`. 3. An empty `GameSystems` object gets `WaveWatcher` → `ActiveEnemies`, `WaveCleared`. 4. A UI object gets `UIDocument` + `HudPresenter`. 5. Add an `IntEventListener`-style `VoidEventListener` (same pattern) to a music object and hook `WaveCleared` to play a stinger, without code. 6. Press Play. Delete enemies in the Hierarchy and watch the HUD count down and the wave event fire. ## Pitfalls - **Play Mode persistence.** In the Editor, serialized fields on SO assets modified at runtime are saved to disk. Keep runtime state in `[NonSerialized]` fields (as above) or clone with `Instantiate(asset)` for per-instance mutable copies. - **Builds behave differently.** In a build, SO changes are lost when the app quits, and assets can be unloaded when no scene references them, which resets their state. Never use SOs as a save system; serialize to a file (see [[PlayerPrefs Advanced]]). - **Enter Play Mode Options.** With Domain Reload disabled (Project Settings > Editor), static fields and SO event listeners survive between play sessions. Clear them in `OnDisable` or on `playModeStateChanged`. - **`OnEnable` order.** SO `OnEnable` runs when the asset loads, which can be before any scene object exists. Don't look up scene objects there. - **Serialization limits.** Unity doesn't serialize `Dictionary`, properties, interfaces or polymorphic types unless you use `[SerializeReference]`. Events (`event Action`) are never serialized, which is what you want. - **Addressables / AssetBundles duplicate assets.** If the same SO ends up in two bundles, you get two instances and events stop crossing between them. Keep shared channels in one group. - **Debuggability.** Indirection hides flow. Add a `description` field and log in `Raise` under a `#if UNITY_EDITOR` toggle when tracing. ## Related - [[Architecture and Code Patterns]] - [[SceneManagement]] - [[Unity Tutorials]] - [[Game Programming Patterns (Review)]]