Unity

Unity 6 Save System Tutorial: JSON, Slots & Autosave

A save system looks simple until your game starts growing.

At first, you may only need to remember the player’s position. Then you add health, coins, inventory items, quests, settings, checkpoints, multiple characters, and different save slots.

Later, you update the game and discover another problem: old save files no longer match the new version of your game.

A good Unity save system needs to do much more than write a few values to disk.

In this tutorial, you’ll build a practical Unity 6 save system that supports:

  • JSON save files
  • Multiple save slots
  • Manual saving
  • Autosaving
  • Save-file backups
  • Corrupted-save recovery
  • Save versioning
  • Migration of older save files
  • Player position
  • Player stats
  • Inventory data
  • Safe file locations
  • Mobile pause handling
  • Scalable project architecture

The system is beginner-friendly, but the architecture is clean enough to expand into larger Unity projects.


What You’ll Learn

By the end of this tutorial, you’ll know how to:

  • Create a serializable save-data model
  • Convert game data into JSON
  • Store save files in the correct Unity directory
  • Create multiple independent save slots
  • Load saved games safely
  • Add autosaving
  • Save when a mobile application is paused
  • Create backup save files
  • Recover from corrupted saves
  • Version your save-file format
  • Migrate old saves after game updates
  • Save inventory using stable item IDs
  • Structure a save system so it remains manageable as your game grows

Who Should Read This

This tutorial is useful for:

  • Beginner Unity developers building their first save system
  • Intermediate developers replacing simple PlayerPrefs saves
  • Indie developers preparing a game for release
  • Developers building RPGs, survival games, platformers, simulations, or progression-based games
  • Advanced developers who need save versioning and migration
  • C# developers learning better Unity architecture

The system is mainly designed for local single-player saves.

For competitive multiplayer games, important progression should normally be handled by an authoritative server rather than trusted from a local file.


Prerequisites

You should understand the basics of:

  • Unity GameObjects
  • MonoBehaviour
  • C# classes
  • Lists
  • Methods
  • Inspector references
  • Basic file concepts

You do not need previous JSON experience.


Table of Contents

  1. How a Unity save system works
  2. JSON vs PlayerPrefs
  3. Recommended project structure
  4. Creating the save-data model
  5. Creating the JSON save system
  6. Adding multiple save slots
  7. Connecting gameplay data
  8. Creating the SaveManager
  9. Adding autosave
  10. Saving when the game pauses or closes
  11. Adding backups
  12. Save versioning
  13. Migrating old saves
  14. Validating loaded data
  15. Saving inventory correctly
  16. Testing the save system
  17. Common mistakes
  18. Performance and optimization
  19. Security
  20. Advanced architecture
  21. Best practices

How a Unity Save System Works

A basic save system follows this flow:

Gameplay State
      ↓
Capture Save Data
      ↓
Serialize to JSON
      ↓
Write JSON File to Disk

Loading performs the opposite process:

JSON Save File
      ↓
Read File
      ↓
Deserialize JSON
      ↓
Validate or Migrate Data
      ↓
Apply Data to Game

The most important idea is separation.

You should save data, not your entire Unity scene.

For example, instead of trying to save the player GameObject, save values such as:

Position X
Position Y
Position Z
Health
Level
Coins
Inventory item IDs
Quest states

When the save is loaded, your game uses those values to rebuild the correct runtime state.

This approach is easier to debug, easier to version, and far easier to maintain.

[Internal Link: Unity Game Architecture]


JSON vs PlayerPrefs

Unity provides PlayerPrefs, but PlayerPrefs is better suited to small preferences than large structured game saves.

PlayerPrefs works well for:

  • Master volume
  • Music volume
  • Mouse sensitivity
  • Graphics quality
  • Language
  • Small user preferences

JSON works better for:

  • Player progression
  • Inventory
  • Character stats
  • World state
  • Quests
  • Multiple save slots
  • Multiple characters
  • Complex structured data

PlayerPrefs vs JSON

FeaturePlayerPrefsJSON
Audio settingsExcellentWorks
Graphics settingsExcellentWorks
Player positionPossibleBetter
InventoryAwkwardExcellent
Multiple save slotsAwkwardEasy
Save versioningDifficultEasy
Human-readableLimitedYes
Complex dataPoor fitGood
BackupsAwkwardEasy
DebuggingLimitedEasy

A good architecture is:

PlayerPrefs
   └── User preferences

JSON Save Files
   └── Game progression

[Internal Link: Unity PlayerPrefs Guide]


Recommended Project Structure

Before writing code, organize your save system.

A clean structure could be:

Assets/
└── Scripts/
    └── SaveSystem/
        ├── Data/
        │   └── SaveData.cs
        │
        ├── Runtime/
        │   ├── SaveSystem.cs
        │   └── SaveManager.cs
        │
        └── Demo/
            └── DemoPlayerState.cs

Each file has a clear responsibility.

SaveData.cs

Defines what gets saved.

SaveSystem.cs

Handles:

  • JSON
  • File paths
  • Saving
  • Loading
  • Backups
  • Save slots
  • Version migration

SaveManager.cs

Connects the save system to your actual gameplay.

This separation becomes increasingly useful as your project grows.

[Internal Link: SOLID Principles for Unity]


Step 1: Create the Save Data Model

Create:

Assets/Scripts/SaveSystem/Data/SaveData.cs

Add:

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

[Serializable]
public class SaveData
{
    public int saveVersion = SaveSystem.CurrentVersion;

    public string slotName;
    public long savedAtUtcTicks;

    public int level = 1;
    public int coins = 0;
    public float health = 100f;
    public string difficulty = "Normal";

    public PlayerPositionData playerPosition = new PlayerPositionData();

    public List<InventoryItemSaveData> inventory =
        new List<InventoryItemSaveData>();
}

