Sunday, 12 July 2026

How to Build a Unity Save System: Save and Load Player Progress Without Losing Data

There is a specific moment every Unity developer hits that feels almost insulting after everything else you have built. Your player moves perfectly. The NavMesh enemies chase and patrol. The UI health bar updates correctly. The scene transitions work. You close the game, open it again, and everything is gone. Health back to 100. Score back to zero. Level progress wiped. All that work the player just did, vanished.

This is the save system problem, and it trips people up in a specific pattern. The first attempt is usually PlayerPrefs — it is built in and works for a few values. Then you realize PlayerPrefs is not designed for complex game state. The second attempt is usually a static class or a DontDestroyOnLoad singleton — which works while the game is running but writes nothing to disk. The third attempt is JSON, which actually works, but the first implementation usually has a bug related to file path, serialization rules, or error handling that silently corrupts the save file in edge cases.

I went through all three of those attempts on my first project that needed a real save system. This guide skips directly to what actually works: a JSON-based save system using Unity's built-in serializer for simple data and Newtonsoft.Json for complex structures, with proper file paths, error handling, version numbers, and the ScriptableObject integration pattern teased at the end of both the ScriptableObjects and Scene Management guides.



What Is a Save System in Unity?

A save system is the code responsible for writing your game's current state to disk when the player saves or quits, and reading it back when the game starts. "Current state" means anything that should persist between sessions — health, score, inventory contents, completed quests, player position, unlocked abilities, and settings like audio volume or control preferences.

Unity has no built-in save system. Unlike an Animator Controller or a NavMeshAgent, there is no component you add to get saving behavior — you build it entirely from your own scripts using the file system tools available in C#.

Why PlayerPrefs Is Not a Real Save System

PlayerPrefs is Unity's built-in key-value storage — a simple API for storing individual integers, floats, and strings by a string key. It is genuinely useful for a specific category of data: simple settings like volume, graphics quality, and control preferences that fit neatly into a handful of key-value pairs.

It breaks down quickly for real game state because:

  • It only stores int, float, and string — no lists, no nested objects, no inventory arrays.
  • On Windows it writes to the registry, which has size limits not designed for game data.
  • On WebGL it uses browser IndexedDB with strict quotas that vary by browser and device.
  • It has no structure, no versioning, and no error handling — a corrupted or missing key silently returns a default value with no indication something went wrong.
  • It cannot be easily inspected during development, making save-related bugs significantly harder to debug.

A common mistake beginners make is extending PlayerPrefs to handle more and more data by converting everything to strings — serializing an entire inventory as a comma-separated string stored in a single PlayerPrefs key, for example. This works until it doesn't, and when it breaks it is very hard to debug. JSON serialization to a file handles all of these cases cleanly from the start.



When To Use Each Approach

Approach Best For Not Suitable For
PlayerPrefs Simple settings: volume, graphics quality, control scheme, whether the tutorial was shown. Complex game state, inventory, quest progress, player position — anything that grows or nests.
JsonUtility (built-in) Simple structured data — player stats, position, basic settings — that fits in flat C# classes with public fields. Dictionaries, polymorphic data, C# properties, nullable types, complex nested structures.
Newtonsoft.Json (package) Complex structured data — inventories, quest logs, any data needing Dictionaries or C# properties. Situations where the tiny extra setup cost is not justified for truly simple data.
Binary serialization Performance-critical large data sets or obfuscated save files. Most beginner and intermediate projects — harder to debug and inspect than JSON.
Cloud save (Unity Gaming Services) Cross-platform progress that should follow the player across devices. Local-only projects, offline games, or projects not using Unity's Gaming Services platform.

Advantages of JSON-Based Save System

Advantage Why It Helps
Human-readable files You can open the save file in any text editor and read the data directly, which makes debugging save-related bugs dramatically faster.
Handles complex structures Lists, nested objects, and inventory arrays serialize cleanly — no string manipulation workarounds needed.
Works across all Unity platforms Application.persistentDataPath provides the correct writable path automatically on Windows, Mac, iOS, Android, and consoles.
Versionable Including a version number in the save data lets you migrate old save files to a new format without breaking existing player saves.


Project Setup

