Skip to main content

๐Ÿ“ฆ Lesson 2.3: ScriptableObjects: Data as Assets

A MonoBehaviour is code that lives on a GameObject in a scene. But a lot of what your game needs isn't behavior at all โ€” it's data: how much damage the shotgun does, how fast the goblin walks, what's in the starter kit. A ScriptableObject lets you store that data as a standalone asset in your Project, editable by designers and shared by many objects. It's the third pillar of decoupled design.

๐ŸŽฏ 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
  • Create one by subclassing ScriptableObject with [CreateAssetMenu]
  • Author data assets like WeaponData and EnemyStats in the Project window
  • Reference an SO from a component and read its values at runtime
  • Tell shared data from instanced data and avoid editing shared assets by accident
  • List the benefits: designer-editable, no scene bloat, and decoupling

Estimated Time: 45 minutes  ยท  Prerequisite: Lesson 2.2 (Events & Delegates); comfort with [SerializeField] from Fundamentals

In This Lesson

What Is a ScriptableObject?

You know MonoBehaviour: it must sit on a GameObject, in a scene, and gets Update, colliders, transforms. A ScriptableObject is its data-only sibling. It's a class Unity can serialize into a .asset file that lives in your Project, not in any scene and not on any GameObject. It has no Update, no transform โ€” it just holds fields.

๐Ÿ“– Definition

A ScriptableObject is a serializable Unity object that stores data as a project asset independent of scenes and GameObjects. You subclass it, mark it with [CreateAssetMenu], and create instances as .asset files you fill in via the Inspector.

Think of the difference this way. Twenty goblins in your scene are twenty GameObjects, each with an Enemy component (behavior). But they all share the same stats โ€” 30 health, 3 damage, 3.5 move speed. Copying those numbers onto twenty components is wasteful and fragile; change the balance and you edit twenty objects. Put the numbers in one EnemyStats asset and all twenty just point at it.

๐Ÿ’ก Not a replacement for MonoBehaviour. ScriptableObjects don't do things each frame โ€” they describe things. You'll almost always have both: a MonoBehaviour that acts, holding a reference to a ScriptableObject that supplies the numbers.

Creating WeaponData

A ScriptableObject definition is just a class that derives from ScriptableObject instead of MonoBehaviour. The magic ingredient is the [CreateAssetMenu] attribute, which adds an entry to Unity's Assets โ–ธ Create menu so you can spawn assets from it:

using UnityEngine;

[CreateAssetMenu(fileName = "NewWeapon", menuName = "Game/Weapon Data")]
public class WeaponData : ScriptableObject
{
    public string weaponName = "Pistol";
    public int damage = 10;
    public float fireRate = 2f;      // shots per second
    public int magazineSize = 12;
    public GameObject projectilePrefab;
    public AudioClip fireSound;
    public Sprite icon;
}

Notice the fields are plain public โ€” ScriptableObjects serialize the same way components do, so [SerializeField] private fields work too. Because it's data, there's no Start or Update; it's a tidy bundle of values, and those values can include asset references like prefabs, clips, and sprites.

โœ… Pro Tip

Use a grouped menuName like "Game/Weapon Data" or "Enemies/Stats". The slash creates a submenu, so a project with dozens of SO types stays organized under Assets โ–ธ Create โ–ธ Game โ–ธ โ€ฆ instead of flooding the top level.

Data as Project Assets

Once the class compiles, right-click in the Project window โ–ธ Create โ–ธ Game โ–ธ Weapon Data. Unity drops a new .asset file that you name and select; its fields appear in the Inspector, ready to fill in. Make one asset per weapon โ€” Pistol, Shotgun, Rifle โ€” each a separate file with its own numbers.

Here's the Project window holding three weapon assets, with the selected Shotgun shown in the Inspector โ€” exactly what you'll see on screen:

