Skip to main content

๐Ÿ“ฆ 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] SaveData class that mirrors your game state
  • Convert objects to text with JsonUtility.ToJson and back with FromJson
  • Write and read the file at Application.persistentDataPath with File.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.

flowchart LR subgraph SAVE["Save"] A["Live game state
(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 a List<T> of another [Serializable] type.
  • They're public fields, not properties. JsonUtility serializes fields, and only serializes auto-properties if you add [SerializeField] to their backing field โ€” so fields are simplest here.
  • The nested InventoryEntry struct 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. The SaveSystem knows nothing about your game โ€” it just moves a SaveData to and from disk. GameSaveController is the only script that touches both the save data and your live objects. That separation is what makes SaveSystem droppable 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 parallel Lists, or a List of a small key-value struct (as our InventoryEntry does).
  • No polymorphism. If SaveData has a List<Item> and you put a Weapon : Item in it, only the Item fields 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 why SaveData holds the list rather than being one).
  • No null distinction for value types, and it won't serialize static or const fields.

โš ๏ธ 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.

  1. Create the SaveData and InventoryEntry classes exactly as above (both [Serializable]).
  2. Add the static SaveSystem and the SaveMigrator.
  3. 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).
  4. Add a GameSaveController. Bind F5 to SaveGame() and F9 to LoadGame() (via Input System actions).
  5. 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.
  6. Open the actual file: Debug.Log(Application.persistentDataPath), browse to it, and read your save.json to 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 (including List<T> of serializable structs).
  • JsonUtility.ToJson/FromJson convert object โ†” text; File.WriteAllText/ReadAllText move text โ†” disk at Application.persistentDataPath.
  • Wrap file I/O in try/catch and return sensible defaults when no save exists.
  • Add a saveVersion field from day one and migrate old saves on load.
  • JsonUtility can'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.