Installing Newtonsoft.Json

For projects needing Dictionary serialization, C# properties, or complex nested structures, install Newtonsoft.Json through Unity's Package Manager:

  1. Go to Window > Package Manager.
  2. Click the + button and choose Add package by name.
  3. Enter: com.unity.nuget.newtonsoft-json
  4. Click Add.

For simple projects without dictionaries or properties, Unity's built-in JsonUtility requires no installation.

Recommended Folder Structure

  • Assets/Scripts/SaveSystem — all save-related scripts.
  • Assets/Scripts/Data — SaveData class definitions (alongside your ScriptableObject classes from guide 10).

Part 1: Building the Save Data Structure

The first rule of a good save system is keeping save data completely separate from your runtime game objects. Your save data is a plain C# class — not a MonoBehaviour, not a ScriptableObject — that only holds serializable values.

using System;
using System.Collections.Generic;

[Serializable]
public class SaveData
{
    public int version = 1;
    public string timestamp;

    public PlayerSaveData player;
    public List<InventoryItemSaveData> inventory = new List<InventoryItemSaveData>();
    public List<string> completedQuests = new List<string>();
    public string currentSceneName;
    public AudioSettingsSaveData audioSettings;
}

[Serializable]
public class PlayerSaveData
{
    public float posX;
    public float posY;
    public float posZ;
    public float health;
    public int score;
    public bool hasDoubleJump;
}

[Serializable]
public class InventoryItemSaveData
{
    public string itemId;
    public int quantity;
}

[Serializable]
public class AudioSettingsSaveData
{
    public float masterVolume = 1f;
    public float musicVolume = 1f;
    public float sfxVolume = 1f;
}

Why Structure It This Way

[Serializable] is mandatory on every class you want JsonUtility to process. Without it, JsonUtility silently ignores the class and serializes nothing — no error, no warning, just empty data.

public int version = 1; is one of the most important fields in the entire save system even though it does nothing yet. When you change your game's data structure in a future update — adding a new stat, renaming a field, restructuring inventory — this version number lets your save loader detect old save files and migrate them gracefully rather than crashing or silently losing data.

public string timestamp; records when the save was created, which is useful for displaying "last saved" information in your save/load UI and for debugging.

Notice that inventory is a List of InventoryItemSaveData — plain string IDs and quantities — not a List of actual WeaponData ScriptableObjects. This is the critical principle connecting back to our ScriptableObjects guide: you save the reference ID, not the asset itself. When loading, you look up the actual WeaponData ScriptableObject from your item database using the saved ID.

Part 2: The Save Manager

using UnityEngine;
using System.IO;
using System;

public class SaveManager : MonoBehaviour
{
    public static SaveManager Instance { get; private set; }

    private string SavePath => Path.Combine(Application.persistentDataPath, "savegame.json");

    void Awake()
    {
        if (Instance != null && Instance != this)
        {
            Destroy(gameObject);
            return;
        }
        Instance = this;
        DontDestroyOnLoad(gameObject);
    }

    public void Save(SaveData data)
    {
        try
        {
            data.timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
            string json = JsonUtility.ToJson(data, true);

            string tempPath = SavePath + ".tmp";
            File.WriteAllText(tempPath, json);
            File.Move(tempPath, SavePath);

            Debug.Log("Game saved to: " + SavePath);
        }
        catch (Exception e)
        {
            Debug.LogError("Save failed: " + e.Message);
        }
    }

    public SaveData Load()
    {
        if (!File.Exists(SavePath))
        {
            Debug.Log("No save file found. Starting fresh.");
            return null;
        }

        try
        {
            string json = File.ReadAllText(SavePath);
            SaveData data = JsonUtility.FromJson<SaveData>(json);
            return data;
        }
        catch (Exception e)
        {
            Debug.LogError("Load failed — save file may be corrupted: " + e.Message);
            return null;
        }
    }

    public bool HasSaveFile()
    {
        return File.Exists(SavePath);
    }

    public void DeleteSave()
    {
        if (File.Exists(SavePath))
        {
            File.Delete(SavePath);
            Debug.Log("Save file deleted.");
        }
    }
}

Line By Line Explanation