The Unity Project window and Inspector showing a ScriptableObject asset A recreation of Unity's editor. On the left, the Project window lists a Data folder containing three weapon assets: Pistol, Shotgun, and Rifle, with Shotgun selected and highlighted blue. On the right, the Inspector shows the selected Shotgun WeaponData asset with fields: Weapon Name Shotgun, Damage 8, Fire Rate 1.2, Magazine Size 6, and object fields for Projectile Prefab, Fire Sound, and Icon. A Script field at the top reads WeaponData. Project Assets Data โ–ค Pistol โ–ค Shotgun โ–ค Rifle 3 WeaponData assets โ€” no GameObjects, no scene Inspector โ–ค Shotgun (Weapon Data) Script WeaponData Weapon Name Shotgun Damage 8 Fire Rate 1.2 Magazine Size 6 Projectile Prefab Pellet Fire Sound sfx_shotgun Icon icon_shotgun
Figure 1: A WeaponData ScriptableObject in the Project and Inspector (faithfully recreated). Three weapon assets live in a Data folder โ€” no GameObjects, no scene โ€” and the selected Shotgun exposes all its fields for a designer to tune.

โš ๏ธ SO edits made in Play mode stick

Unlike component values (which reset when you stop Play), changes to a ScriptableObject asset during Play mode are written to the file and persist after you stop. Handy for tuning; dangerous if you change a shared asset's runtime state without meaning to. We'll come back to this under shared vs. instanced data.

Referencing an SO from Code

A component uses the data by holding a WeaponData reference โ€” exactly like any other serialized field. Drag the Shotgun asset into the slot and the weapon behaves like a shotgun; drag Rifle in and the same script behaves like a rifle. The behavior lives in code; the numbers live in the asset:

using UnityEngine;

public class Weapon : MonoBehaviour
{
    [SerializeField] WeaponData data;   // drag an asset here
    float nextFireTime;

    public void TryFire()
    {
        if (Time.time < nextFireTime) return;
        nextFireTime = Time.time + 1f / data.fireRate;

        var proj = Instantiate(data.projectilePrefab,
                               transform.position, transform.rotation);
        proj.GetComponent<Projectile>().damage = data.damage;

        AudioSource.PlayClipAtPoint(data.fireSound, transform.position);
    }
}

Swapping weapons at runtime is now trivial โ€” assign a different data asset and everything (damage, rate, sound, projectile) follows. That's the decoupling win: the Weapon code doesn't hardcode a single number.

Shared vs. Instanced Data

This is the one concept that trips people up, so slow down here. When many components reference the same SO asset, they share one object in memory. Read from it freely. But if a component writes to it at runtime, every other referencer sees the change โ€” because there's only one.

