Tutorial Tuesday

Build a JSON Save System in Unity 6: Save and Load Player Progress the Right Way

A complete Unity 6 tutorial for building a JSON save system with JsonUtility, ISaveable objects, save slots, autosave, and versioning.

Tutorials · By Game Design Jar · · Updated August 25, 2026 · P.03

Every game with progress needs a save system eventually (unless it’s an ultra-hard mode game like Maximum Carnage for the Sega Genesis where you have to start from the beginning every playthrough! - but I digress). Most devs reach for PlayerPrefs first because it’s right there and super simple to implement. That works fine for a high score or a volume slider. It falls apart the moment you have an inventory, multiple save slots, or a quest log — and trying to convert your PlayerPrefs-based save file later is genuinely painful. This tutorial builds a proper JSON save system from scratch, the way you’d want to have started.

What You’ll Build (and What It Won’t Do)

By the end you’ll have:

  • A SaveData class that represents everything worth persisting
  • A SaveSystem static class that writes and reads JSON files safely
  • An ISaveable interface so any scene object can opt into saving without the save system knowing what it is
  • Multiple save slots and a simple autosave timer
  • A migration hook so adding fields later doesn’t break old save files

This is not a plug-and-play save framework — it’s a foundation you extend as your game grows. It won’t cover cloud sync in depth (that’s covered briefly near the end), and it uses Unity’s built-in JsonUtility rather than a third-party serializer, with notes on when you’d outgrow it.

Prerequisites: Unity 6, C# Basics, and Project Setup

This targets Unity 6000.0 (Unity 6 LTS) using the built-in JsonUtility and standard System.IO. The code doesn’t depend on anything version-specific from Unity 6 except one method call (Object.FindObjectsByType), which is noted where it’s used — on older Unity versions swap that one line for FindObjectsOfType.

You should be comfortable with:

  • C# classes, interfaces, and [Serializable]
  • Basic MonoBehaviour lifecycle (Start, Awake, OnEnable)
  • Reading a compiler error and knowing which line it’s pointing at

Create an empty script folder called Save in your project — everything below goes there.

Why PlayerPrefs Breaks Down for Real Save Data

PlayerPrefs stores flat key-value pairs (ints, floats, strings) in the registry on Windows or a plist on macOS, outside your project’s control. It has no concept of structure, so representing an inventory means either serializing it into a delimited string yourself or exploding it into item0, item1, item2 keys — both approaches can get ugly fast and are hard to debug. It’s also not sandboxed per save slot, so multiple save files means manually prefixing every key, and there’s no atomic write — if the game crashes mid-write you can end up with a half-updated value and no way to know.

A JSON file on disk, by contrast, is structured, human-readable while you’re debugging, versionable, and easy to back up or inspect by hand. That’s what we’re building instead.

Step 1: Design a Serializable SaveData Class

JsonUtility only serializes public fields (or fields marked [SerializeField]) on classes marked [Serializable] — it ignores properties and auto-properties entirely, so please keep that in mind for every class below.

using System;
using System.Collections.Generic;

[Serializable]
public class PlayerData
{
    public float posX, posY, posZ;
    public int currentHealth;
    public int maxHealth;
    public int gold;
}

[Serializable]
public class SaveablePayload
{
    public string id;
    public string json;
}

[Serializable]
public class SaveData
{
    // Deliberately empty, not "1.0" — see the versioning section for why this matters.
    public string saveVersion = "";
    public long savedAtUnixSeconds;
    public float playtimeSeconds;
    public string sceneName;
    public PlayerData player = new PlayerData();
    public List<SaveablePayload> saveables = new List<SaveablePayload>();
}

The saveables list is the key design decision here: instead of trying to make SaveData know about every interactable object in the game (chests, quest logs, doors), each object serializes its own small chunk of JSON and hands it over with a unique ID. JsonUtility can’t serialize a Dictionary directly, so a list of key/value pairs stands in for one.

Step 2: Write a SaveSystem Service with JsonUtility

using System;
using System.IO;
using UnityEngine;

