Save/Load System
Patterns for persisting game state to disk: an ISaveable interface for components that need persistence, a central SaveManager that orchestrates capture and restore, JSON serialization, save slot management, and preparation for cloud sync.
ISaveable Interface
Every component that needs to save state implements this interface. The SaveManager discovers all ISaveable objects in the scene and calls them during save/load.
/// <summary>
/// Implement on any MonoBehaviour that needs to persist state across saves.
/// </summary>
public interface ISaveable
{
/// <summary>
/// Unique key for this saveable. Must be stable across sessions.
/// Recommended format: "{scene}_{gameobject}_{component}" or a GUID.
/// </summary>
string SaveKey { get; }
/// <summary>
/// Capture current state as a serializable object.
/// Return a plain C# class or struct (no MonoBehaviour, no ScriptableObject).
/// </summary>
object CaptureState();
/// <summary>
/// Restore state from a previously captured object.
/// Cast the object to the expected type.
/// </summary>
void RestoreState(object state);
}
Example: Saveable Health Component
using UnityEngine;
public class Health : MonoBehaviour, ISaveable
{
[SerializeField] private int maxHealth = 100;
[SerializeField] private string saveKey;
private int _currentHealth;
public string SaveKey => saveKey;
private void Awake()
{
_currentHealth = maxHealth;
}
[System.Serializable]
private struct HealthSaveData
{
public int currentHealth;
public int maxHealth;
}
public object CaptureState()
{
return new HealthSaveData
{
currentHealth = _currentHealth,
maxHealth = maxHealth
};
}
public void RestoreState(object state)
{
if (state is HealthSaveData data)
{
_currentHealth = data.currentHealth;
maxHealth = data.maxHealth;
}
}
}
Generating Stable Save Keys
The save key must be the same every time the game runs. Options:
- Manual string (simplest): Assign in Inspector. Works for unique objects like "player_health".
- GUID component: Add a
SaveableEntityMonoBehaviour with a[SerializeField] private string uniqueIdthat generates a GUID inReset()(called when the component is first added in the editor). This auto-generates stable IDs.
using UnityEngine;
public class SaveableEntity : MonoBehaviour
{
[SerializeField] private string uniqueId;
public string UniqueId => uniqueId;
// Called in editor when component is first added
private void Reset()
{
uniqueId = System.Guid.NewGuid().ToString();
}
}
Save Data Structure
A single save file contains all captured state, plus metadata.
using System;
using System.Collections.Generic;
[Serializable]
public class SaveData
{
public int saveVersion = 1;
public string timestamp;
public string sceneName;
public float playTime;
// All saveable state, keyed by ISaveable.SaveKey
// Values are JSON strings (serialized individually per saveable)
public Dictionary<string, string> stateEntries = new();
}
Using Dictionary<string, string> where values are JSON strings (rather than Dictionary<string, object>) avoids polymorphic serialization issues with JsonUtility. Each ISaveable's state is serialized independently.
Save Manager
The central orchestrator. Finds all ISaveable components, serializes their state, and writes to disk.
using System;
using System.Collections.Generic;
using System.IO;
using UnityEngine;
using UnityEngine.SceneManagement;
public class SaveManager : MonoBehaviour
{
public static SaveManager Instance { get; private set; }
[SerializeField] private int maxSaveSlots = 3;
private float _sessionStartTime;
public event Action OnSaveCompleted;
public event Action OnLoadCompleted;
private void Awake()
{
if (Instance != null && Instance != this)
{
Destroy(gameObject);
return;
}
Instance = this;
DontDestroyOnLoad(gameObject);
_sessionStartTime = Time.time;
}
// --- File Paths ---
private string GetSaveFolderPath()
{
return Path.Combine(Application.persistentDataPath, "Saves");
}
private string GetSaveFilePath(int slot)
{
return Path.Combine(GetSaveFolderPath(), $"Save{slot}.json");
}
private string GetAutoSaveFilePath()
{
return Path.Combine(GetSaveFolderPath(), "AutoSave.json");
}
// --- Save ---
public void Save(int slot)
{
SaveToFile(GetSaveFilePath(slot));
}
public void AutoSave()
{
SaveToFile(GetAutoSaveFilePath());
}
private void SaveToFile(string path)
{
var saveData = new SaveData
{
saveVersion = 1,
timestamp = DateTime.Now.ToString("o"),
sceneName = SceneManager.GetActiveScene().name,
playTime = Time.time - _sessionStartTime
};
// Find all saveables in the scene
var saveables = FindAllSaveables();
foreach (var saveable in saveables)
{
try
{
object state = saveable.CaptureState();
string json = JsonUtility.ToJson(state);
saveData.stateEntries[saveable.SaveKey] = json;
}
catch (Exception e)
{
Debug.LogError($"Failed to capture state for {saveable.SaveKey}: {e.Message}");
}
}
// Serialize the full save data
// Using Newtonsoft because JsonUtility cannot serialize Dictionary
string fullJson = Newtonsoft.Json.JsonConvert.SerializeObject(saveData, Newtonsoft.Json.Formatting.Indented);
// Ensure directory exists
Directory.CreateDirectory(Path.GetDirectoryName(path));
// Write atomically: write to temp file, then rename
string tempPath = path + ".tmp";
File.WriteAllText(tempPath, fullJson);
if (File.Exists(path)) File.Delete(path);
File.Move(tempPath, path);
Debug.Log($"Game saved to {path}");
OnSaveCompleted?.Invoke();
}
// --- Load ---
public void Load(int slot)
{
LoadFromFile(GetSaveFilePath(slot));
}
public void LoadAutoSave()
{
LoadFromFile(GetAutoSaveFilePath());
}
private void LoadFromFile(string path)
{
if (!File.Exists(path))
{
Debug.LogWarning($"Save file not found: {path}");
return;
}
string json = File.ReadAllText(path);
var saveData = Newtonsoft.Json.JsonConvert.DeserializeObject<SaveData>(json);
if (saveData == null)
{
Debug.LogError("Failed to deserialize save data.");
return;
}
// Handle version migration
if (saveData.saveVersion < 1)
{
MigrateSaveData(saveData);
}
// Load the correct scene, then restore state
if (SceneManager.GetActiveScene().name != saveData.sceneName)
{
StartCoroutine(LoadSceneThenRestore(saveData));
}
else
{
RestoreState(saveData);
}
}
private System.Collections.IEnumerator LoadSceneThenRestore(SaveData saveData)
{
var asyncLoad = SceneManager.LoadSceneAsync(saveData.sceneName);
yield return asyncLoad;
// Wait one frame for scene objects to initialize
yield return null;
RestoreState(saveData);
}
private void RestoreState(SaveData saveData)
{
var saveables = FindAllSaveables();
foreach (var saveable in saveables)
{
if (!saveData.stateEntrie