flowchart TD SO[("๐Ÿ—Ž GoblinStats
(one .asset)
health 30 ยท speed 3.5")] G1["Goblin #1"] --> SO G2["Goblin #2"] --> SO G3["Goblin #3"] --> SO G4["Goblin #4"] --> SO G5["Goblin #5"] --> SO

Figure 2: Five goblins, one shared EnemyStats asset. Editing the asset re-balances all five at once โ€” but writing per-enemy runtime state (like current health) into it would corrupt them all.

So split your thinking into two kinds of data:

  • Shared / definition data โ€” the unchanging template: max health, base speed, damage. Keep this in the SO and only read it. Editing the asset intentionally re-balances every user, which is exactly what you want in the editor.
  • Instanced / runtime state โ€” the per-object values that change during play: current health, ammo left, cooldown timers. These belong in the MonoBehaviour, initialized from the SO:
public class Enemy : MonoBehaviour, IDamageable
{
    [SerializeField] EnemyStats stats;   // shared definition (read-only)
    int currentHealth;                   // instanced runtime state

    void Awake() => currentHealth = stats.maxHealth;  // copy, don't mutate

    public int CurrentHealth => currentHealth;
    public void TakeDamage(int amount) => currentHealth -= amount;
}

The goblin reads stats.maxHealth once to seed its own currentHealth, then only ever changes its private field. The shared asset stays pristine.

โš ๏ธ If you truly need a per-object copy

Occasionally you want a runtime duplicate of an SO (e.g. an item instance that levels up independently). Use Instantiate(stats) to clone it โ€” that gives you a separate in-memory copy that won't touch the shared source asset. Don't do this casually; most of the time seeding a plain field is cheaper and clearer.

Why This Matters

ScriptableObjects pay off in three big ways:

  • Designer-editable. Balancing lives in .asset files a non-programmer can tune in the Inspector โ€” no code edits, no recompiling. Change the shotgun's damage over lunch without opening Visual Studio.
  • No scene bloat. Data lives once in the Project, not copied onto every GameObject or baked into each scene. Twenty goblins reference one stats asset instead of carrying twenty copies of the same numbers, which also keeps scene files small and merge-friendly.
  • Decoupling. Just like interfaces and events, SOs cut a dependency: code depends on a data contract (the SO type) rather than hardcoded values. A brand-new weapon is a new asset, not a new class โ€” often no code at all.
๐Ÿ’ก The whole module clicks together here. Interfaces decouple who calls whom. Events decouple who reacts to what. ScriptableObjects decouple code from data. In the next lesson we combine events and SOs into one pattern โ€” the ScriptableObject event channel โ€” that decouples across entire scenes.

โœ… Great first candidates for an SO

Weapon stats, enemy definitions, item/inventory entries, level configs, audio banks, dialogue lines, ability definitions, and difficulty presets. If you find yourself typing the same numbers onto many objects, or wishing a designer could tweak values without you โ€” that's an SO.

Hands-on Challenge

๐Ÿ‹๏ธ Exercise 1: A weapon locker of assets

Objective: Move weapon numbers out of code and into swappable assets.

  1. Create the WeaponData class above with [CreateAssetMenu(menuName = "Game/Weapon Data")].
  2. Via Assets โ–ธ Create โ–ธ Game โ–ธ Weapon Data, make three assets: Pistol, Shotgun, Rifle, each with different numbers (match Figure 1's Shotgun).
  3. Write a Weapon component with a serialized WeaponData data field.
  4. Drag each asset into the field in turn and confirm the same script fires at different rates and damage โ€” no code changes between weapons.
๐Ÿ’ก Hint: "Create" menu doesn't show my type

Make sure the class compiles (check the Console) and that the file name matches the class name exactly. The [CreateAssetMenu] entry only appears after a clean compile. If you renamed the class, Unity may need a moment to refresh.

โœ… Success check

You can add a fourth weapon (e.g. SMG) by creating one more asset and dragging it in โ€” with zero edits to Weapon.cs. The numbers live entirely in the assets.

๐Ÿ‹๏ธ Exercise 2: Shared stats, private health

Create an EnemyStats SO (maxHealth, moveSpeed, damage). Make one asset, GoblinStats, and put it on five Enemy GameObjects. Have each Enemy seed a private currentHealth from stats.maxHealth in Awake. Damage one goblin and confirm the other four are unaffected โ€” proving you're reading shared data but storing runtime state per object. Then bump maxHealth in the asset and confirm all five re-balance on the next Play.

๐ŸŽฏ Quick Quiz

Question 1: How does a ScriptableObject differ from a MonoBehaviour?

Question 2: What does [CreateAssetMenu] do?

Question 3: Five enemies reference one shared EnemyStats asset. Where should each enemy's current health live?

Summary

๐ŸŽ‰ Key Takeaways

  • A ScriptableObject stores data as a project .asset, independent of scenes and GameObjects โ€” no Update, no transform.
  • Subclass ScriptableObject and add [CreateAssetMenu] to spawn assets from Assets โ–ธ Create.
  • Components reference an SO through a serialized field; swap the asset to swap the data (e.g. WeaponData).
  • Shared data (the template) is read from the SO; instanced state (current health, ammo) lives on the MonoBehaviour, seeded from the SO.
  • SO edits during Play mode persist โ€” great for tuning, risky for runtime writes to shared assets.
  • Benefits: designer-editable, no scene bloat, and decoupling code from data.

๐Ÿš€ What's Next?

You now have all three decoupling tools: interfaces, events, and ScriptableObjects. In Lesson 2.4: ScriptableObject Event Channels we fuse events and SOs into a single elegant pattern โ€” a GameEvent asset that any system can raise and any GameEventListener can react to, letting entirely separate scenes talk without a single direct reference.

๐Ÿ“ฆ Your data is free of your scenes

Numbers live in assets a designer can tune, shared by everything that needs them. Code describes behavior; ScriptableObjects describe the world it acts on.