DEV Community

Game Dev Notes (Korea)
Game Dev Notes (Korea)

Posted on

The ScriptableObject Trap: Why Your Game Data Won't Reset After You Quit Play Mode

We had a bug that only made sense if the laws of physics had quietly changed overnight. A designer would playtest a build, tweak nothing, close the editor, come back the next morning, and the boss enemy would start the fight with 40 HP instead of 400. Nobody had touched the data. Git showed no changes to any script. And yet the asset on disk had quietly rewritten itself.

The culprit was a ScriptableObject. Specifically, it was us not understanding what a ScriptableObject is. If you've ever wondered why a value you changed in play mode is still changed after you stop, this is the post I wish someone had handed me two years ago.

ScriptableObjects are assets, not instances

Here is the one sentence that fixes 90% of ScriptableObject confusion: a ScriptableObject is a single shared asset, and every object that references it points at the same instance in memory.

When you make a MonoBehaviour prefab and drop three copies into a scene, you get three independent objects. Change one's health, the other two don't care. People assume ScriptableObjects work the same way. They do not. If ten enemies all reference the same EnemyStats asset, they are all reading and writing the exact same object.

[CreateAssetMenu(menuName = "Game/Enemy Stats")]
public class EnemyStats : ScriptableObject
{
    public int maxHealth = 400;
    public float moveSpeed = 3.5f;
}
Enter fullscreen mode Exit fullscreen mode
public class Enemy : MonoBehaviour
{
    public EnemyStats stats;     // a reference to the shared asset
    private int currentHealth;

    void Start() => currentHealth = stats.maxHealth;

    public void TakeDamage(int dmg)
    {
        // Fine: we mutate a local copy of the number
        currentHealth -= dmg;

        // DISASTER: this rewrites the shared asset for everyone, forever
        stats.maxHealth -= dmg;
    }
}
Enter fullscreen mode Exit fullscreen mode

That second line is the whole bug in miniature. currentHealth is a private field on the component, so mutating it is safe and per-instance. stats.maxHealth is a field on the shared asset. Subtract from it and you've just changed the starting health of every enemy that uses this stats asset, in this run and every run after.

Why the change survives play mode

Here's the part that turns a confusing bug into a genuinely scary one. In the editor, ScriptableObject assets are live objects loaded from disk. When you enter play mode and mutate one, you are editing the loaded asset directly. Unity doesn't snapshot it for you. When you exit play mode, the in-memory object stays modified, and the next time Unity serializes that asset (saving the project, reimporting, or just Unity deciding to flush), your play-mode mutation gets written to the .asset file on disk.

That's why our boss "lost" 360 HP overnight. A playtest had been chipping away at maxHealth via that exact bug, play mode ended, and at some point Unity serialized the now-corrupted asset back to disk. Git eventually noticed, but by then the damage looked like it came from nowhere.

In a build it's slightly different but just as bad: there's no serialization back to disk, but the mutation persists for the entire session because the asset is loaded once and shared. So a value you "reset" in Start only resets because you happened to copy it into a local field. Anything you mutated on the asset itself stays mutated until the app closes.

The fix: treat SO data as read-only at runtime

The rule we adopted and now enforce in review: ScriptableObject fields are read-only at runtime. Runtime state lives on the component (or in a plain C# state object), seeded from the SO.

public class Enemy : MonoBehaviour
{
    public EnemyStats stats;          // read-only template
    private int currentHealth;        // mutable runtime state

    void Start() => currentHealth = stats.maxHealth;   // copy, never alias

    public void TakeDamage(int dmg) => currentHealth -= dmg;  // touch the copy only
}
Enter fullscreen mode Exit fullscreen mode

If a value is a number or a struct, copying it into a local field is enough — value types copy by default. The trap reopens the moment your SO holds a reference type you intend to mutate, like a List<T> or a nested class. Copying the reference copies the pointer, not the data, so you're right back to mutating the shared asset.

[CreateAssetMenu(menuName = "Game/Loadout")]
public class Loadout : ScriptableObject
{
    public List<string> items = new() { "sword", "potion" };
}

// WRONG: runtimeItems points at the SO's own list
List<string> runtimeItems = loadout.items;
runtimeItems.Add("bomb");   // the asset now permanently has a bomb

// RIGHT: make your own list
List<string> runtimeItems = new(loadout.items);
Enter fullscreen mode Exit fullscreen mode

Catching it before it ships

Two guardrails have saved us repeatedly.

First, make accidental mutation impossible to write. Expose SO data through read-only properties backed by [SerializeField] private fields, instead of public fields. Designers still edit them in the inspector; gameplay code physically cannot assign to them.

[CreateAssetMenu(menuName = "Game/Enemy Stats")]
public class EnemyStats : ScriptableObject
{
    [SerializeField] private int maxHealth = 400;
    [SerializeField] private float moveSpeed = 3.5f;

    public int MaxHealth => maxHealth;     // gameplay can read
    public float MoveSpeed => moveSpeed;   // gameplay cannot write
}
Enter fullscreen mode Exit fullscreen mode

Second, when you genuinely want a runtime-mutable copy of an SO (handy for per-enemy modifiers, buffs, procedural variation), be explicit about it with Instantiate. Calling Instantiate on a ScriptableObject gives you a fresh, independent clone you can mutate freely without ever touching the source asset.

// A per-enemy mutable copy, fully isolated from the shared asset
EnemyStats runtimeStats = Instantiate(stats);
runtimeStats.ApplyModifier(eliteBuff);   // safe: this is our own object
Enter fullscreen mode Exit fullscreen mode

Just remember to clean those clones up — Instantiated ScriptableObjects are objects you now own, and they won't be garbage collected as long as something references them.

What it comes down to

A ScriptableObject is shared, persistent, mutable state pretending to be a humble data file. The mental shift is to stop thinking of it as "a struct on disk" and start thinking of it as "a singleton everyone has a pointer to." Once you treat its fields as read-only templates, copy primitives, deep-copy reference types, and reach for Instantiate when you truly need a mutable variant, the whole class of "my data changed itself" bugs disappears. The data only ever changes when you decide it should, on a copy that's yours.

Top comments (0)