Save / Load — Saving Progress
PlayerPrefs, JSON, binary serialization, encryption, save versioning.
Save/Load is a standard task in any game. Unity has 3 main approaches, and the choice depends on the size and complexity of the data being saved.
Three approaches
| Approach | When | Platforms |
|---|---|---|
| PlayerPrefs | Small primitive values (settings, high score) | All |
| JSON + File.WriteAllText | Structured saves, human-readable | All except WebGL without a plugin |
| Binary Serialization | Large/complex saves, protection from tampering | All |
localStorage for small data (a 5MB limit), IndexedDB for large data. In Node — the regular
file system.
PlayerPrefs ≈ localStorage. JSON + File is already a “user file on disk” — in
Application.persistentDataPath (varies per platform).
PlayerPrefs — for settings
// Saving
PlayerPrefs.SetInt("HighScore", 1500);
PlayerPrefs.SetFloat("MasterVolume", 0.8f);
PlayerPrefs.SetString("PlayerName", "Konstantin");
PlayerPrefs.Save(); // explicitly write to disk (autoSave on quit)
// Reading
int score = PlayerPrefs.GetInt("HighScore", 0); // 0 — the default if the key is not found
float volume = PlayerPrefs.GetFloat("MasterVolume", 1.0f);
// Deleting
PlayerPrefs.DeleteKey("HighScore");
PlayerPrefs.DeleteAll(); // careful — this wipes all keys
Where it’s stored:
- Windows:
HKCU\Software\[Company]\[Product]in the Registry. - macOS:
~/Library/Preferences/[bundle identifier].plist. - Linux:
~/.config/unity3d/[Company]/[Product]/prefs.
PlayerPrefs is not encrypted. The user can open and edit it — that’s OK for settings, but insecure for game progress.
Each Save call synchronously serializes ALL preferences. If you have 1000 keys with string
arrays, performance will suffer. PlayerPrefs is for dozens of keys, not thousands.
JSON saves
For structured data, use JSON. Unity has a built-in JsonUtility (with no external dependencies).
using UnityEngine;
using System.IO;
[System.Serializable]
public class SaveData
{
public string playerName;
public int level;
public float playTimeSeconds;
public Vector3 position;
public InventoryData inventory;
}
[System.Serializable]
public class InventoryData
{
public int gold;
public string[] items;
}
public class SaveSystem : MonoBehaviour
{
private const string FILENAME = "save.json";
private string GetSavePath() {
return Path.Combine(Application.persistentDataPath, FILENAME);
}
public void SaveGame(SaveData data) {
string json = JsonUtility.ToJson(data, prettyPrint: true);
File.WriteAllText(GetSavePath(), json);
Debug.Log($"Saved to {GetSavePath()}");
}
public SaveData LoadGame() {
string path = GetSavePath();
if (!File.Exists(path)) {
return new SaveData(); // default values
}
string json = File.ReadAllText(path);
return JsonUtility.FromJson<SaveData>(json);
}
}
Application.persistentDataPath:
- Windows:
C:\Users\[user]\AppData\LocalLow\[Company]\[Product] - macOS:
~/Library/Application Support/[Company]/[Product] - iOS:
Documents/ - Android:
/data/data/[bundle]/files/(sandboxed)
The built-in JsonUtility does NOT support: dictionaries (Dictionary<TKey, TValue>), nullable
types, abstract classes (polymorphism). For those, use Newtonsoft.Json (the separate package
com.unity.nuget.newtonsoft-json) or System.Text.Json for the .NET style.
Serializing a Dictionary with Newtonsoft
using Newtonsoft.Json;
// ...
public Dictionary<string, int> inventory = new() {
{ "sword", 1 },
{ "potion", 5 },
};
string json = JsonConvert.SerializeObject(inventory, Formatting.Indented);
File.WriteAllText(path, json);
var loaded = JsonConvert.DeserializeObject<Dictionary<string, int>>(File.ReadAllText(path));
Encryption (or just obfuscation)
A JSON file is plain text. The player will open it in Notepad and change "gold": 100 to
"gold": 999999. Solutions, from simple to strong:
Level 1 — Base64 (not protection, just “doesn’t hurt the eye”)
string encoded = Convert.ToBase64String(Encoding.UTF8.GetBytes(json));
Level 2 — XOR with a constant (easy to crack, but hides the text)
const byte KEY = 0x5A;
byte[] data = Encoding.UTF8.GetBytes(json);
for (int i = 0; i < data.Length; i++) data[i] ^= KEY;
File.WriteAllBytes(path, data);
Level 3 — AES encryption (for serious projects with progress)
using System.Security.Cryptography;
using System.Text;
public static byte[] Encrypt(string text, string password) {
using var aes = Aes.Create();
aes.Key = SHA256.HashData(Encoding.UTF8.GetBytes(password));
aes.IV = new byte[16];
using var encryptor = aes.CreateEncryptor();
var plain = Encoding.UTF8.GetBytes(text);
return encryptor.TransformFinalBlock(plain, 0, plain.Length);
}
public static string Decrypt(byte[] data, string password) {
using var aes = Aes.Create();
aes.Key = SHA256.HashData(Encoding.UTF8.GetBytes(password));
aes.IV = new byte[16];
using var decryptor = aes.CreateDecryptor();
var plain = decryptor.TransformFinalBlock(data, 0, data.Length);
return Encoding.UTF8.GetString(plain);
}
⚠️ Important: the password is stored in your code → reverse engineering will find it. AES is protection from casual cheaters, not from serious crackers. For multiplayer game-state, validation belongs on the server, not the client.
Save versioning
When you release game v2.0 with new fields, old saves must keep working. The strategy:
[System.Serializable]
public class SaveData
{
public int version = 2; // <-- VERSION
public string playerName;
public int level;
public InventoryData inventory;
public List<QuestProgress> activeQuests; // <-- NEW in v2
}
public SaveData LoadGame() {
var data = JsonUtility.FromJson<SaveData>(File.ReadAllText(path));
if (data.version < 2) {
// Migration v1 → v2
data.activeQuests = new List<QuestProgress>(); // default
data.version = 2;
}
return data;
}
Version FROM THE VERY START. Adding a version later, when users already have saves, is much harder.
Async saving for large files
If the save is large (several megabytes):
public async Awaitable SaveGameAsync(SaveData data) {
string json = JsonUtility.ToJson(data);
// File.WriteAllText blocks the main thread → use async
await File.WriteAllTextAsync(GetSavePath(), json);
}
This avoids a 100ms stutter during autosave.
The auto-save pattern
public class AutoSave : MonoBehaviour
{
[SerializeField] private float intervalSeconds = 60f;
[SerializeField] private SaveSystem saveSystem;
[SerializeField] private GameState gameState;
private void Start() {
InvokeRepeating(nameof(DoAutoSave), intervalSeconds, intervalSeconds);
}
private async void DoAutoSave() {
var data = gameState.CaptureState();
await saveSystem.SaveGameAsync(data);
// show a "💾 Saved" toast
}
private void OnApplicationPause(bool paused) {
if (paused) DoAutoSave(); // on mobile when minimized
}
private void OnApplicationQuit() {
DoAutoSave();
}
}
Cloud Saves — Steam, Unity Cloud Save
For cross-device synchronization:
- Steam Cloud — via the Steamworks API; works transparently, putting your save into their cloud.
- Unity Cloud Save (the UGS package) — official, tied to Unity Authentication.
- Google Play Games Services — for Android.
All three are separate topics and require backend setup. For single-player without cross-device, a local save is usually enough.
Pitfalls
- Don’t use
Application.dataPathfor writing — it’s the path to the project’s assets, and in a build it’s read-only. OnlypersistentDataPath. - Don’t save a Transform directly —
Transformis not serializable. ExtractVector3 position,Quaternion rotation,Vector3 scaleby hand. - Don’t forget about async — a synchronous
File.WriteAllTextblocks the main thread → stutter. - Test save migration — added a field → run with an old save → doesn’t it crash?