Application.persistentDataPath is the most important line in this entire guide. This Unity property automatically provides the correct writable file path for the current platform — the AppData/LocalLow folder on Windows, the Library folder on Mac, the internal storage path on Android, and so on. Never hardcode a file path like "C:/saves/" — it will work on your machine during development and break on every other device.

string tempPath = SavePath + ".tmp"; File.Move(tempPath, SavePath); is the atomic write pattern — write to a temporary file first, then rename it over the real save file. If the game crashes mid-write without this pattern, you end up with a half-written, corrupted save file. With this pattern, the old save is either fully intact (if the write crashed before the move) or fully replaced (if the write completed). This is the difference between a game that handles crashes gracefully and one that permanently corrupts its save file on a random power cut or force-quit.

JsonUtility.ToJson(data, true) — the second argument (true) enables pretty-printing, which adds readable indentation to the output. Disable this (pass false or omit the argument) in release builds to reduce file size slightly.

try / catch around both Save() and Load() prevents a failed save or corrupted load from crashing the entire game. Without this, a missing file permission or a corrupted save file throws an unhandled exception that may terminate the session.

When I first built a save system without error handling, a player on an Android device where the persistent data path had a permissions issue caused a crash-on-launch that was nearly impossible to reproduce on my development machine. The catch block turns that unhandled crash into a logged error and a fresh-start fallback — recoverable rather than fatal.



Part 3: Collecting and Applying Save Data

The SaveManager handles reading and writing files. Separate scripts handle collecting data from the game world before saving, and applying it back after loading.

using UnityEngine;
using UnityEngine.SceneManagement;

public class PlayerDataCollector : MonoBehaviour
{
    public float currentHealth;
    public int currentScore;
    public bool hasDoubleJump;

    public PlayerSaveData CollectSaveData()
    {
        return new PlayerSaveData
        {
            posX = transform.position.x,
            posY = transform.position.y,
            posZ = transform.position.z,
            health = currentHealth,
            score = currentScore,
            hasDoubleJump = hasDoubleJump
        };
    }

    public void ApplyLoadedData(PlayerSaveData data)
    {
        transform.position = new Vector3(data.posX, data.posY, data.posZ);
        currentHealth = data.health;
        currentScore = data.score;
        hasDoubleJump = data.hasDoubleJump;
    }
}
using UnityEngine;
using UnityEngine.SceneManagement;

public class GameSaveController : MonoBehaviour
{
    public PlayerDataCollector playerCollector;

    public void SaveGame()
    {
        SaveData data = new SaveData();
        data.player = playerCollector.CollectSaveData();
        data.currentSceneName = SceneManager.GetActiveScene().name;

        SaveManager.Instance.Save(data);
    }

    public void LoadGame()
    {
        SaveData data = SaveManager.Instance.Load();

        if (data == null)
        {
            Debug.Log("No save data — starting new game.");
            return;
        }

        if (data.currentSceneName != SceneManager.GetActiveScene().name)
        {
            SceneManager.LoadScene(data.currentSceneName);
        }

        playerCollector.ApplyLoadedData(data.player);
    }
}

This separation — SaveManager handles file I/O, PlayerDataCollector handles gameplay data, GameSaveController orchestrates between them — means you can add new saveable data (an inventory, quest log, or unlocked abilities) by adding a new collector without touching the file I/O code. The SceneManager integration directly connects this guide to our Scene Management guide from Article 11: saving the current scene name and reloading it on load means the player returns to exactly where they left off.

Part 4: Connecting to ScriptableObjects

In the ScriptableObjects guide we warned that ScriptableObjects are not save systems — they are configuration templates. Here is the correct pattern for saving inventory data that uses WeaponData ScriptableObjects.

using UnityEngine;
using System.Collections.Generic;

public class PlayerInventory : MonoBehaviour
{
    public List<WeaponData> equippedWeapons = new List<WeaponData>();
    public WeaponDatabase weaponDatabase;

    public List<InventoryItemSaveData> CollectInventorySaveData()
    {
        List<InventoryItemSaveData> saveList = new List<InventoryItemSaveData>();
        foreach (WeaponData weapon in equippedWeapons)
        {
            saveList.Add(new InventoryItemSaveData
            {
                itemId = weapon.name,
                quantity = 1
            });
        }
        return saveList;
    }

