Engineering

Why ScriptableObject Data Disappears on Scene Transitions (And How to Fix It)

  • Unity
  • ScriptableObject
  • C#
  • Game Development
  • Serialization

During Unity 1 Week Game Jam (ja: “Unity1週間ゲームジャム”, a popular game jam event on unityroom), I encountered a puzzling issue:

Data stored in a ScriptableObject disappears after scene transitions.

What made it stranger was that it sometimes didn’t disappear. After digging into the conditions, I discovered a bizarre pattern: whether the ScriptableObject was selected in the Inspector or not.

The root cause turned out to be Unity’s behavior: non-serialized fields in ScriptableObjects are lost when the asset is unloaded from memory.

Here’s the solution I found using the RuntimeInitializeOnLoadMethod pattern.

Environment

  • Unity … 6000.3.1f1

Reproducing the Problem

Consider this ScriptableObject:

[CreateAssetMenu]
public class GameStateSO : ScriptableObject
{
    [SerializeField] private int savedScore;     // This persists
    private Dictionary<string, bool> flags;       // This disappears!
}

When you set values in the flags field and then transition to another scene, the values are gone.

Steps to reproduce:

  1. Enter Play Mode in the Editor
  2. In Scene A, set some flags in the Dictionary
  3. Transition to Scene B (without selecting the SO in Inspector)
  4. The Dictionary is now empty
sequenceDiagram
    participant A as Scene A
    participant SO as ScriptableObject
    participant U as Unity
    participant B as Scene B

    A->>SO: flags["key"] = true
    A->>U: Transition to Scene B
    U->>SO: No references → Unload
    U->>B: Load Scene B
    B->>SO: Reload (flags is empty)
    Note over SO: Non-serialized fields lost

ScriptableObject Lifecycle in Unity

I found a hint in the Unity Discussions:

“My theory is that Unity will unload your SO between the two scenes if you aren’t observing it, because in your other scene nothing is referencing it, thus it can be unloaded from memory.”

In other words:

  • In Editor: SOs can be unloaded when not selected in Inspector
  • In Build: SOs not referenced by any scene are unloaded

When reloaded after unloading, non-serialized fields are reset to their default values.

Unity’s Serialization Rules

According to the official documentation, Unity can only serialize certain types:

TypeExamplesSerializable?
Primitive typesint, float, double, bool, stringYes
Enums32-bit or smaller enumsYes
Unity built-in typesVector2, Vector3, Rect, Color, AnimationCurveYes
UnityEngine.Object derivativesReferences to GameObject, Transform, ScriptableObjectYes
[Serializable] classes/structsCustom typesYes
Arrays or List<T> of aboveint[], List<string>Yes
Dictionary<K,V>-No
HashSet<T>-No
Multidimensional arraysint[,]No
Jagged arraysint[][]No
Nested containersList<List<T>>No
Delegates-No
Interfaces-No

Even if the type is serializable, fields must meet ALL of these conditions:

  • public or has [SerializeField] attribute
  • Not static, const, or readonly
  • Is a serializable type
// This is dangerous
[System.Serializable]
public struct PlayerProgress
{
    public int level;                              // Serialized
    public Dictionary<string, int> achievements;  // Lost!
}

public class ProgressVariableSO : VariableSO<PlayerProgress> { }

Adding [SerializeField] to a non-serializable type doesn’t help.

Note: This also happens during Domain Reload (script recompilation) in the Editor.

The Solution: Prevent Unloading

The approach I used during the game jam was to maintain a reference to the SO from a persistent GameObject.

The Concept

SOs are unloaded because “nothing is referencing them.” So if we always reference them from somewhere, they won’t be unloaded. The solution: create a persistent GameObject that holds references to the SOs.

flowchart LR
    subgraph DontDestroyOnLoad
        Holder[RuntimeDataHolder]
    end

    Holder -->|holds reference| SO1[GameStateSO]
    Holder -->|holds reference| SO2[PlayerDataSO]

    SO1 -.-> Persist[(Stays in Memory)]
    SO2 -.-> Persist

Implementation

The implementation is straightforward.

First, create the RuntimeDataBootstrap class. This loads and instantiates the RuntimeDataHolder prefab at game startup:

public static class RuntimeDataBootstrap
{
    private const string PrefabPath = "RuntimeData/RuntimeDataHolder";

    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
    private static void Initialize()
    {
        var prefab = Resources.Load<GameObject>(PrefabPath);
        if (prefab == null)
        {
            Debug.LogError($"[RuntimeDataBootstrap] Prefab not found: Resources/{PrefabPath}");
            return;
        }

        var instance = Object.Instantiate(prefab);
        instance.name = "RuntimeDataHolder";
        Object.DontDestroyOnLoad(instance);
    }
}

Then create the RuntimeDataHolder component. This holds references to the SOs you want to persist. Create a prefab and place it in Resources/RuntimeData/:

public class RuntimeDataHolder : MonoBehaviour
{
    [Header("Runtime Data (keep alive)")]
    [SerializeField] private List<ScriptableObject> runtimeData;
}

Setup

  1. Create an empty GameObject for the prefab
  2. Attach the RuntimeDataHolder component
  3. Add the SOs you want to persist to the runtimeData list
  4. Save as prefab at Assets/Resources/RuntimeData/RuntimeDataHolder.prefab

Why This Works Well

  • Works regardless of which scene you start from
  • Enables single-scene testing during development
  • Works in builds too

This significantly improves the development experience. No need to start from a specific scene.

Choosing RuntimeInitializeLoadType

I used RuntimeInitializeLoadType.BeforeSceneLoad. This ensures initialization happens before any scene’s Awake methods.

TypeTiming
SubsystemRegistrationEarliest
BeforeSplashScreenBefore splash screen
BeforeSceneLoadBefore scene’s Awake
AfterSceneLoadAfter scene’s Awake (default)

Alternative Approaches

The “maintain references” approach isn’t the only option. Another method uses ISerializationCallbackReceiver to serialize and restore the Dictionary:

public class DictionaryHolderSO : ScriptableObject, ISerializationCallbackReceiver
{
    [SerializeField] private List<string> keys = new();
    [SerializeField] private List<int> values = new();
    private Dictionary<string, int> dict = new();

    public void OnBeforeSerialize()
    {
        keys.Clear();
        values.Clear();
        foreach (var kvp in dict)
        {
            keys.Add(kvp.Key);
            values.Add(kvp.Value);
        }
    }

    public void OnAfterDeserialize()
    {
        dict.Clear();
        for (int i = 0; i < keys.Count; i++)
            dict[keys[i]] = values[i];
    }
}

Pros:

  • Self-contained in the SO

Cons:

  • Requires implementation for each SO type
  • Gets complex with complicated types

Summary

  1. Non-serialized fields are lost when SOs are unloaded
  2. This happens in both Editor and builds (under different conditions)
  3. Solution: Maintain references from a DontDestroyOnLoad GameObject
  4. Use RuntimeInitializeOnLoadMethod for automatic initialization from any scene

I hit this issue at 3 AM during the game jam and eventually arrived at the RuntimeDataHolder pattern. It turned out to be a simple and versatile solution.

I hope this helps anyone facing the same problem.

References