gamedev-skills/awesome-gamedev-agent-skillsCheck passed
SKILL DETAIL
unity-scriptableobjects
gamedev-skills/awesome-gamedev-agent-skills/unity-scriptableobjects
Architect Unity 6.3 LTS data and decoupling with ScriptableObjects: config/data assets, shared runtime variables, event channels, and runtime sets/registries. Use when designing data-driven systems, replacing singletons/managers, creating .asset data with CreateAssetMenu, or when the user mentions ScriptableObject, SO architecture, or data assets.
Installs · 263View source
Installation
npx skills add https://github.com/gamedev-skills/awesome-gamedev-agent-skills --skill unity-scriptableobjects
Skill files
SKILL.md
Last synced · Oct 25, 2026
references/event-channels.md›
# Event channels & runtime sets (Unity 6.3 LTS ScriptableObjects)
Depth for `unity-scriptableobjects`: two decoupling patterns that are the main reason teams
adopt SO architecture. Verified against the Unity Manual ScriptableObject docs; the design is
original.
## Event channel (publisher/subscriber with no direct references)
A raiser (e.g. the player dying) and listeners (UI, audio, spawner) both reference a shared
`GameEvent` asset, never each other. Add/remove the asset to wire systems together in the
Inspector.
```csharp
using System.Collections.Generic;
using UnityEngine;
[CreateAssetMenu(menuName = "Game/Events/Game Event")]
public class GameEvent : ScriptableObject
{
private readonly List<GameEventListener> _listeners = new();
public void Raise()
{
// iterate backwards so a listener that unregisters in its handler is safe
for (int i = _listeners.Count - 1; i >= 0; i--)
_listeners[i].OnRaised();
}
public void Register(GameEventListener l) { if (!_listeners.Contains(l)) _listeners.Add(l); }
public void Unregister(GameEventListener l) { _listeners.Remove(l); }
}
```
```csharp
using UnityEngine;
using UnityEngine.Events;
public class GameEventListener : MonoBehaviour
{
[SerializeField] private GameEvent channel;
[SerializeField] private UnityEvent response; // wire reactions in the Inspector
private void OnEnable() => channel.Register(this);
private void OnDisable() => channel.Unregister(this); // always unregister
public void OnRaised() => response.Invoke();
}
```
Usage: the player calls `onPlayerDied.Raise()`; the HUD, the music manager, and the respawner
each have a `GameEventListener` pointing at `onPlayerDied` with their own response. Adding a
new reaction is an Inspector change, not a code change.
### Typed payloads
For events that carry data, make a generic base or a concrete typed channel:
```csharp
[CreateAssetMenu(menuName = "Game/Events/Int Event")]
public class IntEvent : ScriptableObject
{
public event System.Action<int> OnRaised;
public void Raise(int value) => OnRaised?.Invoke(value); // e.g. score delta
}
```
Prefer a small number of concrete typed channels (`IntEvent`, `Vector3Event`) over a fully
generic `GameEvent<T>` because Unity cannot create assets for open generic types directly.
## Runtime set / registry (a shared, live collection)
Instead of `FindObjectsByType` (or the obsolete `FindObjectsOfType`) every frame, objects add themselves to a shared set on enable
and remove on disable. Systems (AI targeting, minimap) read the set.
```csharp
[CreateAssetMenu(menuName = "Game/Runtime Set/Enemy Set")]
public class EnemyRuntimeSet : ScriptableObject
{
public readonly List<Enemy> Items = new();
public void Add(Enemy e) { if (!Items.Contains(e)) Items.Add(e); }
public void Remove(Enemy e) => Items.Remove(e);
}
```
```csharp
public class Enemy : MonoBehaviour
{
[SerializeField] private EnemyRuntimeSet set;
private void OnEnable() => set.Add(this);
private void OnDisable() => set.Remove(this); // keep the set clean
}
```
Now "nearest enemy" is a loop over `set.Items` — no scene scans, and the list reflects exactly
what is alive.
## Gotchas
- `List` fields on a ScriptableObject are populated at runtime and **persist in the Editor**
between play sessions unless cleared. Clear runtime sets in `OnEnable` (or `OnDisable`) so a
stale entry from the last session doesn't linger; with Reload Domain off those callbacks do
not run between sessions, so clear on a Play-mode change as in the main skill's pattern 2.
- Always `Unregister`/`Remove` in `OnDisable`; objects destroyed without cleanup leave null
entries that crash listeners.
- Event channels invoke synchronously on the raiser's thread/frame — a heavy listener stalls
the raiser. Keep handlers light or marshal heavy work to a coroutine.
SKILL.md›
---
name: unity-scriptableobjects
description: >
Architect Unity 6.3 LTS data and decoupling with ScriptableObjects: config/data assets, shared
runtime variables, event channels, and runtime sets/registries. Use when designing
data-driven systems, replacing singletons/managers, creating .asset data with
CreateAssetMenu, or when the user mentions ScriptableObject, SO architecture, or data assets.
---
# Unity ScriptableObject Architecture
Use `ScriptableObject` assets to store shared data and decouple systems in Unity 6.3 LTS —
configuration, event channels, and registries that live as project assets instead of being
hard-wired into scenes or singletons. Targets **Unity 6.3 LTS (6000.3)**.
## When to use
- Use when you need designer-editable config (weapon stats, level data), to share one value
between unrelated systems, to decouple senders from listeners via event channels, or to
build a runtime registry of active objects — without a `static`/singleton manager.
- Use when the project has `*.asset` data files backed by `: ScriptableObject` classes.
**When _not_ to use:** per-instance runtime state that differs per GameObject (that belongs on
a MonoBehaviour) — a ScriptableObject asset is _shared_ by everyone who references it. Saving
player progress to disk → `save-systems`. Plain DTOs that never need to be an asset can just be
`[System.Serializable]` classes.
## Core workflow
1. **Define the class** deriving from `ScriptableObject` and tag it with `[CreateAssetMenu]` so
designers can create instances from the Assets menu.
2. **Create one or more `.asset` instances** in the Project window; each is a shared, named
piece of data referenced by `[SerializeField]` fields.
3. **Reference, don't copy.** MonoBehaviours hold a reference to the asset; they all see the
same data, so changing the asset changes every consumer.
4. **For decoupling**, model _signals_ and _shared variables_ as ScriptableObjects: a
"FloatVariable" the HUD reads and the player writes; an "event channel" the player raises
and many systems listen to. Neither side references the other.
5. **Reset runtime mutations** in `OnEnable` if the asset is mutated during play, because edits
made in the Editor at runtime persist on the asset (a frequent source of "my values
changed after I played").
6. **Verify** by inspecting the asset values during Play mode and confirming consumers react.
## Patterns
### 1. Config/data asset
```csharp
using UnityEngine;
[CreateAssetMenu(fileName = "WeaponData", menuName = "Game/Weapon Data", order = 0)]
public class WeaponData : ScriptableObject
{
public string displayName = "Pistol";
public int damage = 10;
public float fireRate = 0.25f;
public GameObject projectilePrefab;
}
```
```csharp
public class Weapon : MonoBehaviour
{
[SerializeField] private WeaponData data; // assign the shared asset in the Inspector
private void Fire() => Debug.Log($"{data.displayName} for {data.damage}");
}
```
### 2. Shared runtime variable (decouples producer from consumer)
```csharp
[CreateAssetMenu(menuName = "Game/Float Variable")]
public class FloatVariable : ScriptableObject
{
[SerializeField] private float initialValue;
[System.NonSerialized] public float runtimeValue; // not saved to the asset
private void OnEnable()
{
runtimeValue = initialValue; // reset when the asset loads
#if UNITY_EDITOR
// With Reload Domain off, OnEnable does not run again on Play, so also reset there.
UnityEditor.EditorApplication.playModeStateChanged -= ResetOnPlay;
UnityEditor.EditorApplication.playModeStateChanged += ResetOnPlay;
#endif
}
#if UNITY_EDITOR
private void OnDisable() => UnityEditor.EditorApplication.playModeStateChanged -= ResetOnPlay;
private void ResetOnPlay(UnityEditor.PlayModeStateChange change)
{
if (change == UnityEditor.PlayModeStateChange.ExitingEditMode) runtimeValue = initialValue;
}
#endif
}
// Player writes playerHealth.runtimeValue; the HUD reads it — neither references the other.
```
### 3. Creating an instance at runtime (not an asset on disk)
```csharp
// For transient SO data you build in code (e.g. a generated config).
var temp = ScriptableObject.CreateInstance<WeaponData>();
temp.damage = 25;
// ...use temp... Destroy(temp); // clean up runtime-created instances
```
## Pitfalls
- **Editing an SO at runtime persists in the Editor** — values you change during Play stay
changed on the asset after you stop. Keep mutable runtime state in `[NonSerialized]` fields
reset in `OnEnable`, or it will surprise you. (In a _build_, asset edits do not persist
across launches.)
- **Disabled Domain Reload skips your `OnEnable` reset** — with **Enter Play Mode Options**
enabled and **Reload Domain** off (a Unity 6.3 LTS fast-iteration setting), already-loaded SOs are
_not_ re-created when you press Play, so `OnEnable` never fires and `runtimeValue` keeps its
value from the previous session (non-serialized fields keep their Play-mode values). Also
reset on `EditorApplication.playModeStateChanged` (pattern 2), or from a static
`[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]` method.
- **Expecting per-object state** — every reference points to the _same_ asset. If two enemies
need different current HP, store HP on the MonoBehaviour, not the shared SO.
- **No frame lifecycle** — ScriptableObjects have `OnEnable`/`OnDisable`/`OnDestroy` but no
`Update`. Don't expect per-frame callbacks.
- **Using SOs as a save file** — they're authoring assets, not runtime persistence; write
progress with `save-systems` instead.
- **Leaking `CreateInstance` objects** — runtime-created instances are not garbage-collected
like plain C# objects; `Destroy` them when done.
## References
- For the **event-channel** pattern (a `GameEvent` SO + listeners, type-safe payloads) and
**runtime sets/registries** (a shared list of active enemies), read
`references/event-channels.md`.
- Primary docs: Unity Manual "ScriptableObject" (`/Manual/class-ScriptableObject.html`) and
"Enter Play mode with domain reload disabled" (`/Manual/domain-reloading.html`), plus
`ScriptReference/ScriptableObject`, `ScriptReference/CreateAssetMenuAttribute`.
## Related skills
- `unity-csharp-scripting` — the MonoBehaviours that consume these assets.
- `save-systems` — persisting state to disk (what SOs are _not_ for).
- `card-game` / `rpg` / `survival-crafting` — genres that lean on SO-driven data.