๐ฆ Lesson 5.4: A JSON Save/Load System (Mini-Project)
PlayerPrefs stopped at flat values. Now we build the real thing: a save system that captures the player's position, stats, and inventory into a structured JSON file on disk, then restores it exactly. This is the module's capstone โ you'll leave with a reusable SaveSystem you can drop into any project, plus a clear-eyed understanding of where Unity's JsonUtility shines and where it flatly refuses to help.
๐ฏ Learning Objectives
By the end of this lesson, you will be able to:
- Design a
[System.Serializable]SaveDataclass that mirrors your game state - Convert objects to text with
JsonUtility.ToJsonand back withFromJson - Write and read the file at
Application.persistentDataPathwithFile.WriteAllText - Add a version field and migrate older saves
- Work around
JsonUtility's limits (no dictionaries, no polymorphism, top-level must be an object) - Assemble a complete save/load round-trip for position + stats + inventory
Estimated Time: 50 minutes ยท Prerequisite: Lesson 5.3 (PlayerPrefs); ScriptableObjects/serialization from Module 2 help
In This Lesson
The Save/Load Round-Trip
Every save system is the same four-step loop: gather live game state into a plain data object, turn that object into text, write the text to a file โ and reverse the whole chain to load. Hold this picture in your head; the rest of the lesson just fills in each arrow.
(Player, stats, items)"] --> B["SaveData object
[Serializable]"] B --> C["JSON string
JsonUtility.ToJson"] C --> D["save.json on disk
File.WriteAllText"] end subgraph LOAD["Load"] D2["save.json on disk
File.ReadAllText"] --> C2["JSON string"] C2 --> B2["SaveData object
JsonUtility.FromJson"] B2 --> A2["Apply back to
live game state"] end D -.same file.-> D2
Figure 1: The round-trip. Saving flows object โ JSON โ disk; loading flows disk โ JSON โ object, then applies it back to the running game.
๐ Definition
Serialization: converting an in-memory object into a format that can be stored or transmitted (here, a JSON text string), and deserialization is the reverse. JsonUtility is Unity's built-in, fast serializer that uses the same rules as the Inspector โ if a field shows up in the Inspector, it serializes to JSON.
The SaveData Class
The heart of the system is a plain data class โ no MonoBehaviour, no logic โ that holds exactly what you want to persist. It must be marked [System.Serializable] so Unity will serialize it, and its fields must be serializable too.
using System;
using System.Collections.Generic;
using UnityEngine;
[Serializable]
public class SaveData
{
public int saveVersion = 1; // for migrations later
// Player transform (Vector3 IS serializable)
public Vector3 playerPosition;
public float playerRotationY;
// Stats
public int level;
public float health;
public int gold;
// Inventory โ a List of a serializable item struct
public List<InventoryEntry> inventory = new List<InventoryEntry>();
}
[Serializable]
public struct InventoryEntry
{
public string itemId; // e.g. "sword_iron"
public int quantity;
}
Three things make this work:
- Every type used is serializable โ
int,float,string,Vector3(Unity built-in), and aList<T>of another[Serializable]type. - They're public fields, not properties.
JsonUtilityserializes fields, and only serializes auto-properties if you add[SerializeField]to their backing field โ so fields are simplest here. - The nested
InventoryEntrystruct is itself[Serializable], so a whole list of items round-trips cleanly.
โ ๏ธ Properties don't serialize by default
A C# auto-property like public int Gold { get; set; } is ignored by JsonUtility โ it silently saves nothing for it. Use public fields (or [SerializeField] private fields) in your SaveData. This is the same serialization rule the Inspector follows, which you met back in Module 2.
JsonUtility: To & From JSON
With a serializable class, converting to and from JSON is two calls:
// Object -> JSON string. Pass true for pretty (indented) output.
string json = JsonUtility.ToJson(saveData, prettyPrint: true);
// JSON string -> new object.
SaveData loaded = JsonUtility.FromJson<SaveData>(json);
// Or fill an EXISTING object in place (handy for config reloads):
JsonUtility.FromJsonOverwrite(json, existingSaveData);
The SaveData above serializes to text like this:
{
"saveVersion": 1,
"playerPosition": { "x": 12.5, "y": 0.0, "z": -8.25 },
"playerRotationY": 90.0,
"level": 3,
"health": 74.5,
"gold": 250,
"inventory": [
{ "itemId": "sword_iron", "quantity": 1 },
{ "itemId": "potion_health", "quantity": 5 }
]
}
Readable, diff-able, and hand-editable during development. That legibility is a big reason to prefer a JSON file over cramming a blob into PlayerPrefs.
โ Pro Tip
Use prettyPrint: true while developing so you can open the save file and eyeball it. For a shipped build you can drop to false for a smaller, single-line file โ the game reads both identically.
Writing to persistentDataPath
Where does the file go? Never hard-code a path like C:\Saves โ it won't exist on other machines or platforms, and some folders are read-only in a build. Unity gives you a guaranteed-writable, per-user, per-app folder: Application.persistentDataPath.
using System.IO;
using UnityEngine;
string path = Path.Combine(Application.persistentDataPath, "save.json");
Debug.Log(path);
// Windows: C:/Users/<you>/AppData/LocalLow/<Company>/<Product>/save.json
// macOS: ~/Library/Application Support/<Company>/<Product>/save.json
// Android: /storage/emulated/0/Android/data/<package>/files/save.json
Building the path with Path.Combine (not string concatenation) keeps the slashes correct on every OS. Then reading and writing is the standard .NET file API:
File.WriteAllText(path, json); // save
string json = File.ReadAllText(path); // load
bool exists = File.Exists(path); // is there a save?
File.Delete(path); // delete the save
โ ๏ธ persistentDataPath vs. dataPath vs. streamingAssets
Only persistentDataPath is writable at runtime across all platforms. Application.dataPath points inside the installed game (often read-only), and streamingAssetsPath is for shipping read-only files with your build. Save games always go to persistentDataPath.
The Full SaveSystem
Here's the reusable piece: a static SaveSystem that saves, loads, checks, and deletes. Notice the try/catch around file I/O โ disks fail, files get corrupted, and a save system that throws an unhandled exception can soft-lock a game.
using System.IO;
using UnityEngine;
public static class SaveSystem
{
static string Path =>
System.IO.Path.Combine(Application.persistentDataPath, "save.json");
public static void Save(SaveData data)
{
try
{
string json = JsonUtility.ToJson(data, true);
File.WriteAllText(Path, json);
Debug.Log($"Saved to {Path}");
}
catch (System.Exception e)
{
Debug.LogError($"Save failed: {e.Message}");
}
}
public static SaveData Load()
{
if (!File.Exists(Path))
{
Debug.Log("No save found; starting fresh.");
return new SaveData(); // defaults
}
try
{
string json = File.ReadAllText(Path);
SaveData data = JsonUtility.FromJson<SaveData>(json);
return SaveMigrator.Migrate(data); // handle old versions
}
catch (System.Exception e)
{
Debug.LogError($"Load failed, using defaults: {e.Message}");
return new SaveData();
}
}
public static bool HasSave() => File.Exists(Path);
public static void Delete()
{
if (File.Exists(Path)) File.Delete(Path);
}
}
And the glue that reads live state into a SaveData and applies it back โ this is where your specific game objects plug in:
public class GameSaveController : MonoBehaviour
{
[SerializeField] Transform player;
[SerializeField] PlayerStats stats; // your own stats component
[SerializeField] Inventory inventory; // your own inventory component
public void SaveGame()
{
var data = new SaveData
{
playerPosition = player.position,
playerRotationY = player.eulerAngles.y,
level = stats.Level,
health = stats.Health,
gold = stats.Gold,
inventory = inventory.ToEntries() // returns List<InventoryEntry>
};
SaveSystem.Save(data);
}
public void LoadGame()
{
SaveData data = SaveSystem.Load();
player.position = data.playerPosition;
player.eulerAngles = new Vector3(0f, data.playerRotationY, 0f);
stats.Set(data.level, data.health, data.gold);
inventory.FromEntries(data.inventory);
}
}
๐ก Keep gathering and applying in one place. TheSaveSystemknows nothing about your game โ it just moves aSaveDatato and from disk.GameSaveControlleris the only script that touches both the save data and your live objects. That separation is what makesSaveSystemdroppable into your next project unchanged.
Versioning Your Saves
Ship your game, players make saves โ then in a patch you add a new stat or rename a field. Old save files still exist on their disks. Without a plan, loading one either crashes or silently drops data. The fix is the saveVersion field we added on day one.
public static class SaveMigrator
{
public const int CurrentVersion = 2;
public static SaveData Migrate(SaveData data)
{
// v1 had no "gold" field -> JsonUtility leaves it at 0.
if (data.saveVersion < 2)
{
data.gold = 100; // grandfather old players a starter purse
data.saveVersion = 2;
}
// future: if (data.saveVersion < 3) { ... }
data.saveVersion = CurrentVersion;
return data;
}
}
Because JsonUtility simply ignores JSON fields that don't exist on the class and leaves missing fields at their defaults, adding a field is safe โ the migrator's job is to fill in sensible values for saves made before that field existed.
โ Pro Tip
Add the saveVersion field before you ship, even if it's always 1 at first. Retrofitting versioning after players already have un-versioned saves is far more painful. It costs one int now and saves you a support nightmare later.
JsonUtility's Gotchas
JsonUtility is fast and built-in, but it is deliberately minimal. Design your SaveData around these hard limits:
- No
Dictionary<K,V>. Dictionaries don't serialize. Store two parallelLists, or aListof a small key-value struct (as ourInventoryEntrydoes). - No polymorphism. If
SaveDatahas aList<Item>and you put aWeapon : Itemin it, only theItemfields are saved โ the subclass is flattened. Use an enum "type" field + one flat struct, or a different serializer. - Top level must be an object.
JsonUtility.ToJson(myList)won't work โ wrap the list in a class (that's whySaveDataholds the list rather than being one). - No
nulldistinction for value types, and it won't serializestaticorconstfields.
โ ๏ธ When to reach for a different tool
If your save genuinely needs dictionaries, polymorphic type hierarchies, or references between objects, JsonUtility will fight you. That's the signal to bring in Newtonsoft Json.NET (available as the com.unity.nuget.newtonsoft-json package via Package Manager), which handles all three. For the common case โ flat stats, a position, a list of items โ JsonUtility is faster and needs no package.
๐ A word on security. A JSON file is plain text a player can open and edit โ same caveat as PlayerPrefs. For a single-player game that's usually fine (let them cheat their own save). For anything competitive or monetized, the authoritative state must live on a server; never trust a local save for values that matter.
Mini-Project: Save & Restore the Player
Time to tie Module 5 together. You'll build a scene where the player can walk around, pick up items, take damage โ then save, quit, relaunch, and load back into exactly the state they left.
๐๏ธ Build it
Objective: A working save/load round-trip for position + stats + inventory, backed by a JSON file.
- Create the
SaveDataandInventoryEntryclasses exactly as above (both[Serializable]). - Add the static
SaveSystemand theSaveMigrator. - Make a simple scene: a movable player (reuse your Input System controller from Module 3), a couple of pickup items that add to a small inventory, and a stat or two you can change at runtime (health, gold).
- Add a
GameSaveController. Bind F5 toSaveGame()and F9 toLoadGame()(via Input System actions). - Move the player, collect items, spend gold, press F5. Then move somewhere else and press F9 โ you should snap back to the saved position and inventory.
- Open the actual file:
Debug.Log(Application.persistentDataPath), browse to it, and read yoursave.jsonto confirm it matches Figure 1's structure.
๐ก Hint: my inventory loads empty
Most likely your inventory fields are C# properties, which JsonUtility ignores โ switch them to public fields (or [SerializeField] private fields). Also confirm InventoryEntry itself carries [Serializable]; a list of a non-serializable type saves as an empty array.
๐ก Hint: the file never appears on disk
Check the path you logged actually exists, wrap the write in the try/catch so any exception is visible in the Console, and remember File.WriteAllText creates the file but not missing parent folders โ persistentDataPath always exists, so use it directly rather than inventing subfolders you never created.
โ Success check
Pressing F5 writes a readable save.json to persistentDataPath. After fully quitting a build and relaunching, F9 restores the player's position, rotation, health, gold, and every inventory item with correct quantities. Deleting the file and loading yields clean defaults with no errors.
๐๏ธ Stretch goal: three save slots + versioning
Generalize SaveSystem to take a slot index (save_0.json, save_1.json, โฆ) and build a small UI listing each slot with its saved level and gold (read by loading each file's header). Then bump CurrentVersion to 3, add a new field, and prove the migrator upgrades a version-2 file on load without losing data.
๐ฏ Quick Quiz
Question 1: Why must your SaveData use public fields rather than auto-properties?
Question 2: Where should a save file be written so it works on every platform?
Question 3: Your SaveData needs a lookup of itemId โ quantity. What's the JsonUtility-friendly approach?
Summary
๐ Key Takeaways
- A save system is a round-trip: live state โ
SaveDataโ JSON โ disk, and back again. - Mark your data class
[System.Serializable]and use public fields of serializable types (includingList<T>of serializable structs). JsonUtility.ToJson/FromJsonconvert object โ text;File.WriteAllText/ReadAllTextmove text โ disk atApplication.persistentDataPath.- Wrap file I/O in
try/catchand return sensible defaults when no save exists. - Add a
saveVersionfield from day one and migrate old saves on load. JsonUtilitycan't do dictionaries or polymorphism and needs an object at the top level โ reach for Newtonsoft Json.NET when you truly need those.
๐ What's Next?
You've closed out Module 5: your game can move between scenes smoothly and remember everything that matters. Next we shift into the Systems Track and give the world a brain. In Lesson 6.1: The NavMesh: Baking & Agents you'll install the AI Navigation package, bake a walkable surface, and send your first agent chasing a destination.
๐ฆ You have a real save system now
A serializable data class, JsonUtility, and a writable path add up to progress that sticks. Drop SaveSystem into any future project โ you built it to be reusable.