[Serializable]
public class PlayerPositionData
{
    public float x;
    public float y;
    public float z;

    public PlayerPositionData()
    {
    }

    public PlayerPositionData(Vector3 position)
    {
        x = position.x;
        y = position.y;
        z = position.z;
    }

    public Vector3 ToVector3()
    {
        return new Vector3(x, y, z);
    }
}

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

Understanding SaveData

SaveData represents one complete snapshot of your game.

Think of it as a container holding everything needed to restore the player’s progress.


saveVersion

public int saveVersion = SaveSystem.CurrentVersion;

This records which version of the save format created the file.

For example:

Version 1
Health
Coins
Level

Version 2
Health
Coins
Level
Difficulty

Without save versioning, your game may have trouble understanding older save files after updates.


savedAtUtcTicks

public long savedAtUtcTicks;

This stores when the save was created.

Later, your save-slot menu can display something like:

Slot 1
Level 14
Last Saved: 8:42 PM

Player Position

Instead of saving the Transform component itself, we save three numbers:

x
y
z

This keeps persistent data separate from Unity runtime objects.


Inventory Data

Each inventory entry contains:

public string itemId;
public int amount;

For example:

wood_sword → 1
health_potion → 4
gold_key → 1

Saving stable item IDs is usually much safer than trying to save GameObjects, prefabs, or runtime component references.

[Internal Link: Unity Inventory System]


Understanding JsonUtility

Unity can convert supported serializable C# classes into JSON.

Saving:

string json = JsonUtility.ToJson(saveData, true);

Loading:

SaveData data = JsonUtility.FromJson<SaveData>(json);

The second argument in ToJson() controls pretty printing.

JsonUtility.ToJson(saveData, true);

produces easier-to-read JSON.

A generated file could look like:

{
    "saveVersion": 2,
    "slotName": "Knight",
    "savedAtUtcTicks": 639287402000000000,
    "level": 8,
    "coins": 475,
    "health": 82.5,
    "difficulty": "Normal",
    "playerPosition": {
        "x": 14.2,
        "y": 1.0,
        "z": -8.4
    },
    "inventory": [
        {
            "itemId": "iron_sword",
            "amount": 1
        },
        {
            "itemId": "health_potion",
            "amount": 4
        }
    ]
}

This is one of JSON’s biggest advantages during development.

You can open the file and immediately inspect what your game saved.


Step 2: Use the Correct Save Location

Do not save files inside the Unity Assets directory.

Do not hard-code a Windows path such as:

C:/MyGame/Saves/

That path may not exist on another computer, Android, macOS, or iOS.

Use:

Application.persistentDataPath

You can inspect the location using:

Debug.Log(Application.persistentDataPath);

Our save files will be stored like this:

persistentDataPath/
└── Saves/
    ├── slot_0.json
    ├── slot_0.json.bak
    ├── slot_1.json
    └── slot_2.json

Step 3: Create the Core SaveSystem

Create:

Assets/Scripts/SaveSystem/Runtime/SaveSystem.cs

Add:

using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using UnityEngine;

public static class SaveSystem
{
    public const int CurrentVersion = 2;

    private const int MaxSlots = 3;

    private static string SaveDirectory =>
        Path.Combine(Application.persistentDataPath, "Saves");

    public static void Save(int slotIndex, SaveData data)
    {
        ValidateSlotIndex(slotIndex);

        if (data == null)
        {
            throw new ArgumentNullException(nameof(data));
        }

        Directory.CreateDirectory(SaveDirectory);

        data.saveVersion = CurrentVersion;
        data.savedAtUtcTicks = DateTime.UtcNow.Ticks;

        string json = JsonUtility.ToJson(data, true);
        string path = GetSlotPath(slotIndex);

        WriteFileSafely(path, json);

        Debug.Log($"Game saved to slot {slotIndex}: {path}");
    }

    public static bool TryLoad(int slotIndex, out SaveData data)
    {
        ValidateSlotIndex(slotIndex);

        string mainPath = GetSlotPath(slotIndex);
        string backupPath = GetBackupPath(slotIndex);

        if (TryLoadFromPath(mainPath, out data))
        {
            return true;
        }

        if (TryLoadFromPath(backupPath, out data))
        {
            Debug.LogWarning(
                $"Main save for slot {slotIndex} failed. Loaded backup."
            );

            return true;
        }

        data = null;
        return false;
    }

    public static bool SlotExists(int slotIndex)
    {
        ValidateSlotIndex(slotIndex);

        return File.Exists(GetSlotPath(slotIndex)) ||
               File.Exists(GetBackupPath(slotIndex));
    }

    public static void DeleteSlot(int slotIndex)
    {
        ValidateSlotIndex(slotIndex);

        DeleteIfExists(GetSlotPath(slotIndex));
        DeleteIfExists(GetBackupPath(slotIndex));
        DeleteIfExists(GetTempPath(slotIndex));
    }

    public static SaveSlotInfo GetSlotInfo(int slotIndex)
    {
        ValidateSlotIndex(slotIndex);

        if (!TryLoad(slotIndex, out SaveData data))
        {
            return new SaveSlotInfo
            {
                slotIndex = slotIndex,
                exists = false
            };
        }

        return new SaveSlotInfo
        {
            slotIndex = slotIndex,
            exists = true,
            slotName = data.slotName,
            level = data.level,
            savedAtUtcTicks = data.savedAtUtcTicks
        };
    }

    private static bool TryLoadFromPath(
        string path,
        out SaveData data)
    {
        data = null;

        if (!File.Exists(path))
        {
            return false;
        }

        try
        {
            string json = File.ReadAllText(path, Encoding.UTF8);

            if (string.IsNullOrWhiteSpace(json))
            {
                return false;
            }

            data = DeserializeAndMigrate(json);

            if (data == null)
            {
                return false;
            }

            ValidateLoadedData(data);

            return true;
        }
        catch (Exception exception)
        {
            Debug.LogWarning(
                $"Could not load save file '{path}'.\n{exception.Message}"
            );

            data = null;
            return false;
        }
    }