    public void ApplyInventoryLoadData(List<InventoryItemSaveData> savedItems)
    {
        equippedWeapons.Clear();
        foreach (InventoryItemSaveData item in savedItems)
        {
            WeaponData weapon = weaponDatabase.GetWeaponById(item.itemId);
            if (weapon != null)
            {
                equippedWeapons.Add(weapon);
            }
        }
    }
}

weapon.name uses Unity's built-in asset name as the ID. In production, use a dedicated ID field on WeaponData instead of relying on the asset name, since renaming an asset would silently break existing save files that stored the old name.

weaponDatabase.GetWeaponById() would be a method on a WeaponDatabase ScriptableObject — a list of all WeaponData assets in the game — that looks up an asset by its ID. This is the correct pattern: save the lightweight ID string, load the full ScriptableObject data at runtime by looking it up in the database. You never save the ScriptableObject asset itself.

Part 5: Save Versioning

The version field in SaveData is useless unless you actually check it on load. Here is a minimal migration pattern:

public SaveData Load()
{
    if (!File.Exists(SavePath))
        return null;

    try
    {
        string json = File.ReadAllText(SavePath);
        SaveData data = JsonUtility.FromJson<SaveData>(json);

        data = MigrateSaveData(data);
        return data;
    }
    catch (Exception e)
    {
        Debug.LogError("Load failed: " + e.Message);
        return null;
    }
}

private SaveData MigrateSaveData(SaveData data)
{
    if (data.version == 1)
    {
        // Example: version 1 did not have hasDoubleJump
        // Leave it at the default (false) — no action needed
        data.version = 2;
    }

    if (data.version == 2)
    {
        // Future migration from version 2 to 3 goes here
        data.version = 3;
    }

    return data;
}

This pattern migrates save files forward one version at a time. A save file from version 1 passes through the version 1 migration, then the version 2 migration, and so on until it reaches the current version. Each migration handles only the specific changes introduced in that version, which keeps each migration block simple and easy to reason about.



Part 6: Basic Save Encryption

For most mobile and PC games, basic encryption is enough to prevent casual players from hand-editing save files. This is not serious security — a determined player with decompilation tools can still access game data — but it stops the majority of casual save editing.

using System;
using System.Text;

public static class SaveEncryption
{
    private static readonly string EncryptionKey = "YourSecretKey123";

    public static string Encrypt(string plainText)
    {
        byte[] data = Encoding.UTF8.GetBytes(plainText);
        byte[] key = Encoding.UTF8.GetBytes(EncryptionKey);

        for (int i = 0; i < data.Length; i++)
        {
            data[i] = (byte)(data[i] ^ key[i % key.Length]);
        }

        return Convert.ToBase64String(data);
    }

    public static string Decrypt(string encryptedText)
    {
        byte[] data = Convert.FromBase64String(encryptedText);
        byte[] key = Encoding.UTF8.GetBytes(EncryptionKey);

        for (int i = 0; i < data.Length; i++)
        {
            data[i] = (byte)(data[i] ^ key[i % key.Length]);
        }

        return Encoding.UTF8.GetString(data);
    }
}

To use it, encrypt the JSON string before writing and decrypt it after reading in SaveManager:

// In Save():
string json = JsonUtility.ToJson(data, true);
string encrypted = SaveEncryption.Encrypt(json);
File.WriteAllText(tempPath, encrypted);

// In Load():
string encrypted = File.ReadAllText(SavePath);
string json = SaveEncryption.Decrypt(encrypted);
SaveData data = JsonUtility.FromJson<SaveData>(json);

This is XOR encryption — basic, fast, and sufficient for casual protection. For a competitive game where cheating has real consequences, consider a server-authoritative approach where the server holds the canonical game state rather than relying on client-side save file protection.

