Quick answer: ScriptableObject fields modified at runtime exist only in memory and are never written back to disk. In a build, every session begins from the asset’s serialised defaults. Treat ScriptableObjects as read-only config; write runtime state to PlayerPrefs, a JSON save file, or a binary file instead. For runtime mutation, clone the asset with ScriptableObject.CreateInstance() so the original is never touched.
ScriptableObjects are one of Unity’s most beloved architecture patterns — a clean way to decouple data from MonoBehaviour logic, share configuration across scenes, and give designers a friendly Inspector UI. They’re also one of the most common sources of a particularly nasty class of bug: data that “works fine in the Editor” but resets to defaults the moment someone plays the shipped build. Understanding why requires understanding what ScriptableObjects actually are under the hood.
Why the Editor Lies to You
In the Unity Editor, a ScriptableObject asset lives in the Asset Database — a persistent cache that Unity maintains in your Library/ folder. When you enter Play Mode and write to an SO field, the change is reflected immediately because you’re writing to the in-memory representation of a loaded asset. When you exit Play Mode, Unity does not automatically revert those changes. The next time you look at the Inspector, the values are still there.
This makes it feel as though SO fields are persistent. They are not — they’re just still in memory. If you close and reopen the project, or if Unity triggers a domain reload (any script compilation), the asset reverts to its serialised state on disk. In a build there is no Asset Database at all: the SO is serialised into the game’s data files at build time and is effectively immutable at runtime.
The Correct Pattern: Read-Only Config
The idiomatic use of a ScriptableObject is as a designer-editable, read-only configuration asset. Think item definitions, enemy stat tables, audio cue lists, level parameters, dialogue trees. The SO contains the ground truth set at design time; runtime code reads from it but never writes to it.
The [CreateAssetMenu] attribute makes this workflow convenient: designers right-click in the Project window to create new instances, fill in the fields, and check the asset into source control. Code references the asset through a serialised field on a MonoBehaviour.
using UnityEngine;
[CreateAssetMenu(fileName = "NewEnemyConfig", menuName = "Game/Enemy Config")]
public class EnemyConfig : ScriptableObject
{
public float maxHealth = 100f;
public float moveSpeed = 3.5f;
public int scoreReward = 50;
// No setters. Treat these as constants at runtime.
}
Any MonoBehaviour that needs enemy stats holds a public EnemyConfig config; reference and reads config.maxHealth. It never assigns config.maxHealth = something. If you’re tempted to do that, you need a different storage strategy.
Runtime State: PlayerPrefs and JSON Save Files
For data that genuinely changes at runtime — current health, unlocked levels, inventory contents, high scores — use one of Unity’s persistence APIs. PlayerPrefs is the simplest option for a handful of scalar values. For complex state, serialise a plain C# data class to JSON and write it to Application.persistentDataPath.
using System.IO;
using UnityEngine;
[System.Serializable]
public class SaveData
{
public int currentLevel = 1;
public float totalPlayTime = 0f;
public bool tutorialDone = false;
}
public static class SaveSystem
{
private static readonly string SavePath =
Path.Combine(Application.persistentDataPath, "save.json");
public static void Save(SaveData data)
{
string json = JsonUtility.ToJson(data, prettyPrint: true);
File.WriteAllText(SavePath, json);
}
public static SaveData Load()
{
if (!File.Exists(SavePath)) return new SaveData();
string json = File.ReadAllText(SavePath);
return JsonUtility.FromJson<SaveData>(json);
}
}
Load this at game start and apply relevant values to your runtime objects. The SO still holds the design-time defaults; the save file holds the player’s progress on top of them.
Safe Runtime Mutation: ScriptableObject.CreateInstance()
Sometimes you genuinely need a per-session mutable copy of SO data — for example, a character build that the player customises during a run but that resets when they start a new run. The solution is ScriptableObject.CreateInstance(), which creates a new in-memory instance that is never linked to any asset file. Modifying this instance has zero effect on the original asset.
void StartNewRun()
{
// Clone the designer asset into a fresh runtime instance
runtimeStats = ScriptableObject.CreateInstance<CharacterStats>();
runtimeStats.maxHealth = baseConfig.maxHealth;
runtimeStats.moveSpeed = baseConfig.moveSpeed;
runtimeStats.attackDamage = baseConfig.attackDamage;
// Safe to modify runtimeStats freely during play
// baseConfig remains untouched
}
void OnDestroy()
{
// Always destroy runtime instances you create
if (runtimeStats != null)
Destroy(runtimeStats);
}
Remember to call Destroy() on runtime instances when they’re no longer needed. Unlike assets, runtime SOs are not garbage-collected automatically in Unity’s object system.
Resetting SO State at Game Start
If your project has an existing codebase that writes to SO fields at runtime and you can’t refactor it immediately, a pragmatic short-term fix is to cache the default values at startup and restore them on game end. This works around the Editor-vs-build inconsistency by making behaviour consistent everywhere — always starting from defaults — rather than trying to make the Editor behave like a build.
Create an ISerializationCallbackReceiver implementation on the SO. In OnBeforeSerialize do nothing; in OnAfterDeserialize copy the serialised values into a private backup. Expose a ResetToDefaults() method that restores from the backup. Call it from an initialization manager at game start.
This is a band-aid, not a cure. The real fix is to move mutable state out of the SO entirely. But for a hot patch before a release, it gets the job done without a full architecture change.
Catching This Bug Before It Ships
The Editor’s leniency around runtime SO mutation makes this bug invisible during development. The most reliable safeguard is an automated test that starts a fresh domain and verifies SO values are at their expected defaults. Unity’s Test Runner with Edit Mode tests runs in a fully reset domain, making it well-suited for this. You can also add a [RuntimeInitializeOnLoadMethod] guard that logs a warning whenever a MonoBehaviour writes to an SO field — useful during QA even if you remove it before shipping.
“A ScriptableObject is a configuration file, not a save file. The moment you start treating it as both, you’ve introduced a bug that only appears in the build your players actually run.”
If your save system relies on ScriptableObject fields, budget a day to migrate state to a proper serialisation layer — your future self will thank you the first time a player reports data loss.