    private static SaveData DeserializeAndMigrate(string json)
    {
        SaveHeader header = JsonUtility.FromJson<SaveHeader>(json);

        if (header == null)
        {
            throw new InvalidDataException(
                "Save file does not contain a valid header."
            );
        }

        switch (header.saveVersion)
        {
            case 1:
                SaveDataV1 oldData =
                    JsonUtility.FromJson<SaveDataV1>(json);

                return MigrateFromV1(oldData);

            case CurrentVersion:
                return JsonUtility.FromJson<SaveData>(json);

            default:
                if (header.saveVersion > CurrentVersion)
                {
                    throw new InvalidDataException(
                        $"Save version {header.saveVersion} is newer " +
                        $"than supported version {CurrentVersion}."
                    );
                }

                throw new InvalidDataException(
                    $"Unsupported save version: {header.saveVersion}"
                );
        }
    }

    private static SaveData MigrateFromV1(SaveDataV1 oldData)
    {
        if (oldData == null)
        {
            throw new InvalidDataException(
                "Version 1 save could not be read."
            );
        }

        SaveData migrated = new SaveData
        {
            saveVersion = CurrentVersion,
            slotName = oldData.slotName,
            savedAtUtcTicks = oldData.savedAtUtcTicks,
            level = oldData.level,
            coins = oldData.coins,
            health = oldData.health,

            difficulty = "Normal",

            playerPosition =
                oldData.playerPosition ?? new PlayerPositionData(),

            inventory =
                oldData.inventory ??
                new List<InventoryItemSaveData>()
        };

        return migrated;
    }

    private static void ValidateLoadedData(SaveData data)
    {
        data.level = Mathf.Max(1, data.level);
        data.health = Mathf.Max(0f, data.health);

        if (string.IsNullOrWhiteSpace(data.difficulty))
        {
            data.difficulty = "Normal";
        }

        if (data.playerPosition == null)
        {
            data.playerPosition = new PlayerPositionData();
        }

        if (data.inventory == null)
        {
            data.inventory = new List<InventoryItemSaveData>();
        }

        for (int i = data.inventory.Count - 1; i >= 0; i--)
        {
            InventoryItemSaveData item = data.inventory[i];

            if (item == null ||
                string.IsNullOrWhiteSpace(item.itemId) ||
                item.amount <= 0)
            {
                data.inventory.RemoveAt(i);
            }
        }
    }

    private static void WriteFileSafely(
        string destinationPath,
        string json)
    {
        string tempPath = destinationPath + ".tmp";
        string backupPath = destinationPath + ".bak";

        File.WriteAllText(tempPath, json, Encoding.UTF8);

        if (File.Exists(destinationPath))
        {
            File.Copy(
                destinationPath,
                backupPath,
                true
            );
        }

        File.Copy(
            tempPath,
            destinationPath,
            true
        );

        File.Delete(tempPath);
    }

    private static string GetSlotPath(int slotIndex)
    {
        return Path.Combine(
            SaveDirectory,
            $"slot_{slotIndex}.json"
        );
    }

    private static string GetBackupPath(int slotIndex)
    {
        return GetSlotPath(slotIndex) + ".bak";
    }

    private static string GetTempPath(int slotIndex)
    {
        return GetSlotPath(slotIndex) + ".tmp";
    }

    private static void ValidateSlotIndex(int slotIndex)
    {
        if (slotIndex < 0 || slotIndex >= MaxSlots)
        {
            throw new ArgumentOutOfRangeException(
                nameof(slotIndex),
                $"Slot must be between 0 and {MaxSlots - 1}."
            );
        }
    }

    private static void DeleteIfExists(string path)
    {
        if (File.Exists(path))
        {
            File.Delete(path);
        }
    }

    [Serializable]
    private class SaveHeader
    {
        public int saveVersion;
    }

    [Serializable]
    private class SaveDataV1
    {
        public int saveVersion;

        public string slotName;
        public long savedAtUtcTicks;

        public int level;
        public int coins;
        public float health;

        public PlayerPositionData playerPosition;

        public List<InventoryItemSaveData> inventory;
    }
}

public class SaveSlotInfo
{
    public int slotIndex;
    public bool exists;
    public string slotName;
    public int level;
    public long savedAtUtcTicks;
}

How the Save Method Works

The save method receives:

Save(int slotIndex, SaveData data)

For example:

SaveSystem.Save(0, data);

means:

Save to Slot 1
→ slot_0.json

First, the folder is created if it does not already exist:

Directory.CreateDirectory(SaveDirectory);

Then we update the save version:

data.saveVersion = CurrentVersion;

Next, we store the current save time:

data.savedAtUtcTicks = DateTime.UtcNow.Ticks;

Finally, the object is converted into JSON:

string json = JsonUtility.ToJson(data, true);

and written to disk.


Step 4: Add Multiple Save Slots

Our filename is generated with:

$"slot_{slotIndex}.json"

That creates:

slot_0.json
slot_1.json
slot_2.json

Your UI can display these as:

Save Slot 1
Save Slot 2
Save Slot 3

The mapping is:

UI Slot 1 → index 0
UI Slot 2 → index 1
UI Slot 3 → index 2

Check Whether a Slot Exists

bool exists = SaveSystem.SlotExists(0);

Delete a Slot

SaveSystem.DeleteSlot(0);

This removes:

slot_0.json
slot_0.json.bak
slot_0.json.tmp

Read Save-Slot Information