public static class SaveSystem
{
    public const string CurrentVersion = "1.0";

    private const string FileSuffix = ".save.json";

    public static string GetSavePath(int slot)
    {
        return Path.Combine(Application.persistentDataPath, $"slot_{slot}{FileSuffix}");
    }

    public static void Save(SaveData data, int slot)
    {
        data.saveVersion = CurrentVersion;
        data.savedAtUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds();

        string json = JsonUtility.ToJson(data, true);
        string finalPath = GetSavePath(slot);
        string tempPath = finalPath + ".tmp";

        Directory.CreateDirectory(Application.persistentDataPath);
        File.WriteAllText(tempPath, json);

        // Single atomic-ish swap. Never delete the old file first — see note below.
        File.Move(tempPath, finalPath, overwrite: true);
    }

    public static bool TryLoad(int slot, out SaveData data)
    {
        string path = GetSavePath(slot);
        if (!File.Exists(path))
        {
            data = null;
            return false;
        }

        try
        {
            string json = File.ReadAllText(path);
            data = JsonUtility.FromJson<SaveData>(json);
            if (data == null) return false;

            Migrate(data);
            return true;
        }
        catch (Exception e)
        {
            Debug.LogError($"Save file at {path} could not be read: {e.Message}");
            data = null;
            return false;
        }
    }

    private static void Migrate(SaveData data)
    {
        if (string.IsNullOrEmpty(data.saveVersion))
        {
            // A save written before versioning existed. Fields added since then are
            // already sitting at their defaults, so there's nothing to convert.
            data.saveVersion = CurrentVersion;
        }

        // Future breaking changes go here as explicit, ordered migration steps.
    }

    public static bool SlotExists(int slot) => File.Exists(GetSavePath(slot));

    public static void DeleteSlot(int slot)
    {
        string path = GetSavePath(slot);
        if (File.Exists(path)) File.Delete(path);
    }
}

Writing to a .tmp file and then moving it over the real file matters more than you might think: if the process is killed mid-write (crash, forced quit, power loss), you’re much better off with a corrupt .tmp file and an untouched, still-valid save — instead of a half-written save file that silently ruins someone’s precious hours of playthrough.

That protection only holds if you never delete the old file first. A File.Delete followed by a File.Move opens a window where the player has no save at all, which is worse than the problem you set out to solve. File.Move(..., overwrite: true) does the swap in one call, so there’s no moment where both files are gone.

Step 3: Use Application.persistentDataPath Correctly on Each Platform

Application.persistentDataPath resolves to a different real location per platform — inside the user’s AppData/Library folder on desktop, sandboxed app storage on mobile, and IndexedDB-backed virtual storage on WebGL. You should never hardcode a path or assume a specific OS folder structure; always go through this property.

Two things trip people up:

  • WebGL: writes to persistentDataPath go into a virtual filesystem backed by IndexedDB, and that layer can be asynchronous under the hood. If you ship to WebGL, test save/load specifically on that build target rather than assuming desktop behavior carries over.
  • Consoles: platform certification often requires using the platform’s own save API rather than raw file I/O, so treat this SaveSystem as a PC/mobile-first solution and expect a console port to need an abstraction layer in front of it.

Step 4: Collect State from Scene Objects with an ISaveable Interface

public interface ISaveable
{
    string SaveId { get; }
    string CaptureState();
    void RestoreState(string json);
}

An example implementation on a treasure chest. Note that ChestState is nested inside the component — a top-level type can’t be private, and nesting keeps each object’s save shape next to the object that owns it:

using UnityEngine;

public class TreasureChest : MonoBehaviour, ISaveable
{
    [System.Serializable]
    private class ChestState
    {
        public bool opened;
    }

    [SerializeField] private string saveId; // set uniquely per chest in the inspector
    private bool opened;

    public string SaveId => saveId;

    public string CaptureState()
    {
        return JsonUtility.ToJson(new ChestState { opened = opened });
    }

    public void RestoreState(string json)
    {
        var state = JsonUtility.FromJson<ChestState>(json);
        opened = state.opened;
        // update visuals/animator here based on `opened`
    }
}

