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
- How a Unity save system works
- JSON vs PlayerPrefs
- Recommended project structure
- Creating the save-data model
- Creating the JSON save system
- Adding multiple save slots
- Connecting gameplay data
- Creating the SaveManager
- Adding autosave
- Saving when the game pauses or closes
- Adding backups
- Save versioning
- Migrating old saves
- Validating loaded data
- Saving inventory correctly
- Testing the save system
- Common mistakes
- Performance and optimization
- Security
- Advanced architecture
- 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
| Feature | PlayerPrefs | JSON |
|---|---|---|
| Audio settings | Excellent | Works |
| Graphics settings | Excellent | Works |
| Player position | Possible | Better |
| Inventory | Awkward | Excellent |
| Multiple save slots | Awkward | Easy |
| Save versioning | Difficult | Easy |
| Human-readable | Limited | Yes |
| Complex data | Poor fit | Good |
| Backups | Awkward | Easy |
| Debugging | Limited | Easy |
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:
- The player runs version 3 of your game.
- Version 3 creates a new save.
- The player installs an older version 2 build.
- 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
- Start a new game.
- Move the player.
- Change health.
- Add coins.
- Add an inventory item.
- Save.
- Stop Play Mode.
- Start again.
- 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:
- Catch file or deserialization errors.
- Avoid crashing.
- Reject invalid data.
- 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.