SaveSlotInfo info = SaveSystem.GetSlotInfo(0);

if (info.exists)
{
    Debug.Log($"Level: {info.level}");
}

You can convert the stored timestamp back to a readable date:

DateTime savedTime =
    new DateTime(
        info.savedAtUtcTicks,
        DateTimeKind.Utc
    ).ToLocalTime();

Your UI could then show:

Knight
Level 8
Last Saved: 8:42 PM

Step 5: Create a Demo Player State

Now we need gameplay data to save.

Create:

Assets/Scripts/SaveSystem/Demo/DemoPlayerState.cs

Add:

using System.Collections.Generic;
using UnityEngine;

public class DemoPlayerState : MonoBehaviour
{
    public int level = 1;
    public int coins = 0;
    public float health = 100f;
    public string difficulty = "Normal";

    public List<InventoryItemSaveData> inventory =
        new List<InventoryItemSaveData>();

    public SaveData CaptureSaveData(
        Vector3 playerPosition,
        string slotName)
    {
        return new SaveData
        {
            slotName = slotName,
            level = level,
            coins = coins,
            health = health,
            difficulty = difficulty,

            playerPosition =
                new PlayerPositionData(playerPosition),

            inventory =
                new List<InventoryItemSaveData>(inventory)
        };
    }

    public void ApplySaveData(SaveData data)
    {
        level = data.level;
        coins = data.coins;
        health = data.health;
        difficulty = data.difficulty;

        inventory =
            new List<InventoryItemSaveData>(
                data.inventory
            );
    }
}

This class represents our runtime player state.

In a larger game, this data may come from separate systems such as:

PlayerStats
InventoryManager
QuestManager
WorldManager
CurrencyManager
EquipmentManager

That is perfectly fine.

The important idea is that these systems provide data to the save layer rather than the save layer trying to serialize your whole scene automatically.


Step 6: Create the SaveManager

Create:

Assets/Scripts/SaveSystem/Runtime/SaveManager.cs

Add:

using System.Collections;
using UnityEngine;

public class SaveManager : MonoBehaviour
{
    [Header("References")]
    [SerializeField]
    private Transform player;

    [SerializeField]
    private DemoPlayerState playerState;

    [Header("Autosave")]
    [SerializeField]
    private bool autosaveEnabled = true;

    [SerializeField]
    private float autosaveInterval = 60f;

    private int activeSlot = -1;
    private bool canSave;

    private Coroutine autosaveCoroutine;

    private void Start()
    {
        if (autosaveEnabled)
        {
            autosaveCoroutine =
                StartCoroutine(AutosaveLoop());
        }
    }

    public void StartNewGame(int slotIndex)
    {
        activeSlot = slotIndex;
        canSave = true;

        ManualSave();
    }

    public bool LoadGame(int slotIndex)
    {
        if (!SaveSystem.TryLoad(
                slotIndex,
                out SaveData data))
        {
            Debug.LogWarning(
                $"No valid save found in slot {slotIndex}."
            );

            return false;
        }

        activeSlot = slotIndex;

        ApplyGameState(data);

        canSave = true;

        return true;
    }

    public void ManualSave()
    {
        if (!canSave || activeSlot < 0)
        {
            return;
        }

        SaveCurrentGame();
    }

    private void SaveCurrentGame()
    {
        SaveData data =
            playerState.CaptureSaveData(
                player.position,
                $"Slot {activeSlot + 1}"
            );

        SaveSystem.Save(
            activeSlot,
            data
        );
    }

    private void ApplyGameState(SaveData data)
    {
        playerState.ApplySaveData(data);

        player.position =
            data.playerPosition.ToVector3();
    }

    private IEnumerator AutosaveLoop()
    {
        while (true)
        {
            yield return new WaitForSecondsRealtime(
                Mathf.Max(10f, autosaveInterval)
            );

            if (canSave)
            {
                SaveCurrentGame();
            }
        }
    }

    private void OnApplicationPause(bool pauseStatus)
    {
        if (pauseStatus && canSave)
        {
            SaveCurrentGame();
        }
    }

    private void OnApplicationQuit()
    {
        if (canSave)
        {
            SaveCurrentGame();
        }
    }
}

Why canSave Is Important

This small variable prevents a serious problem.

Imagine your game starts on the main menu.

An existing save is stored in:

slot_0.json

The player has not loaded it yet.

If your autosave system immediately saves the current state, it could overwrite the real save with empty or default data.

That is why we start with:

private bool canSave;

Saving becomes available only after:

StartNewGame()

or a successful:

LoadGame()

This protects existing save files from being overwritten before the game has actually loaded them.


Step 7: Set Up SaveManager in Unity

Create an empty GameObject:

SaveManager

Attach:

SaveManager.cs

Drag your player Transform into:

Player

Then attach DemoPlayerState to your player or another appropriate GameObject and assign it to:

Player State

A simple hierarchy might look like:

Game
├── Systems
│   └── SaveManager
│
└── Player
    └── DemoPlayerState

Step 8: Connect Save and Load Buttons

For simple UI buttons, add wrapper methods to SaveManager.

public void NewGameSlot1()
{
    StartNewGame(0);
}

public void LoadSlot1()
{
    LoadGame(0);
}

public void SaveGame()
{
    ManualSave();
}

Connect these methods to your Unity UI Button OnClick events.

For multiple slots:

StartNewGame(0);
StartNewGame(1);
StartNewGame(2);

Loading works the same way:

LoadGame(0);
LoadGame(1);
LoadGame(2);

[Internal Link: Unity UI Toolkit Save Slot Menu]


Step 9: Add Autosave

Our autosave loop uses:

WaitForSecondsRealtime

instead of:

WaitForSeconds

This means the autosave timer is not affected by:

Time.timeScale

That becomes useful if your pause menu sets:

Time.timeScale = 0f;

The default interval is:

60 seconds

but the right interval depends on your game.


Good Autosave Triggers

Useful moments to save include:

  • Every few minutes
  • Reaching a checkpoint
  • Completing a quest
  • Entering a safe area
  • Finishing a level
  • Changing scenes
  • Sleeping in a survival game
  • Returning to the main menu
  • Application pause

You normally do not need to save every time one coin changes.


Saving on Mobile

Mobile applications do not always follow the same shutdown behavior as desktop programs.

For that reason, do not rely only on:

OnApplicationQuit()

Our system also uses:

private void OnApplicationPause(bool pauseStatus)
{
    if (pauseStatus && canSave)
    {
        SaveCurrentGame();
    }
}

When the application is moved into the background, this gives your game another opportunity to store its current state.

For mobile games, always test this behavior on the actual target device.


Step 10: Create Save Backups

Our system does not immediately destroy the old save.

First, the new JSON is written to a temporary file:

File.WriteAllText(tempPath, json, Encoding.UTF8);

If a current save already exists, it is copied to:

slot_0.json.bak

Then the temporary file becomes the new main save.

The process looks like this:

Current Save
     ↓
Create Backup
     ↓
Write Temporary Save
     ↓
Create New Main Save

If the main save later fails to load, TryLoad() attempts to read the backup.

This gives players an additional recovery path if something goes wrong.


Step 11: Save Versioning

Game data changes over time.

Imagine version 1 contains:

Level
Coins
Health
Position
Inventory

Later, version 2 adds:

Difficulty

Our save system contains:

public const int CurrentVersion = 2;

Every new save contains:

"saveVersion": 2

When loading, the system first reads only the version number.

That lets it decide how the file should be interpreted.


Step 12: Migrate an Old Save

Our old version looks like:

[Serializable]
private class SaveDataV1
{
    public int saveVersion;

    public string slotName;
    public long savedAtUtcTicks;

    public int level;
    public int coins;
    public float health;

    public PlayerPositionData playerPosition;

    public List<InventoryItemSaveData> inventory;
}

Notice that version 1 does not contain:

difficulty

The migration method converts the old save into the current format:

private static SaveData MigrateFromV1(
    SaveDataV1 oldData)
{
    SaveData migrated = new SaveData
    {
        saveVersion = CurrentVersion,

        slotName = oldData.slotName,
        savedAtUtcTicks = oldData.savedAtUtcTicks,

        level = oldData.level,
        coins = oldData.coins,
        health = oldData.health,

        difficulty = "Normal",

        playerPosition =
            oldData.playerPosition ??
            new PlayerPositionData(),

        inventory =
            oldData.inventory ??
            new List<InventoryItemSaveData>()
    };

    return migrated;
}

Old players keep their original progress while receiving:

difficulty = Normal

as the default value for the new field.


What Happens When Version 3 Arrives?

Suppose later your game reaches:

public const int CurrentVersion = 3;

You could update the loader:

switch (header.saveVersion)
{
    case 1:
        return MigrateFromV1(
            JsonUtility.FromJson<SaveDataV1>(json)
        );

    case 2:
        return MigrateFromV2(
            JsonUtility.FromJson<SaveDataV2>(json)
        );

    case 3:
        return JsonUtility.FromJson<SaveData>(json);
}

For larger projects, migrations can also happen sequentially:

V1
↓
V2
↓
V3
↓
Current

This becomes easier to maintain than writing one massive migration function for every historical save format.


Why Reject Newer Save Versions?

Imagine this situation:

  1. The player runs version 3 of your game.
  2. Version 3 creates a new save.
  3. The player installs an older version 2 build.
  4. Version 2 tries to load the newer save.

Version 2 may not understand version 3 data.

Trying to load it anyway could destroy information.

That is why our loader checks:

if (header.saveVersion > CurrentVersion)

and rejects the file safely.

Failing safely is better than pretending incompatible data is valid.


Step 13: Validate Loaded Data

Never assume loaded data is perfect.

Our validation code checks values such as:

data.level = Mathf.Max(1, data.level);
data.health = Mathf.Max(0f, data.health);

It also repairs missing objects:

if (data.playerPosition == null)
{
    data.playerPosition =
        new PlayerPositionData();
}

and:

if (data.inventory == null)
{
    data.inventory =
        new List<InventoryItemSaveData>();
}

Invalid inventory entries are also removed.

Validation is useful because save data can become incorrect due to:

  • Old game versions
  • Corrupted files
  • Removed content
  • Development builds
  • Manual editing
  • Bugs
  • Failed migrations
  • Mods

Step 14: Save Inventory Correctly

Inventory systems need careful design.

Avoid saving:

GameObject
Prefab
MonoBehaviour
UI slot
Runtime component

Instead, save stable item identifiers.

For example:

{
    "itemId": "health_potion",
    "amount": 5
}

Your item database can resolve:

health_potion

into the correct ScriptableObject or runtime item after loading.

The flow becomes:

Saved Item ID
      ↓
Item Database
      ↓
Item Definition
      ↓
Runtime Inventory Item

This keeps persistent save data independent from Unity scene objects.

[Internal Link: Unity Inventory System with ScriptableObjects]


Why Stable Item IDs Matter

Suppose you save items using list indexes:

Item 7

Later, you reorder the database.

Now item 7 may represent something completely different.

Instead, save:

iron_sword

or another permanent identifier.

Once an item ID has shipped in a released game, avoid changing it without migration.


Example: Add an Inventory Item

You can add an item using:

playerState.inventory.Add(
    new InventoryItemSaveData
    {
        itemId = "health_potion",
        amount = 3
    }
);

Then save:

saveManager.ManualSave();

The JSON file will contain that inventory entry.


Step 15: Test the Save System