The saveId has to be unique and stable across play sessions — a GUID generated once and stored in the prefab instance, not gameObject.name, since duplicate names or reordered hierarchies will collide.

Step 5: Load on Startup and Restore Scene State

The controller below references a PlayerHealth component. That’s a stand-in for whatever health component your game already has — it isn’t a Unity type and there’s nothing to install. Swap it for your own, or drop in this minimal version to follow along:

using UnityEngine;

public class PlayerHealth : MonoBehaviour
{
    public int Current { get; private set; }
    public int Max { get; private set; }

    public void SetHealth(int current, int max)
    {
        Max = max;
        Current = Mathf.Clamp(current, 0, max);
    }
}

This class uses properties rather than fields, which is fine precisely because PlayerHealth is never handed to JsonUtility — only SaveData and the ISaveable state classes get serialized, and those all use public fields as Step 1 requires.

Now the controller itself:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class SaveController : MonoBehaviour
{
    [SerializeField] private int activeSlot = 0;
    [SerializeField] private Transform playerTransform;
    [SerializeField] private PlayerHealth playerHealth; // swap for your own

    private float sessionStartTime;
    private float previousPlaytime;

    private void Start()
    {
        sessionStartTime = Time.unscaledTime;

        if (SaveSystem.TryLoad(activeSlot, out SaveData data))
        {
            previousPlaytime = data.playtimeSeconds;
            ApplySaveData(data);
        }

        StartCoroutine(AutosaveLoop());
    }

    public void SaveGame()
    {
        var data = new SaveData
        {
            sceneName = UnityEngine.SceneManagement.SceneManager.GetActiveScene().name,
            // Total across every session, not just this one.
            playtimeSeconds = previousPlaytime + (Time.unscaledTime - sessionStartTime)
        };

        data.player.posX = playerTransform.position.x;
        data.player.posY = playerTransform.position.y;
        data.player.posZ = playerTransform.position.z;
        data.player.currentHealth = playerHealth.Current;
        data.player.maxHealth = playerHealth.Max;

        foreach (var saveable in FindSaveables())
        {
            data.saveables.Add(new SaveablePayload
            {
                id = saveable.SaveId,
                json = saveable.CaptureState()
            });
        }

        SaveSystem.Save(data, activeSlot);
    }

    private void ApplySaveData(SaveData data)
    {
        playerTransform.position = new Vector3(data.player.posX, data.player.posY, data.player.posZ);
        playerHealth.SetHealth(data.player.currentHealth, data.player.maxHealth);

        var lookup = new Dictionary<string, string>();
        foreach (var payload in data.saveables)
        {
            lookup[payload.id] = payload.json;
        }

        foreach (var saveable in FindSaveables())
        {
            if (lookup.TryGetValue(saveable.SaveId, out string json))
            {
                saveable.RestoreState(json);
            }
        }
    }

    private static List<ISaveable> FindSaveables()
    {
        // Unity 6: FindObjectsByType replaces the older FindObjectsOfType.
        // FindObjectsInactive.Include matters — a disabled chest still has state
        // worth saving, and the default skips it silently.
        var behaviours = Object.FindObjectsByType<MonoBehaviour>(
            FindObjectsInactive.Include, FindObjectsSortMode.None);

        var results = new List<ISaveable>();
        foreach (var mb in behaviours)
        {
            if (mb is ISaveable saveable) results.Add(saveable);
        }
        return results;
    }

    private IEnumerator AutosaveLoop()
    {
        var wait = new WaitForSecondsRealtime(120f); // every two minutes
        while (true)
        {
            yield return wait;
            SaveGame();
        }
    }
}

If you’re restoring state across a scene load (rather than at boot in the same scene), do the ApplySaveData call after the new scene has finished loading — hook into SceneManager.sceneLoaded rather than calling it from Start() on an object that might not exist yet in the target scene.

Step 6: Multiple Save Slots and Autosave Timing

Slots are already supported by the slot parameter on Save/TryLoad — a save select screen just needs to loop over a fixed number of slots and call SaveSystem.SlotExists(i) to show populated vs. empty slots, then set activeSlot before calling SaveGame/loading.

