# 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)]]