Never test only the successful case.

A real save system should survive unusual situations.


Test 1: Basic Save and Load

  1. Start a new game.
  2. Move the player.
  3. Change health.
  4. Add coins.
  5. Add an inventory item.
  6. Save.
  7. Stop Play Mode.
  8. Start again.
  9. Load the save.

Confirm that every value returns correctly.


Test 2: Multiple Slots

Create:

Slot 1 → Level 3
Slot 2 → Level 8
Slot 3 → Level 15

Load each one separately.

Verify that no slot overwrites another.


Test 3: Delete a Slot

Run:

SaveSystem.DeleteSlot(1);

Verify that only the selected slot disappears.


Test 4: Backup Recovery

Create several saves so a .bak file exists.

Then intentionally damage:

slot_0.json

For example:

broken file

Try loading the game.

The system should fail to load the main save and then attempt to use the backup.


Test 5: Migration

Create a version 1 JSON file:

{
    "saveVersion": 1,
    "slotName": "Old Hero",
    "savedAtUtcTicks": 0,
    "level": 10,
    "coins": 500,
    "health": 70,
    "playerPosition": {
        "x": 5,
        "y": 1,
        "z": 10
    },
    "inventory": []
}

Load it.

The migrated save should still contain:

Level = 10
Coins = 500
Health = 70

and should receive:

Difficulty = Normal

Test 6: Mobile Pause

Build the game for your target mobile platform.

Run the game.

Move the application into the background.

Return to it.

Check whether the save file was updated correctly.

Do not rely only on Editor testing for mobile lifecycle behavior.


Common Unity Save System Mistakes

Mistake 1: Saving Only When the Game Quits

A game may crash.

A mobile application may be suspended.

The operating system may terminate the process.

Use several save triggers:

  • Manual save
  • Autosave
  • Checkpoints
  • Pause
  • Scene transitions

Mistake 2: Saving Every Frame

Never do this:

private void Update()
{
    SaveGame();
}

That creates unnecessary:

  • Serialization
  • Disk writes
  • CPU work
  • Storage activity

Save meaningful snapshots instead.


Mistake 3: Using PlayerPrefs for Everything

PlayerPrefs is useful for small preferences.

It becomes difficult to manage when your save contains:

  • Inventory
  • Quests
  • World state
  • Character progression
  • Multiple save slots

Use structured save files for structured game data.


Mistake 4: Saving Runtime Objects Directly

Avoid saving:

GameObjects
MonoBehaviours
Transforms
UI objects
Scene references

Store simple persistent data instead.


Mistake 5: No Save Version

A system without versioning often works during development.

Then your game updates and old saves break.

Add a save version from the beginning.

Even this is enough:

public int saveVersion = 1;

Mistake 6: Renaming Fields Without Migration

Suppose you release:

public int coins;

Later, you rename it:

public int money;

Existing saves may no longer map correctly.

Once players have save files, your save structure behaves like a data schema.

Changes should be handled carefully.


Mistake 7: No Backup

Players may spend dozens of hours building progress.

A single damaged save file should not automatically destroy everything.

Keep at least one previous copy where appropriate.


Mistake 8: Saving Before the Game Is Loaded

This is a dangerous lifecycle bug.

Imagine an existing save has:

Level 25
Coins 5000

Your game starts on the menu with default values:

Level 1
Coins 0

If an autosave runs before loading the real save, the player’s progress may be overwritten.

That is why our system uses:

canSave

Performance Tips

Saving should normally be almost invisible to the player.

If your game freezes every time the autosave icon appears, the system needs improvement.


Don’t Save Derived Data

Suppose:

Strength = 20
Sword Bonus = 5
Total Damage = 25

You may only need to store:

Strength
Sword ID

The total damage can be recalculated after loading.

Saving unnecessary calculated data increases file size and creates more opportunities for inconsistent values.


Save IDs Instead of Large Objects

Prefer:

"itemId": "iron_sword"

instead of copying every property of the sword into each save file.


Disable Pretty Printing for Production if Needed

During development:

JsonUtility.ToJson(data, true);

is easier to inspect.

For release builds, you can use:

JsonUtility.ToJson(data, false);

which removes unnecessary whitespace.

For small save files, the difference may be minor, so debugging convenience is often more valuable during development.


Avoid Saving During Time-Critical Gameplay

Do not unnecessarily trigger large saves:

  • During every combat frame
  • Every physics update
  • Every UI refresh
  • Every collected coin

Prefer:

  • Checkpoints
  • Menus
  • Scene transitions
  • Controlled autosave intervals

Memory Optimization

Do not create a save file that duplicates your entire runtime world.

Imagine a procedural world containing thousands of trees.

Instead of saving every tree:

Tree 1
Tree 2
Tree 3
Tree 4
...

you may save:

World Seed
+
Removed Tree IDs
+
Player Changes

For example:

seed = 849291
removedTreeIds = [...]
openedChestIds = [...]
builtStructures = [...]

Then the game regenerates the default world and applies only the player’s changes.

This can scale much better.

[Internal Link: Unity Memory Optimization]


Security Tips

JSON is human-readable.

A player could open:

slot_0.json

and change:

"coins": 500

to:

"coins": 99999999

For many local single-player games, this may not be a serious concern.

For competitive multiplayer games or games with valuable online economies, it is a completely different situation.


Never Trust Local Saves as Multiplayer Authority

Anything stored on the player’s device can potentially be:

  • Read
  • Modified
  • Replaced
  • Deleted
  • Rolled back

For important online progression, the server should be authoritative.

For example:

Client:
"I have 50,000 premium coins."

Server:
"According to the authoritative game state, you have 500."

The server should decide which value is valid.


Encryption Is Not the Same as Security

Encrypting a save file can discourage casual editing.