For autosave, avoid saving every frame or on a tight timer — disk writes aren’t free and doing them mid-combat can cause a hitch. The AutosaveLoop coroutine above runs on a coarse two-minute timer, which is a reasonable default; autosaving on scene transitions and at checkpoints works just as well, and the two combine happily.

Always also expose a manual save option — players trust an explicit “Save” button more than an invisible autosave, especially early in a game’s life when they don’t yet know your autosave cadence.

JsonUtility vs Newtonsoft.Json: When You Need to Switch

JsonUtility is fast and dependency-free, but it has real limits: no Dictionary<TKey,TValue> support, no polymorphism (a base class field won’t serialize as its derived type), and it silently produces {} for anything that isn’t [Serializable] with public fields. If your save data needs any of those — a dictionary of item IDs to quantities, or a list of Quest objects where each quest is a different subclass — you’ll want Newtonsoft.Json, available as a Unity package (com.unity.nuget.newtonsoft-json) through the Package Manager’s “Add package by name” option. It supports dictionaries, polymorphic serialization with type hints, and finer control over null handling, at the cost of a slightly larger build and marginally slower serialization for very large save files.

A practical rule: start with JsonUtility as shown above, and only reach for Newtonsoft once you hit a specific limitation, rather than adopting it preemptively. Everyone is building something different, so choose accordingly based on the complexity of your game data.

Common Pitfalls and the Errors They Produce

  • Empty {} JSON output — the class isn’t marked [Serializable], or the fields are properties (public int Gold { get; set; }) instead of fields. JsonUtility ignores properties entirely.
  • CS1527: Elements defined in a namespace cannot be explicitly declared as private — you wrote a helper state class like ChestState at the top level with a private modifier. Nest it inside the component that uses it, as in Step 4.
  • DirectoryNotFoundException on first save — Application.persistentDataPath’s folder isn’t guaranteed to exist yet on a fresh install; call Directory.CreateDirectory before writing, as shown in Step 2.
  • NullReferenceException on load — usually means JsonUtility.FromJson returned a valid object but a nested list or class was never initialized in the class definition (give lists and nested classes default values inline, e.g. = new List<...>()).
  • Save file exists but reads as an old shape — you added or renamed a field and didn’t handle migration; see the next section.
  • Duplicate or missing ISaveable state — two objects share the same saveId, so one silently overwrites the other in the lookup dictionary during restore.

Handling Save Versioning Before It Bites You

It’s worth being precise about what JsonUtility.FromJson actually does, because the versioning hook depends on it: it constructs the object first, then overwrites only the fields that are present in the JSON. Anything missing from the file keeps whatever the field initializer gave it — which is why player and saveables come back as real empty objects rather than nulls, and why a field with no initializer lands on 0, false, or null.

That behavior makes adding a field safe for old saves. The dangerous change is renaming or removing one, which silently drops that data with no error at all.

It also explains why saveVersion starts as an empty string rather than "1.0". If it defaulted to the current version, a save written before versioning existed would load, find no saveVersion key in the file, keep the initializer, and report itself as perfectly current — so the migration you wrote for exactly those files would never run. An empty default is the one value an old file can’t accidentally claim, which is what makes it detectable:

if (string.IsNullOrEmpty(data.saveVersion))
{
    // Pre-versioning save. Nothing to convert yet — the defaults are correct.
    data.saveVersion = SaveSystem.CurrentVersion;
}

SaveSystem.Migrate (Step 2) already runs this on every load. The cheapest insurance is bumping CurrentVersion deliberately whenever SaveData’s shape changes, and adding an explicit migration step alongside it, rather than only reacting once a player reports a broken load.

Extending It: Encryption, Cloud Saves, and Steam Cloud