Beginner Mistakes

  • Hardcoding a file path instead of using Application.persistentDataPath. A path that works on your Windows development machine will fail on Android, iOS, and Mac. Always use Application.persistentDataPath.
  • Forgetting [Serializable] on save data classes. JsonUtility silently ignores classes without this attribute — no error, just empty or missing data in the save file, which is a very confusing bug to track down.
  • Writing save data directly without the atomic temp-file pattern. A crash or force-quit mid-write permanently corrupts the save file. Always write to a temporary file and rename it over the final save file.
  • Not wrapping file operations in try/catch. File permission errors, out-of-storage errors, and corrupted data all throw exceptions that crash the game without error handling. Catch them and fall back gracefully.
  • Saving ScriptableObject assets directly instead of their IDs. JsonUtility cannot serialize Unity asset references. Save the asset's ID string and look it up from your item database on load.
  • Never incrementing the version number. Adding a new field to SaveData without bumping the version means you cannot distinguish old save files (which are missing the field) from new ones, making clean migration impossible.
  • Only testing the save system on your development machine. Platform-specific path and permission issues only surface on the actual target device. Test save and load on a real Android or iOS device before shipping.

Troubleshooting Guide

Problem: Save file is created but all values load as zero or default.

Likely cause: The [Serializable] attribute is missing from one or more SaveData classes, or fields are declared as properties (get/set) instead of plain public fields.

Fix: Add [Serializable] to every class in your save data hierarchy. Convert any C# properties to public fields, or switch to Newtonsoft.Json which supports properties.

Problem: Save file does not exist after calling Save().

Likely cause: An exception during the write is being swallowed silently, or the file is being written but to a different path than you are checking.

Fix: Log Application.persistentDataPath at startup to confirm where the file should appear. Check the catch block logs for any exception messages that indicate why the write failed.

Problem: Load crashes the game with a JSON parse error.

Likely cause: The save file is corrupted — likely from a previous crashed write without the atomic temp-file pattern, or from a manual edit that introduced invalid JSON.

Fix: Add the atomic write pattern to prevent future corruption. For the current corrupted file, add a fallback in the catch block that deletes the corrupted save and starts fresh rather than crashing.

Problem: Player spawns at position (0,0,0) instead of the saved position after loading.

Likely cause: ApplyLoadedData() is being called before the scene finishes loading, so the player object is not yet in its final state when the position is applied.

Fix: Call ApplyLoadedData() from Start() or after a scene load event rather than during the scene loading process itself. Use the SceneManager.sceneLoaded callback to apply data safely after the scene is fully initialized.

Problem: Inventory is empty after loading even though items were saved.

Likely cause: The item IDs in the save file do not match the asset names in the weapon database, often because assets were renamed after save files were created.

Fix: Use a dedicated, stable ID field on WeaponData ScriptableObjects rather than relying on the asset file name. Add null checks in ApplyInventoryLoadData() and log a warning when a saved ID cannot be found in the database.



Best Practices

  • Always use Application.persistentDataPath — never hardcode a file path that will only work on your own machine.
  • Always use the atomic temp-file write pattern — write to .tmp, then rename to the final save path, to prevent corruption from crashes.
  • Always wrap file I/O in try/catch and fall back gracefully — never let a save or load failure crash the game session.
  • Keep save data as plain [Serializable] C# classes with public fields, completely separate from MonoBehaviours and ScriptableObjects.
  • Save asset IDs, not asset references — look up ScriptableObject assets by ID from your item database at load time.
  • Include a version field and increment it every time you change the save data structure, with a migration step for each version.
  • Test save and load on actual target hardware (real Android or iOS device) before shipping — platform-specific issues do not surface in the Editor.


Frequently Asked Questions

Does Unity have a built-in save system?

No. Unity provides PlayerPrefs for simple key-value storage and JsonUtility for JSON serialization, but there is no built-in save system component. You build it from these tools using your own scripts.

What is the difference between PlayerPrefs and a JSON save system?

PlayerPrefs only stores individual int, float, and string values by key, has platform-specific size limits, and has no structure or versioning. A JSON save system stores fully structured data including nested objects, lists, and complex types in a readable text file with no practical size limits for typical game data.

What is Application.persistentDataPath?

It is a Unity property that automatically returns the correct writable file path for the current platform — AppData on Windows, Library on Mac, internal storage on Android, and so on. Always use this instead of hardcoding a path.

