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:
- Enter Play Mode in the Editor
- In Scene A, set some flags in the Dictionary
- Transition to Scene B (without selecting the SO in Inspector)
- 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:
| Type | Examples | Serializable? |
|---|---|---|
| Primitive types | int, float, double, bool, string | Yes |
| Enums | 32-bit or smaller enums | Yes |
| Unity built-in types | Vector2, Vector3, Rect, Color, AnimationCurve | Yes |
UnityEngine.Object derivatives | References to GameObject, Transform, ScriptableObject | Yes |
[Serializable] classes/structs | Custom types | Yes |
Arrays or List<T> of above | int[], List<string> | Yes |
Dictionary<K,V> | - | No |
HashSet<T> | - | No |
| Multidimensional arrays | int[,] | No |
| Jagged arrays | int[][] | No |
| Nested containers | List<List<T>> | No |
| Delegates | - | No |
| Interfaces | - | No |
Even if the type is serializable, fields must meet ALL of these conditions:
publicor has[SerializeField]attribute- Not
static,const, orreadonly - 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
- Create an empty GameObject for the prefab
- Attach the
RuntimeDataHoldercomponent - Add the SOs you want to persist to the
runtimeDatalist - 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.
| Type | Timing |
|---|---|
SubsystemRegistration | Earliest |
BeforeSplashScreen | Before splash screen |
BeforeSceneLoad | Before scene’s Awake |
AfterSceneLoad | After 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
- Non-serialized fields are lost when SOs are unloaded
- This happens in both Editor and builds (under different conditions)
- Solution: Maintain references from a DontDestroyOnLoad GameObject
- 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.