Topic 13 of 15 12 min intermediate

Saving & Loading

Getting state onto disk without losing it

Saving is easy to get working and easy to get wrong in ways that only show up after release — when players already have saves you cannot afford to break.

Pick the right storage

MethodGood forAvoid when
PlayerPrefsSettings, volume, best scoreAnything structured, or anything cheatable.
JSON fileFull game stateYou need to hide the contents.
Binary fileLarge or private dataYou want to debug it by eye.
Cloud saveCross-device progressYou have no account system.
PlayerPrefs is a registry write on Windows and a plist on macOS. It is fine for a volume slider and wrong for an inventory. It also has no atomicity — a crash mid-write can leave half your keys updated.

Where the file goes

Always Application.persistentDataPath. It is the one location that is writable on every platform and survives an app update.

C#
// Correct: writable everywhere, survives updates
string path = Path.Combine(Application.persistentDataPath, "save_01.json");

// Wrong: read-only in a build, and inside the app bundle on mobile
string bad = Path.Combine(Application.dataPath, "save_01.json");

A JSON save that works

SaveSystem.cs
C#
using System.IO;
using UnityEngine;

[System.Serializable]
public class SaveData
{
    public int version = 2;          // bump when the shape changes
    public string sceneName;
    public float[] playerPosition;
    public int health;
    public SavedSlot[] inventory;
}

public static class SaveSystem
{
    private static string PathFor(int slot) =>
        Path.Combine(Application.persistentDataPath, $"save_{slot:00}.json");

    public static void Save(SaveData data, int slot)
    {
        string json = JsonUtility.ToJson(data, prettyPrint: true);
        string target = PathFor(slot);
        string temp = target + ".tmp";

        // Write to a temp file first, then swap. A crash mid-write
        // then costs you the new save, not the previous one too.
        File.WriteAllText(temp, json);
        if (File.Exists(target)) File.Delete(target);
        File.Move(temp, target);
    }

    public static SaveData Load(int slot)
    {
        string path = PathFor(slot);
        if (!File.Exists(path)) return null;

        try
        {
            return JsonUtility.FromJson<SaveData>(File.ReadAllText(path));
        }
        catch (System.Exception e)
        {
            Debug.LogError($"Save {slot} is corrupt: {e.Message}");
            return null;   // let the caller offer a new game
        }
    }
}
The temp-file-then-move pattern is the whole reason players do not lose a save when the game crashes during autosave. It costs four lines.

What JsonUtility will not do

Unity's built-in JsonUtility is fast but limited. Knowing the limits up front saves a confusing afternoon.

  • It cannot serialise Dictionary. Use parallel arrays or a list of key/value structs.
  • It cannot serialise a top-level array. Wrap it in a class.
  • It ignores properties — only public fields, or private fields marked [SerializeField].
  • It cannot serialise null for a nested class; you get a default instance back.
  • It cannot handle polymorphism. A List<Item> holding a Weapon loads back as an Item.
When those limits bite, Newtonsoft Json.NET is available as a Unity package (com.unity.nuget.newtonsoft-json) and handles all of the above.

Versioning

The moment you ship, players have saves in the old shape. A version number plus a migration step keeps them working.

C#
public static SaveData Migrate(SaveData data)
{
    if (data.version < 2)
    {
        // v1 stored a single item id; v2 stores an inventory array
        data.inventory = string.IsNullOrEmpty(data.legacyItemId)
            ? new SavedSlot[0]
            : new[] { new SavedSlot { itemId = data.legacyItemId, quantity = 1 } };
        data.version = 2;
    }

    return data;
}
Deleting a field is not free. Old saves still contain it, and if your migration reads it, keep the field around marked obsolete rather than removing it outright.

What not to save

  • GameObject references. They do not survive a scene reload. Save an id and find the object on load.
  • Vector3 directly is fine with JsonUtility, but a float array is more portable across tools.
  • Anything derivable. If max health comes from level, save the level.
  • Absolute paths. They differ per machine and per platform.

What to take away

  • PlayerPrefs for settings; a JSON file for game state.
  • Always write to Application.persistentDataPath.
  • Write to a temp file and move it, so a crash cannot destroy the old save.
  • Wrap loading in try/catch and handle corrupt files as a normal case.
  • Put a version number in the save from day one.
  • Save ids and values, never object references.