Skip to main content

📝 Lesson 4.3: Saving and Loading

A game the player can't come back to is only half a game. This lesson makes progress persist between sessions: PlayerPrefs for simple settings, and JSON serialization to write structured save data to disk — reusing the files & JSON skills from the Intermediate C# course, now the Unity way.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Store simple values with PlayerPrefs and know its limits
  • Serialize a [System.Serializable] data class to JSON with JsonUtility
  • Write and read save files at Application.persistentDataPath
  • Build a reusable SaveManager with Save()/Load()
  • Recognize JsonUtility's limitations and security caveats
  • Choose the right persistence tool for settings vs full save games

Estimated Time: 60 minutes

Project: A SaveManager that writes the player's level, score, and position to a JSON file and restores them on load.

In This Lesson

What Persistence Means

Everything so far lives in memory and vanishes when the game closes — the GameManager's score, the player's position, unlocked levels. Persistence means writing that data somewhere durable (the disk) so it can be read back next session.

Two questions shape every save system:

QuestionAnswered by
What format? How is the data turned into bytes?Serialization — here, JSON (or PlayerPrefs' key-value store)
Where does it live? Which file/location on the device?Application.persistentDataPath (a safe, writable folder)

💡 You already know serialization

The Intermediate C# course covered files and JSON. Unity uses the same ideas: convert an object to text, write text to a file, read it back, convert text to an object. The Unity-specific parts are just which helper (JsonUtility) and where to write (persistentDataPath).

PlayerPrefs for Settings

PlayerPrefs is Unity's built-in key-value store for small, simple values — volume, difficulty, a high score. It handles the file for you; you just get and set by string key.

using UnityEngine;

// Save
PlayerPrefs.SetInt("HighScore", 1200);
PlayerPrefs.SetFloat("Volume", 0.8f);
PlayerPrefs.SetString("PlayerName", "Ray");
PlayerPrefs.Save();   // flush to disk (also happens on normal quit)

// Load — the second argument is the default if the key doesn't exist yet.
int highScore = PlayerPrefs.GetInt("HighScore", 0);
float volume  = PlayerPrefs.GetFloat("Volume", 1f);

if (PlayerPrefs.HasKey("PlayerName"))
    Debug.Log(PlayerPrefs.GetString("PlayerName"));

⚠️ PlayerPrefs is for preferences, not save games

It only stores int, float, and string — no structured data. It's stored in plain, easily-edited locations (the registry on Windows), so it's not secure and not meant for large or important data. Use it for settings and trivial values; use JSON files (below) for actual game saves.

💡 Default values matter: always pass a default to GetInt/GetFloat/GetString. On a first run the key doesn't exist, and the default is what a new player gets. PlayerPrefs.DeleteKey("HighScore") or DeleteAll() clears values (handy for a "reset progress" button).

JSON Serialization

Real save data is structured — a level number, a score, a position, a list of unlocked items. The clean approach: put it all in a plain data class, then convert that class to a JSON string with JsonUtility.

First, a serializable data class. The [System.Serializable] attribute tells Unity it can turn this into JSON (and show it in the Inspector):

using System;
using UnityEngine;

[Serializable]
public class SaveData
{
    public int level;
    public int score;
    public Vector3 playerPosition;
    public string[] unlockedItems;
}

Then convert to and from JSON text with two static methods:

SaveData data = new SaveData
{
    level = 3,
    score = 1500,
    playerPosition = player.transform.position,
    unlockedItems = new[] { "sword", "shield" }
};

// Object → JSON string (the 'true' pretty-prints with indentation).
string json = JsonUtility.ToJson(data, true);

// JSON string → object.
SaveData loaded = JsonUtility.FromJson<SaveData>(json);
Debug.Log($"Level {loaded.level}, score {loaded.score}");

📖 What gets serialized?

JsonUtility serializes public fields (and [SerializeField] private ones) of a [Serializable] type — the same rules as the Inspector (Lesson 1.3). It handles primitives, strings, enums, Unity structs like Vector3, arrays, List<T>, and nested [Serializable] classes. It does not serialize properties, static fields, or Dictionary.

The resulting JSON is human-readable text — great for debugging:

{
    "level": 3,
    "score": 1500,
    "playerPosition": { "x": 4.0, "y": 0.0, "z": -2.0 },
    "unlockedItems": ["sword", "shield"]
}

Writing to Disk

JSON is just a string; now write it to a file. The one Unity-specific rule: save to Application.persistentDataPath, a per-user, writable folder that survives app updates and differs per platform (Unity picks the correct location on each device).

using System.IO;   // File, Path — standard .NET (Intermediate C#)
using UnityEngine;

string path = Path.Combine(Application.persistentDataPath, "save.json");

// Write
File.WriteAllText(path, json);

// Read (only if it exists)
if (File.Exists(path))
{
    string loadedJson = File.ReadAllText(path);
    SaveData data = JsonUtility.FromJson<SaveData>(loadedJson);
}
graph LR OBJ["SaveData object
(in memory)"] -->|"JsonUtility.ToJson"| JSON["JSON string"] JSON -->|"File.WriteAllText"| FILE["save.json on disk"] FILE -->|"File.ReadAllText"| JSON2["JSON string"] JSON2 -->|"JsonUtility.FromJson"| OBJ2["SaveData object"] style OBJ fill:#e8f5e9,stroke:#4CAF50,stroke-width:2px style FILE fill:#eff6ff,stroke:#3b82f6,stroke-width:2px style OBJ2 fill:#e8f5e9,stroke:#4CAF50,stroke-width:2px

⚠️ Never hardcode a path — and never use Assets/

Use Application.persistentDataPath, not a literal like "C:/saves/" (won't exist on other machines) and not your project's Assets folder (read-only in a built game). Path.Combine joins folder and filename with the right separator for the platform. To find the folder while testing, Debug.Log(Application.persistentDataPath) and open it.

A SaveManager

Wrap all of this in a single manager (Lesson 4.1) so the rest of your game just calls SaveManager.Instance.Save() / Load() and never touches paths or JSON directly.

using System.IO;
using UnityEngine;

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

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

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

    public void Save(SaveData data)
    {
        string json = JsonUtility.ToJson(data, true);
        File.WriteAllText(SavePath, json);
        Debug.Log($"Saved to {SavePath}");
    }

    public SaveData Load()
    {
        if (!File.Exists(SavePath))
            return null;   // no save yet — caller starts fresh

        string json = File.ReadAllText(SavePath);
        return JsonUtility.FromJson<SaveData>(json);
    }

    public bool HasSave() => File.Exists(SavePath);

    public void DeleteSave()
    {
        if (File.Exists(SavePath)) File.Delete(SavePath);
    }
}

✅ Gathering and applying data

The manager handles storage; something still has to collect the values into a SaveData before saving, and apply them after loading. A common pattern: the GameManager builds a SaveData from the current state, calls SaveManager.Save(data); on load it reads the values back and restores score, level, and player position. Keep "what to save" near the systems that own the data.

💡 When to save: at checkpoints, on level complete, or on quit (OnApplicationQuit / OnApplicationPause on mobile). Avoid saving every frame — disk writes are slow and cause hitches.

Limits & Gotchas

JsonUtility is fast and built-in, but deliberately minimal. Know its edges before you hit them:

LimitationWorkaround
No Dictionary supportStore parallel Lists, or a List of a [Serializable] key-value struct
No polymorphism (can't serialize a base-typed field holding a derived object)Flatten the data, or use a type tag + switch; or use a richer library
Top-level must be an object, not a raw arrayWrap the array in a class with a List/array field
Only fields, not propertiesUse fields in save classes (or back properties with serialized fields)

⚠️ Saves are plain text — not secure

A JSON save file is human-readable and trivially editable — a player can open save.json and set their score to a million. For a single-player game that's often fine. If tampering matters (leaderboards, competitive play), add a checksum/hash, encrypt the data, or validate server-side. Don't rely on obscurity.

💡 Beyond JsonUtility

For features JsonUtility lacks — dictionaries, polymorphism, populating existing objects — the popular Newtonsoft Json.NET (available as a Unity package) is the go-to. There's also FromJsonOverwrite to load values into an existing object rather than creating a new one. And always plan for versioning: add a version field to SaveData now, so a future update can migrate old saves instead of breaking them.

Exercise & Quiz

🏋️ Exercise: Save and Restore the Player

Objective: Persist level, score, and player position to a JSON file, then restore them on the next run.

Instructions:

  1. Create the [Serializable] SaveData class (level, score, Vector3 position) — add a version field for future-proofing.
  2. Write the SaveManager singleton (Section 5) with Save(SaveData), Load(), and HasSave().
  3. On a key press (e.g. F5), build a SaveData from current values and call Save. On F9, Load and apply the values (move the player, set the score).
  4. Debug.Log(Application.persistentDataPath), open the folder, and inspect the pretty-printed save.json.
  5. Stop and restart Play mode, press F9, and confirm the player returns to the saved position with the saved score.

Starter Code:

using System;
using UnityEngine;

[Serializable]
public class SaveData
{
    public int version = 1;
    public int level;
    public int score;
    public Vector3 playerPosition;
}

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

    void Awake()
    {
        // TODO: singleton guard + DontDestroyOnLoad.
    }

    // TODO: Save(SaveData), Load(), HasSave() using JsonUtility + File + persistentDataPath.
}
💡 Hint

Path: Path.Combine(Application.persistentDataPath, "save.json"). Save: File.WriteAllText(path, JsonUtility.ToJson(data, true)). Load: guard with File.Exists, then JsonUtility.FromJson<SaveData>(File.ReadAllText(path)). To apply position: player.transform.position = data.playerPosition;.

✅ Solution
using System;
using System.IO;
using UnityEngine;

[Serializable]
public class SaveData
{
    public int version = 1;
    public int level;
    public int score;
    public Vector3 playerPosition;
}

public class SaveManager : MonoBehaviour
{
    public static SaveManager Instance { get; private set; }
    private string SavePath => Path.Combine(Application.persistentDataPath, "save.json");

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

    public void Save(SaveData data)
    {
        File.WriteAllText(SavePath, JsonUtility.ToJson(data, true));
        Debug.Log($"Saved to {SavePath}");
    }

    public SaveData Load()
    {
        if (!File.Exists(SavePath)) return null;
        return JsonUtility.FromJson<SaveData>(File.ReadAllText(SavePath));
    }

    public bool HasSave() => File.Exists(SavePath);
}
using UnityEngine;

public class SaveTester : MonoBehaviour
{
    [SerializeField] private Transform player;
    [SerializeField] private int level = 1;
    [SerializeField] private int score;

    void Update()
    {
        if (Input.GetKeyDown(KeyCode.F5))
        {
            SaveManager.Instance.Save(new SaveData
            {
                level = level,
                score = score,
                playerPosition = player.position
            });
        }

        if (Input.GetKeyDown(KeyCode.F9))
        {
            SaveData data = SaveManager.Instance.Load();
            if (data != null)
            {
                level = data.level;
                score = data.score;
                player.position = data.playerPosition;
                Debug.Log($"Loaded: level {level}, score {score}");
            }
        }
    }
}

Storage lives in the SaveManager; gathering/applying lives with the systems that own the data. The version field costs nothing now and saves you from broken saves after a future update.

🎯 Quick Quiz

Question 1: Which is the right tool for a full structured save game (level, score, position, inventory)?

Question 2: Where should game save files be written?

Question 3: Which does JsonUtility not serialize out of the box?

Summary

🎉 Key Takeaways

  • Persistence answers two questions: what format (JSON / PlayerPrefs) and where (Application.persistentDataPath).
  • PlayerPrefs stores simple int/float/string settings by key — convenient but not for structured or sensitive data. Always pass a default when reading.
  • For real saves, put data in a [Serializable] class and use JsonUtility.ToJson/FromJson; write with File.WriteAllText to persistentDataPath.
  • Wrap it in a SaveManager singleton so the game calls Save()/Load() without touching paths or JSON.
  • JsonUtility limits: no Dictionary, no polymorphism, fields only, top-level must be an object. Reach for Json.NET when you outgrow it.
  • Saves are plain text — editable and insecure by default. Add a version field for migration; hash/encrypt if tampering matters.

📚 Additional Resources

🚀 What's Next?

That completes Module 4 — Systems and Managers: state, UI, and persistence — the scaffolding of a complete game. Module 5 sharpens quality. Lesson 5.1, Unity Performance and Memory, tackles the GC and per-frame costs we've been flagging all course — caching, avoiding allocations in Update, and object pooling.

🎉 Module 4 complete!

Your game remembers. State, interface, and now saved progress — the loop is whole. Next we make it fast and robust.