A few natural next steps once the base system works:

  • Light obfuscation: XOR or AES-encrypt the JSON string before writing to discourage casual save editing. This isn’t real security — a determined player can still find the key in the compiled game — but it stops trivial text-editor cheating.
  • Steam Cloud: if you’re distributing on Steam, files placed in the paths Steam is configured to track (via Steamworks) sync automatically; the JSON files this system produces work fine as-is, since Steam Cloud just mirrors files rather than caring about their format.
  • Unity Cloud Save / other backends: the same SaveData object can be serialized to JSON and pushed to a cloud save service instead of (or alongside) local disk — the SaveSystem class above is a natural seam to swap in a network call.
  • Off-the-shelf save assets: if hand-rolling this isn’t a good use of your time, the Unity Asset Store has dedicated save/serialization packages that add encryption, cloud sync, and editor tooling out of the box — worth evaluating against the time cost of building and maintaining your own system, especially for a small team. Browse the Unity Asset Store directly rather than trusting a random recommendation, and read recent reviews since save-system assets need to keep pace with engine updates.

If you’re also building out the visual side of a save/load screen and need slot thumbnails or icons, pixel art tools matter here too — see the comparison in Best Pixel Art Software for Game Dev if you’re deciding what to use for UI icons alongside your main art pipeline.

Full Script Listing

For reference, here’s the complete set of files as built above, in one place:

// SaveData.cs
using System;
using System.Collections.Generic;

[Serializable]
public class PlayerData
{
    public float posX, posY, posZ;
    public int currentHealth;
    public int maxHealth;
    public int gold;
}

[Serializable]
public class SaveablePayload
{
    public string id;
    public string json;
}

[Serializable]
public class SaveData
{
    public string saveVersion = "";
    public long savedAtUnixSeconds;
    public float playtimeSeconds;
    public string sceneName;
    public PlayerData player = new PlayerData();
    public List<SaveablePayload> saveables = new List<SaveablePayload>();
}
// SaveSystem.cs
using System;
using System.IO;
using UnityEngine;

public static class SaveSystem
{
    public const string CurrentVersion = "1.0";

    private const string FileSuffix = ".save.json";

    public static string GetSavePath(int slot)
    {
        return Path.Combine(Application.persistentDataPath, $"slot_{slot}{FileSuffix}");
    }

    public static void Save(SaveData data, int slot)
    {
        data.saveVersion = CurrentVersion;
        data.savedAtUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds();

        string json = JsonUtility.ToJson(data, true);
        string finalPath = GetSavePath(slot);
        string tempPath = finalPath + ".tmp";

        Directory.CreateDirectory(Application.persistentDataPath);
        File.WriteAllText(tempPath, json);
        File.Move(tempPath, finalPath, overwrite: true);
    }

    public static bool TryLoad(int slot, out SaveData data)
    {
        string path = GetSavePath(slot);
        if (!File.Exists(path)) { data = null; return false; }

        try
        {
            string json = File.ReadAllText(path);
            data = JsonUtility.FromJson<SaveData>(json);
            if (data == null) return false;

            Migrate(data);
            return true;
        }
        catch (Exception e)
        {
            Debug.LogError($"Save file at {path} could not be read: {e.Message}");
            data = null;
            return false;
        }
    }

    private static void Migrate(SaveData data)
    {
        if (string.IsNullOrEmpty(data.saveVersion))
        {
            data.saveVersion = CurrentVersion;
        }
    }

    public static bool SlotExists(int slot) => File.Exists(GetSavePath(slot));

    public static void DeleteSlot(int slot)
    {
        string path = GetSavePath(slot);
        if (File.Exists(path)) File.Delete(path);
    }
}
// ISaveable.cs
public interface ISaveable
{
    string SaveId { get; }
    string CaptureState();
    void RestoreState(string json);
}

That’s a complete, working baseline: structured save data, safe file writes, a way for arbitrary scene objects to opt into persistence, and enough of a versioning hook to survive your game’s design changing under it. Everything from here — slot UI, encryption, cloud sync — builds on top without touching this core.

unityunity6csharpsave-systemgame-developmenttutorial-tuesday

Registration card

Register to receive one focused post every weekday — engine tutorials, marketing tactics, and design craft. Free, unsubscribe anytime.

No spam. We never share your address. No purchase necessary — it's free.