π 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
ScriptableObjectis and how it differs from aMonoBehaviour - 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:
| MonoBehaviour | ScriptableObject | |
|---|---|---|
| Lives on | A GameObject in a scene | An asset in the project |
| Answers | "What does this object do?" | "What data does this represent?" |
| Lifecycle | Awake/Update/etc. every frame | No per-frame loop; no Update |
| Instances | One per GameObject that has it | One asset, shared by everything that references it |
| Created with | AddComponent / attach in Editor | [CreateAssetMenu] / CreateInstance |
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.
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 |
|---|---|---|
| ScriptableObject | is shared config designers tune, reused across many objects/scenes | Weapon stats, enemy types, level settings, audio libraries |
| MonoBehaviour field | is live per-instance state that changes during play | Current health, ammo left, this enemy's target |
| Plain C# class/struct | is short-lived runtime data with no need to appear in the Inspector or be an asset | A 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:
- Write the
EnemyDataScriptableObject (Section 3) with health, speed, damage, and a display name. - Create two assets via Assets β Create β Game β Enemy Data: a weak "Goblin" and a tough "Orc" with different numbers.
- Write the
EnemyMonoBehaviour that readsdata.maxHealthinto a localcurrentHealthinStart, and moves atdata.moveSpeed. - 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.
- Prove the gotcha: temporarily write
data.maxHealth -= amountinTakeDamage, play, damage an enemy, stop β then look at the asset's health in the Inspector. Fix it back to modifycurrentHealthinstead.
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
ScriptableObjectand tagging it[CreateAssetMenu]; create instances from Assets β Create. Don'tnewit (useCreateInstanceif 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
Instantiateif 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
- Scripting Reference β ScriptableObject
- Scripting Reference β CreateAssetMenu
- Manual β ScriptableObject
π 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.