However, if the game client contains the code and key required to decrypt the file, a determined attacker may still be able to discover them.

Encryption can be useful, but it should not be treated as a replacement for server authority.


Checksums

A checksum can help detect accidental corruption.

However, a plain checksum is not automatically anti-cheat.

If an attacker can modify both the save data and the checksum, the protection is limited.

Choose security measures based on your actual game and threat model.


Advanced Architecture: Split Save Data by System

Our tutorial uses one SaveData class because it is simple to understand.

A larger project may use:

SaveData
├── PlayerSaveData
├── InventorySaveData
├── QuestSaveData
├── WorldSaveData
└── ProgressionSaveData

For example:

[Serializable]
public class SaveData
{
    public int saveVersion;

    public PlayerSaveData player;
    public InventorySaveData inventory;
    public QuestSaveData quests;
    public WorldSaveData world;
}

This becomes much easier to maintain than putting hundreds of unrelated variables inside one class.


Advanced Architecture: Save Participants

A large game may have many systems that contribute data.

For example:

PlayerStats
InventoryManager
QuestManager
WorldManager
AchievementManager

Instead of making the SaveManager understand every system, each one can expose its own save state.

For example:

public interface ISaveParticipant<T>
{
    T CaptureState();
    void RestoreState(T data);
}

The central save service can coordinate these systems without owning all of their gameplay logic.

[Internal Link: Unity Dependency Injection]

[Internal Link: Unity Game Architecture]


Separate Global Data and Slot Data

Not all data belongs inside a save slot.

A useful structure is:

Global Data
├── Audio settings
├── Graphics settings
├── Language
└── Accessibility

Slot Data
├── Player
├── Inventory
├── Quests
├── World
└── Progression

This prevents switching save slots from unexpectedly changing player preferences.


Save World Changes Instead of the Entire World

Imagine an open-world game containing thousands of chests.

You do not always need to save the full state of every chest.

Instead:

Default World
+
Player Changes

For example:

openedChestIds
destroyedObjectIds
completedPuzzleIds
collectedUniqueItemIds

The game creates the normal world first, then applies those changes.

This approach can make large save systems significantly more manageable.


Handle Removed Content

Suppose version 1 contains:

{
    "itemId": "old_fire_sword",
    "amount": 1
}

Version 2 removes that item.

Your loader needs a policy.

You could:

Ignore the item

Useful if it has little importance.

Replace it

old_fire_sword
→
fire_sword_v2

Refund the player

Convert the removed item into:

Coins
Materials
Replacement item

Save migration often involves game-design decisions, not just code changes.


Keep Save Schema Documentation

For a released game, keep a simple internal changelog.

For example:

Save Version 1
- Initial save format

Save Version 2
- Added difficulty

Save Version 3
- Changed inventory from indexes to stable IDs

Save Version 4
- Added quest states

This becomes extremely useful after multiple years of development.


JsonUtility Limitations

JsonUtility is convenient for simple structured save data, but it is not designed for every possible data model.

You should be aware of limitations such as:

  • Normal dictionaries require special handling
  • Complex polymorphic structures can become awkward
  • Serialization is primarily field-based
  • Highly dynamic JSON structures may require another approach

For many small and medium-sized Unity games, JsonUtility is completely sufficient.

Use another serializer when your project actually requires more flexibility, not simply because another library has more features.


Should You Use Binary Save Files?

Binary data may be smaller and less readable than JSON.

However:

Unreadable ≠ Secure

A binary file can still be modified by someone who understands its format.

JSON has major development advantages:

  • Easy debugging
  • Easy inspection
  • Straightforward migration
  • Easy test data creation
  • Human-readable structure

For many indie projects, those benefits are valuable.


Autosave Best Practices

A good autosave system should answer four questions.


What Triggers the Save?

Possible triggers:

Timer
Checkpoint
Scene transition
Application pause
Quest completion

Is Saving Allowed?

Use lifecycle state such as:

canSave

This prevents saving uninitialized game state.


What Happens if Saving Fails?

Keep a previous backup and log useful debugging information.


What Happens When the Save Format Changes?

Use:

saveVersion
+
migration

These decisions are what turn a basic save script into a maintainable save system.


Recommended Production Save Flow

A stronger save process looks like:

Player Reaches Save Trigger
          ↓
Is Saving Allowed?
          ↓
Capture Runtime State
          ↓
Validate Data
          ↓
Set Current Version
          ↓
Serialize to JSON
          ↓
Write Temporary File
          ↓
Preserve Previous Backup
          ↓
Write New Main Save
          ↓
Complete

Loading:

User Selects Slot
       ↓
Read Main Save
       ↓
Valid?
 ↓ Yes         ↓ No
Migrate        Try Backup
 ↓                ↓
Validate          Valid?
 ↓                ↓
Apply State ←──── Yes

Save System Checklist

Before shipping, verify that:

  • Save files use Application.persistentDataPath
  • Each slot has an independent filename
  • One slot cannot accidentally overwrite another
  • Save files contain a version number
  • Old versions have migration paths
  • Newer unsupported versions fail safely
  • Corrupted JSON does not crash the game
  • A backup or recovery strategy exists
  • Inventory uses stable item IDs
  • Removed items have a migration policy
  • Loaded data is validated
  • Mobile pause behavior has been tested on real devices
  • Saving does not happen every frame
  • Derived values are not unnecessarily persisted
  • Local files are not trusted for competitive multiplayer progression
  • Deleting a slot also removes its backup and temporary files
  • Existing save files are tested before major releases

Recommended Architecture for Larger Projects

As the project grows, you may eventually move toward a structure like:

