Skip to main content

πŸ“ Lesson 3.1: ScriptableObjects for Data

Not everything needs to be a MonoBehaviour living on a GameObject. Game data β€” weapon stats, enemy configs, level settings β€” is often better stored as standalone assets. That's what a ScriptableObject is: a C# object saved as a project asset, editable in the Inspector, shared by reference.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain what a ScriptableObject is and how it differs from a MonoBehaviour
  • Author a ScriptableObject class and make instances with [CreateAssetMenu]
  • Reference a data asset from a MonoBehaviour and read its values
  • Apply data-driven design to separate data from behavior
  • Avoid the "shared instance" trap when modifying a ScriptableObject at runtime
  • Decide when data belongs in a ScriptableObject vs a MonoBehaviour or plain class

Estimated Time: 60 minutes

Project: Turn hardcoded enemy stats into reusable EnemyData assets that many enemies share.

In This Lesson

The Problem: Duplicated Data

Imagine 50 Goblins in your game, each a prefab instance with a MonoBehaviour holding maxHealth = 30, speed = 4, damage = 5. Now the designer wants goblins tougher: health 40. You either edit the prefab (fine) β€” but what if those values are tuned per-scene, or spread across several enemy scripts? The data gets duplicated and drifts out of sync.

The deeper issue: data is riding on behavior. The numbers that define "what a goblin is" are trapped inside a component that also has to live on a GameObject, run a lifecycle, and exist in a scene. What we want is to store the data once, separately, and let every goblin point at it.

πŸ’‘ The goal: one source of truth for each kind of data, editable by designers, referenced by many objects. Change it in one place, and everything using it updates. That's exactly what a ScriptableObject gives you.

What Is a ScriptableObject?

A ScriptableObject is a class that inherits ScriptableObject instead of MonoBehaviour. Like a MonoBehaviour it has serialized fields you edit in the Inspector β€” but unlike a MonoBehaviour, an instance is saved as a project asset, not attached to a GameObject.

πŸ“– Definition

A ScriptableObject is a data container that lives as an asset in your project (a .asset file), independent of any scene or GameObject. It holds data and helper methods, is edited in the Inspector, and is referenced by other objects just like a prefab.

Both derive from Unity's Object, but they answer different questions:

MonoBehaviourScriptableObject
Lives onA GameObject in a sceneAn asset in the project
Answers"What does this object do?""What data does this represent?"
LifecycleAwake/Update/etc. every frameNo per-frame loop; no Update
InstancesOne per GameObject that has itOne asset, shared by everything that references it
Created withAddComponent / attach in Editor[CreateAssetMenu] / CreateInstance
graph TD SO["GoblinData (asset)
health 30, speed 4, damage 5"] G1["Goblin #1
Enemy component"] -->|"references"| SO G2["Goblin #2
Enemy component"] -->|"references"| SO G3["Goblin #3
Enemy component"] -->|"references"| SO style SO fill:#eff6ff,stroke:#3b82f6,stroke-width:2px style G1 fill:#e8f5e9,stroke:#4CAF50,stroke-width:2px style G2 fill:#e8f5e9,stroke:#4CAF50,stroke-width:2px style G3 fill:#e8f5e9,stroke:#4CAF50,stroke-width:2px

All three goblins point at the same GoblinData asset. Edit that one asset and every goblin sees the change β€” the single source of truth we wanted. There's also a memory win: 50 goblins share one copy of the data instead of each carrying its own.

Authoring a Data Asset

Two steps: write a class that inherits ScriptableObject, and tag it with [CreateAssetMenu] so you can create instances from Unity's Assets β†’ Create menu.

using UnityEngine;

[CreateAssetMenu(fileName = "NewEnemyData", menuName = "Game/Enemy Data")]
public class EnemyData : ScriptableObject
{
    [Header("Stats")]
    public int maxHealth = 30;
    public float moveSpeed = 4f;
    public int damage = 5;

    [Header("Presentation")]
    public string displayName = "Goblin";
    public Sprite icon;

    // ScriptableObjects can have methods too β€” data + behavior on the data itself.
    public int DamageAfterArmor(int armor) => Mathf.Max(0, damage - armor);
}

πŸ’‘ The [CreateAssetMenu] attribute

menuName is where it appears under Assets β†’ Create (here: Create β†’ Game β†’ Enemy Data). fileName is the default name of the new asset. Right-click in the Project window, create a couple β€” "GoblinData", "OrcData" β€” and fill in different stats in the Inspector. Each is a separate asset of the same type.

⚠️ No constructor, no new

Just like MonoBehaviours (Lesson 1.1), you don't new a ScriptableObject or give it a constructor β€” Unity creates the asset. In the rare case you need one at runtime, use ScriptableObject.CreateInstance<EnemyData>(), not new EnemyData(). Initialization that must run when the asset loads goes in OnEnable.

Referencing Data from a MonoBehaviour

An EnemyData asset is just data β€” something has to act on it. That's a MonoBehaviour with a reference to the asset, wired in the Inspector (Lesson 1.3). The behavior lives in the component; the numbers live in the asset.

using UnityEngine;

public class Enemy : MonoBehaviour
{
    [SerializeField] private EnemyData data;   // drag a GoblinData asset here
    private int currentHealth;

    void Start()
    {
        // Read starting values FROM the shared data asset into per-instance state.
        currentHealth = data.maxHealth;
        gameObject.name = data.displayName;
    }

    public void TakeDamage(int amount)
    {
        currentHealth -= amount;
        if (currentHealth <= 0)
            Destroy(gameObject);
    }

    void Update()
    {
        // Behavior uses the shared data, but state (currentHealth) stays local.
        transform.Translate(Vector3.forward * data.moveSpeed * Time.deltaTime);
    }
}

βœ… The pattern: shared data in, per-instance state local

Notice the split. data.maxHealth is shared config read from the asset. currentHealth is this enemy's live state β€” a normal field on the component, unique per instance. Read config from the ScriptableObject; keep changing state on the MonoBehaviour. This is the crux of using data assets correctly.

Now swapping an enemy from Goblin to Orc is a single drag in the Inspector β€” no code change. Designers can create and balance dozens of enemy types without touching the Enemy script at all. That's data-driven design: behavior is fixed in code, variety comes from data.

The Shared-Instance Gotcha

Here's the trap that catches everyone once. Because all enemies share the same asset, writing to that asset's fields at runtime changes it for everybody β€” and, in the Editor, the change can persist after you stop playing.

// ❌ DON'T do this β€” it mutates the shared asset, affecting all enemies
// and permanently editing your data asset in the Editor.
public void TakeDamage(int amount)
{
    data.maxHealth -= amount;   // WRONG: writing to the shared ScriptableObject
}
graph TD T["enemy.TakeDamage()
writes data.maxHealth"] --> SO["Shared GoblinData asset"] SO -->|"now changed for"| G1["Goblin #1 😱"] SO -->|"and"| G2["Goblin #2 😱"] SO -->|"and persists in Editor"| PERSIST["Asset edited on disk 😱"] style T fill:#fdecea,stroke:#c0392b,stroke-width:2px style PERSIST fill:#fdecea,stroke:#c0392b,stroke-width:2px

⚠️ Treat ScriptableObject data as read-only at runtime

Read from the asset into a local field, then modify the local field β€” never the asset. In the correct Enemy above, currentHealth = data.maxHealth copies the value out, and damage reduces currentHealth, leaving the asset untouched. If you truly need a mutable per-instance copy of a whole asset, clone it with Instantiate(data).

πŸ’‘ Why it persists: In a build, ScriptableObject edits are discarded when the game closes. But in the Editor, the asset is a real file β€” runtime writes to it are saved like any Inspector edit, silently corrupting your tuned values. This surprises people because it doesn't happen with MonoBehaviour fields, which reset each Play session.

When to Use Which

ScriptableObjects are powerful but not the answer to everything. Three homes for your data, each with a job:

Use…When the data…Example
ScriptableObjectis shared config designers tune, reused across many objects/scenesWeapon stats, enemy types, level settings, audio libraries
MonoBehaviour fieldis live per-instance state that changes during playCurrent health, ammo left, this enemy's target
Plain C# class/structis short-lived runtime data with no need to appear in the Inspector or be an assetA pathfinding node, a temporary result, a DTO

πŸ’‘ Beyond data: SOs as more

ScriptableObjects also shine as shared services β€” event channels that decouple systems (a preview of Lesson 3.2), runtime variables both UI and gameplay read, or strategy objects (different AI behaviors as swappable assets). The common thread is "an asset that many things reference." For this lesson, focus on the data-container use; the pattern generalizes from there.

βœ… Rule of thumb

Config that's the same for many and edited by designers β†’ ScriptableObject. State that's unique per object and changes at runtime β†’ MonoBehaviour field. When in doubt, ask: "Is this describing a kind of thing (SO) or the current condition of one thing (MonoBehaviour)?"

Exercise & Quiz

πŸ‹οΈ Exercise: Data-Drive Your Enemies

Objective: Replace hardcoded enemy stats with shared EnemyData assets, and prove the shared-instance rule to yourself.

Instructions:

  1. Write the EnemyData ScriptableObject (Section 3) with health, speed, damage, and a display name.
  2. Create two assets via Assets β†’ Create β†’ Game β†’ Enemy Data: a weak "Goblin" and a tough "Orc" with different numbers.
  3. Write the Enemy MonoBehaviour that reads data.maxHealth into a local currentHealth in Start, and moves at data.moveSpeed.
  4. Put two enemy GameObjects in the scene; assign the Goblin asset to one and the Orc asset to the other. Confirm they behave differently with no code change.
  5. Prove the gotcha: temporarily write data.maxHealth -= amount in TakeDamage, play, damage an enemy, stop β€” then look at the asset's health in the Inspector. Fix it back to modify currentHealth instead.

Starter Code:

using UnityEngine;

public class Enemy : MonoBehaviour
{
    [SerializeField] private EnemyData data;
    private int currentHealth;

    void Start()
    {
        // TODO: copy data.maxHealth into currentHealth; set the object's name.
    }

    public void TakeDamage(int amount)
    {
        // TODO: reduce currentHealth (NOT data!), destroy at 0.
    }
}
πŸ’‘ Hint

In Start: currentHealth = data.maxHealth;. In TakeDamage: subtract from currentHealth, and if (currentHealth <= 0) Destroy(gameObject);. The rule: read from data, write to currentHealth. Never assign to a data.* field at runtime.

βœ… Solution
using UnityEngine;

[CreateAssetMenu(fileName = "NewEnemyData", menuName = "Game/Enemy Data")]
public class EnemyData : ScriptableObject
{
    public int maxHealth = 30;
    public float moveSpeed = 4f;
    public int damage = 5;
    public string displayName = "Goblin";
}
using UnityEngine;

public class Enemy : MonoBehaviour
{
    [SerializeField] private EnemyData data;
    private int currentHealth;

    void Start()
    {
        currentHealth = data.maxHealth;      // copy shared config into local state
        gameObject.name = data.displayName;
    }

    public void TakeDamage(int amount)
    {
        currentHealth -= amount;             // modify LOCAL state, not the asset
        if (currentHealth <= 0)
            Destroy(gameObject);
    }

    void Update()
    {
        transform.Translate(Vector3.forward * data.moveSpeed * Time.deltaTime);
    }
}

Two enemy types, one script, zero duplicated numbers β€” and because damage touches only currentHealth, your EnemyData assets stay pristine across Play sessions.

🎯 Quick Quiz

Question 1: What is the key difference between a ScriptableObject and a MonoBehaviour?

Question 2: Five enemies reference the same EnemyData asset. One enemy does data.maxHealth -= 10 at runtime. What happens?

Question 3: Which belongs in a MonoBehaviour field rather than a ScriptableObject?

Summary

πŸŽ‰ Key Takeaways

  • A ScriptableObject is a data container saved as a project asset β€” no GameObject, no per-frame lifecycle.
  • Author one by inheriting ScriptableObject and tagging it [CreateAssetMenu]; create instances from Assets β†’ Create. Don't new it (use CreateInstance if needed).
  • Reference the asset from a MonoBehaviour with [SerializeField]; read shared config from the asset, keep changing state in local fields.
  • This enables data-driven design: one script, many data assets β€” designers add variety without code.
  • Gotcha: the asset is shared β€” writing to its fields at runtime changes it for everyone and persists in the Editor. Treat it as read-only; clone with Instantiate if you need a mutable copy.
  • Choose by intent: shared "kind of thing" config β†’ ScriptableObject; live per-object state β†’ MonoBehaviour; throwaway runtime data β†’ plain class.

πŸ“š Additional Resources

πŸš€ What's Next?

You can now separate data from behavior. Next we separate systems from each other. Lesson 3.2, Events and Decoupling, uses C# events, UnityEvents, and ScriptableObject event channels so components can talk without knowing about each other β€” the cleanup pattern from Lesson 1.2 finally pays off.

πŸŽ‰ Data, set free!

Your game's numbers now live where designers can shape them, shared and consistent. That's a hallmark of a professional, scalable Unity project.