Topic 6 of 15 11 min intermediate

ScriptableObjects

Data that lives outside the scene

A ScriptableObject is a data container that lives as an asset in your project rather than as a component in a scene. It is the answer to "where do I put the numbers that lots of objects need?"

The problem it solves

Say fifty enemy prefabs each need the same damage value. With MonoBehaviours, that number is copied fifty times and balancing means editing fifty prefabs. With a ScriptableObject, they all point at one asset. Change it once.

There is a memory argument too. A MonoBehaviour field is duplicated per instance; a hundred enemies means a hundred copies of every value. A ScriptableObject exists once no matter how many objects reference it.

Creating one

EnemyData.cs
C#
using UnityEngine;

[CreateAssetMenu(fileName = "EnemyData", menuName = "Game/Enemy Data")]
public class EnemyData : ScriptableObject
{
    [Header("Stats")]
    public string enemyName = "Grunt";
    public int maxHealth = 100;
    public float moveSpeed = 3.5f;
    public int damage = 10;

    [Header("Visuals")]
    public GameObject prefab;
    public Sprite icon;
}

[CreateAssetMenu] adds it to the right-click Create menu in the Project window. Make as many assets from it as you like — Grunt, Archer, Boss — each with its own values.

Enemy.cs
C#
public class Enemy : MonoBehaviour
{
    [SerializeField] private EnemyData data;

    private int currentHealth;

    private void Awake()
    {
        // Read shared config, keep per-instance state locally
        currentHealth = data.maxHealth;
    }
}
The split above is the whole pattern: shared, read-only config in the asset; per-instance state in the MonoBehaviour.

The gotcha everyone hits

ScriptableObjects are assets, not instances. Write to one at runtime and the change persists in the editor after you stop playing. It does not reset.

C#
public class Enemy : MonoBehaviour
{
    [SerializeField] private EnemyData data;

    public void TakeDamage(int amount)
    {
        // Editing the shared asset. Every enemy using this data
        // loses health together, and the value stays changed
        // after you exit play mode.
        data.maxHealth -= amount;
    }
}
In a built game the change does not persist between sessions, so this bug looks like an editor-only quirk — right up until two enemies share a health bar. Treat ScriptableObject fields as read-only at runtime unless you deliberately intend otherwise.

Where they beat the alternatives

You needReach forWhy
Shared config valuesScriptableObjectOne asset, edited once.
Per-object stateMonoBehaviour fieldEach instance needs its own.
A reusable objectPrefabIt needs a presence in the scene.
Save dataJSON or PlayerPrefsMust survive a rebuild.
Global accessScriptableObjectOften a cleaner singleton.

As an alternative to singletons

A common use is decoupling systems that should not know about each other. Instead of the UI holding a reference to the player, both reference a shared asset.

FloatVariable.cs
C#
using UnityEngine;

[CreateAssetMenu(menuName = "Game/Float Variable")]
public class FloatVariable : ScriptableObject
{
    [SerializeField] private float startValue;

    [System.NonSerialized] public float runtimeValue;

    // Reset from the serialised default when play begins
    private void OnEnable() => runtimeValue = startValue;
}

The player writes playerHealth.runtimeValue; the health bar reads it. Neither script references the other, so either can be deleted without breaking the other.

[System.NonSerialized] plus setting the value in OnEnable is the standard trick for a ScriptableObject that should hold runtime state without saving it back into the asset.

Events without references

The ScriptableObject event channel

The same idea works for events. An asset holds a list of listeners; anything can raise it and anything can subscribe, with no direct references between them.

C#
[CreateAssetMenu(menuName = "Game/Game Event")]
public class GameEvent : ScriptableObject
{
    private readonly List<System.Action> listeners = new();

    public void Raise()
    {
        // Backwards: a listener may unsubscribe during the call
        for (int i = listeners.Count - 1; i >= 0; i--)
            listeners[i]?.Invoke();
    }

    public void Register(System.Action listener) => listeners.Add(listener);
    public void Unregister(System.Action listener) => listeners.Remove(listener);
}

Note the backwards loop. Iterating forwards while a listener removes itself skips the next entry — a subtle bug that only shows up occasionally.

What to take away

  • ScriptableObjects are assets, so the data exists once regardless of instance count.
  • Use [CreateAssetMenu] so designers can make variants without code.
  • Read config from the asset, keep changing state in the MonoBehaviour.
  • Runtime writes persist in the editor — guard with [System.NonSerialized] and OnEnable.
  • They decouple systems that would otherwise need direct references.