📝 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
PlayerPrefsand know its limits - Serialize a
[System.Serializable]data class to JSON withJsonUtility - Write and read save files at
Application.persistentDataPath - Build a reusable
SaveManagerwithSave()/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:
| Question | Answered 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 toGetInt/GetFloat/GetString. On a first run the key doesn't exist, and the default is what a new player gets.PlayerPrefs.DeleteKey("HighScore")orDeleteAll()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);
}
(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/OnApplicationPauseon 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:
| Limitation | Workaround |
|---|---|
No Dictionary support | Store 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 array | Wrap the array in a class with a List/array field |
| Only fields, not properties | Use 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:
- Create the
[Serializable] SaveDataclass (level, score,Vector3position) — add aversionfield for future-proofing. - Write the
SaveManagersingleton (Section 5) withSave(SaveData),Load(), andHasSave(). - On a key press (e.g.
F5), build aSaveDatafrom current values and callSave. OnF9,Loadand apply the values (move the player, set the score). Debug.Log(Application.persistentDataPath), open the folder, and inspect the pretty-printedsave.json.- 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/stringsettings 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 useJsonUtility.ToJson/FromJson; write withFile.WriteAllTexttopersistentDataPath. - Wrap it in a
SaveManagersingleton so the game callsSave()/Load()without touching paths or JSON. JsonUtilitylimits: noDictionary, 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
versionfield for migration; hash/encrypt if tampering matters.
📚 Additional Resources
- Scripting Reference — JsonUtility
- Scripting Reference — PlayerPrefs
- Scripting Reference — Application.persistentDataPath
- Manual — JSON Serialization
🚀 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.