What is the difference between JsonUtility and Newtonsoft.Json for Unity?

JsonUtility is Unity's built-in serializer — fast, with no setup, but it cannot serialize Dictionaries, C# properties, or polymorphic types. Newtonsoft.Json is a third-party package (available free through Unity's Package Manager) that handles all of those cases with more flexibility at a slightly higher initial setup cost.

Why should I write to a temp file before the real save file?

If the game crashes or is force-quit mid-write without the temp file pattern, the save file is left in a partially written, corrupted state. Writing to a temp file and renaming it over the final file ensures the old save is either fully intact or fully replaced — never partially written.

Can I save ScriptableObject assets directly?

No — JsonUtility cannot serialize Unity asset references. Save the asset's ID string, then look up the actual ScriptableObject from your item database at load time using that ID.

How do I handle save file corruption?

Wrap all file read operations in a try/catch block. In the catch, log the error and return null or a default SaveData. In your game startup logic, treat a null load result as a fresh start rather than crashing. The atomic write pattern prevents most corruption from write failures.

How do I add multiple save slots?

Store each save slot in a separate file, naming them by slot index (savegame_0.json, savegame_1.json). Update SaveManager to accept a slot parameter in Save() and Load() and build the path from that parameter.

How do I implement auto-save?

Call SaveManager.Instance.Save(data) on a timer (every 5 minutes, for example), on each scene load, and on application quit using OnApplicationQuit(). Keep auto-save and manual save using the same Save() method to avoid maintaining two separate code paths.

Should I encrypt my save files?

For most games, basic XOR encryption as shown in this guide is sufficient to prevent casual save editing. For competitive games where cheating has real consequences, a server-authoritative approach is more reliable than any client-side encryption.

Why is my save data loading as all zeros?

The [Serializable] attribute is almost certainly missing from one of your save data classes, or fields are declared as C# properties (get/set) instead of plain public fields. JsonUtility silently ignores unserializable data without throwing any error.

How do I save the current scene so the player returns to the right level?

Store SceneManager.GetActiveScene().name in your SaveData before saving, then call SceneManager.LoadScene(data.currentSceneName) before applying other save data on load. This connects directly to the Scene Management patterns from our earlier guide.

Key Takeaways

  • PlayerPrefs is for simple settings, not complex game state. Use JSON serialization for anything more structured than a handful of individual values.
  • Application.persistentDataPath is the only correct file path for save data — never hardcode a platform-specific path.
  • The atomic write pattern (write to temp, rename to final) prevents save file corruption from crashes and force-quits.
  • Always wrap file I/O in try/catch to prevent save failures from crashing the game.
  • Save asset IDs, not Unity asset references — look up ScriptableObjects by ID at load time.
  • Include a version field and migrate save files forward one version at a time to handle future data structure changes cleanly.

Action Steps

  1. Create the SaveData, PlayerSaveData, and InventoryItemSaveData classes from this guide and confirm they serialize correctly by logging the JSON output to the console.
  2. Build the SaveManager and test Save() and Load() with a simple scene — confirm the file appears at Application.persistentDataPath.
  3. Add the atomic temp-file write pattern and test that it handles a simulated mid-write interruption without corrupting the save file.
  4. Connect LoadGame() to the Scene Management system from guide 11 — save the current scene name and confirm the player returns to the correct scene on load.
  5. Test the complete save and load cycle on your actual target device (Android or iOS), not just in the Unity Editor.

Learning Roadmap

  1. Get basic save and load working with player stats before adding inventory, quest data, or scene management integration.
  2. Add the version field and write one migration step, even if the current version is still 1, to build the habit before you actually need it.
  3. Connect your save system to the ScriptableObject item database from guide 10, saving and loading inventory by asset ID.
  4. Add auto-save on scene load and OnApplicationQuit() for a production-quality save experience.
  5. Explore Unity Gaming Services Cloud Save for cross-device progress if your game targets multiple platforms.

Next Topics To Learn

No comments:

Post a Comment

How to Use ElevenLabs for Game Development: AI Voice Acting, NPC Dialogue, and Sound Design

Most indie games ship without voice acting. Not because developers do not want it, but because hiring voice actors is expensive, scheduling ...