~3 min read

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

ApproachWhenPlatforms
PlayerPrefsSmall primitive values (settings, high score)All
JSON + File.WriteAllTextStructured saves, human-readableAll except WebGL without a plugin
Binary SerializationLarge/complex saves, protection from tamperingAll
Web

localStorage for small data (a 5MB limit), IndexedDB for large data. In Node — the regular file system.

Unity

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.

PlayerPrefs is not for large 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)
JsonUtility limitations

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

  1. Don’t use Application.dataPath for writing — it’s the path to the project’s assets, and in a build it’s read-only. Only persistentDataPath.
  2. Don’t save a Transform directlyTransform is not serializable. Extract Vector3 position, Quaternion rotation, Vector3 scale by hand.
  3. Don’t forget about async — a synchronous File.WriteAllText blocks the main thread → stutter.
  4. Test save migration — added a field → run with an old save → doesn’t it crash?