Assets/
└── Scripts/
    └── SaveSystem/
        ├── Core/
        │   ├── SaveService.cs
        │   ├── SaveFileRepository.cs
        │   └── SaveMigrationService.cs
        │
        ├── Data/
        │   ├── SaveData.cs
        │   ├── PlayerSaveData.cs
        │   ├── InventorySaveData.cs
        │   ├── QuestSaveData.cs
        │   └── WorldSaveData.cs
        │
        ├── Migration/
        │   ├── SaveMigrationV1ToV2.cs
        │   └── SaveMigrationV2ToV3.cs
        │
        └── UI/
            └── SaveSlotMenu.cs

You do not need this level of complexity for a small prototype.

Add architecture when your project actually needs it.

The best save system is not the one with the most classes.

It is the simplest system that protects player progress and remains easy to maintain.


Best Practices Summary

Save Plain Data

Save values, not runtime Unity objects.

Use persistentDataPath

Do not use hard-coded machine-specific folders.

Use Stable IDs

Especially for:

  • Items
  • Skills
  • Quests
  • Persistent world objects

Add Versioning Early

Even if the first version is simply:

1

Keep Backups

Player progress is valuable.

Autosave Intelligently

Avoid saving constantly.

Validate Loaded Data

A file existing does not mean its contents are valid.

Create Migrations Before Changing Released Data

Once players have save files, your save structure should be treated like a persistent schema.

Do Not Trust Local Saves for Multiplayer Economies

The client should not be the authority for valuable online progression.


FAQ

What is the best way to save game data in Unity?

For structured local game progression, a common approach is to create serializable C# data classes, convert them into JSON, and store the files under Application.persistentDataPath.

This works well for player progression, inventory, quests, save slots, and world state.


Should I use JSON or PlayerPrefs?

Use PlayerPrefs for small settings such as:

  • Volume
  • Graphics quality
  • Mouse sensitivity
  • Language

Use JSON or another structured save format for:

  • Player progress
  • Inventory
  • Quests
  • World state
  • Multiple characters
  • Multiple save slots

Where should Unity save files be stored?

Use:

Application.persistentDataPath

This gives your game an appropriate persistent location for the current platform.


How do I create multiple save slots in Unity?

Give each slot a separate file.

For example:

slot_0.json
slot_1.json
slot_2.json

Then pass the selected slot index into your save and load methods.


How often should a Unity game autosave?

There is no perfect interval for every game.

A practical system usually combines:

  • Periodic autosaves
  • Checkpoints
  • Scene transitions
  • Quest completion
  • Application pause

Avoid saving every frame or after every tiny state change.


Can JsonUtility save a List?

Yes, a list can be stored as a field inside a serializable class.

For example:

[Serializable]
public class SaveData
{
    public List<ItemData> items;
}

Can JsonUtility save a Dictionary?

Normal C# dictionaries are not handled as simply as standard serializable fields.

A common approach is to convert dictionary data into serializable lists or use a different serializer if the project requires more complex JSON structures.


How do I prevent old save files from breaking?

Add a version number:

public int saveVersion;

When your save structure changes, migrate older data into the current format.


What happens if a save file becomes corrupted?

Your loader should:

  1. Catch file or deserialization errors.
  2. Avoid crashing.
  3. Reject invalid data.
  4. Try a backup if one exists.

Is JSON secure?

No local save format should be considered authoritative security.

JSON is easy to edit, but binary and encrypted client-side files can also be modified by determined attackers.

For competitive multiplayer games, important progression should normally be validated by a trusted server.


Should I encrypt Unity save files?

Encryption can discourage casual editing or hide readable data.

However, it should not be treated as a complete anti-cheat solution for client-controlled files.


How do I save inventory?

Save stable item IDs and quantities.

For example:

{
    "itemId": "health_potion",
    "amount": 5
}

After loading, use the item ID to find the correct item definition in your database.


Should I save calculated values?

Usually not if they can be reliably recalculated.

Save the minimum canonical state needed to reconstruct the game.


How do I test save migrations?

Keep example save files from each released save version.

For example:

test_save_v1.json
test_save_v2.json
test_save_v3.json

Run all of them through the newest loader and verify that the migrated state is correct.


Conclusion

A reliable Unity save system is much more than a Save() and Load() method.

It needs to survive the real lifecycle of a game.

Players create multiple characters. Games receive updates. Save structures change. Inventory systems expand. Mobile apps move into the background. Files can become corrupted. Old items get removed.

The architecture in this tutorial handles those problems by separating runtime gameplay state from persistent save data.

The full process becomes:

Capture
→ Validate
→ Serialize
→ Backup
→ Save
→ Load
→ Migrate
→ Validate
→ Restore

You now have a Unity 6 save system that supports:

  • JSON serialization
  • Multiple save slots
  • Manual saving
  • Autosaving
  • Mobile pause saving
  • Persistent file locations
  • Save backups
  • Backup recovery
  • Data validation
  • Inventory persistence
  • Save versioning
  • Version migration
  • Unsupported-version protection
  • Scalable project architecture

For a small game, this may already be enough.

For a larger project, keep the same foundation and gradually separate player data, inventory data, quest data, world data, migration logic, and file storage into dedicated systems.

Avatar photo

SayedTurzo

Hi, I'm Sayed Turzo, the founder of Endless Existence and a passionate game developer focused on Roblox, Unity, programming, AI, and game design.I create practical, beginner-friendly tutorials that help aspiring developers build real games using Roblox Studio, Unity, Luau, C#, and modern game development workflows.My goal is to make game development easier to learn through step-by-step guides, best practices, optimization tips, and real-world development experience.Whether you're creating your first Roblox game or building advanced Unity projects, Endless Existence is here to help you become a better game developer.

View Author Profile →

Continue Your Game Development Journey

Explore more practical tutorials on Roblox, Unity, C#, Luau, AI, and Game Development.

Leave a Reply

Your email address will not be published. Required fields are marked *