Lesson 8: Save, Load, and Progression Systems
Players expect progress to survive restarts. Lesson 7 gave you definitions; this lesson captures runtime state (health, score, level index, unlocked flags) in a small save file you control end to end.
Lesson Objective
By the end of this lesson you will have:
- A
GameSaveDataplain C# model with aformatVersionfield and only serializable primitives and lists SaveService(orGamePersistence) that writes JSON underApplication.persistentDataPath- Load on boot and save on at least two hooks (for example pause → resume and level complete)
Why This Matters
Shipping without saves is fine for a jam, painful for a Steam demo. A minimal JSON pipeline teaches migration, failure handling, and why you should never serialize UnityEngine.Object references directly into a file you ship across builds.
For a deeper article-style pass, read How to Build a Simple Save and Load System in Unity.
Step-by-Step
Step 1 - Define the save schema
Create GameSaveData.cs:
[System.Serializable]
public class GameSaveData
{
public int formatVersion = 1;
public int highestWave;
public int totalScore;
public float playerHealth;
public string currentLevelId; // string id, not scene path magic
}
Rule: Store IDs that map back to ScriptableObject or addressable keys, not direct UnityEngine.Object fields.
Step 2 - Choose serialization
For prototypes, JsonUtility.ToJson / FromJson is enough. For dictionaries or nested graphs, consider Newtonsoft (package) or System.Text.Json on newer runtimes—pick one stack for the whole project.
public static void WriteSave(GameSaveData data, string fileName = "save.json")
{
var path = System.IO.Path.Combine(Application.persistentDataPath, fileName);
var json = JsonUtility.ToJson(data, prettyPrint: true);
System.IO.File.WriteAllText(path, json);
}
Step 3 - Load on startup
- Add a
GameBootstrapobject in your first scene (Lesson 2 structure). - On
Awake, attemptReadSave(). - If the file does not exist, create a default
GameSaveDataand apply starting values from your definitions (Lesson 7).
Verification: Delete the file in Explorer/Finder while the editor is stopped, play again, confirm defaults return.
Step 4 - Save on meaningful events
Call WriteSave when:
- Player hits checkpoint or wave end
- Player opens pause and you auto-save (optional)
- Application quits (
OnApplicationQuitis not reliable on mobile—prefer explicit saves)
Common mistake: Saving every frame. Throttle to events or time (e.g. max once per 30 s).
Step 5 - Wire progression flags
Track unlocked levels or metaprogression as parallel lists:
public System.Collections.Generic.List<string> completedLevels = new();
When loading, disable level buttons in UI (Lesson 6) based on this list.
Step 6 - Versioning and migration
When you change fields, increment formatVersion. On load:
if (data.formatVersion < 2) {
// map old fields → new fields, then set data.formatVersion = 2;
}
Ship with a test save file in QA notes so you catch breaks early.
Step 7 - Security expectations
Players can edit JSON. Never trust save files for competitive integrity. For single-player indies, checksums are optional; server-side truth is required for online games.
Mini Challenge
Add a second save slot (save_a.json, save_b.json) selectable from a debug menu so you can compare before/after balance tweaks without touching PlayerPrefs.
Pro Tips
- Log
persistentDataPathonce in Development Builds so testers know where files land. - Keep save IO on a background thread only if you measure stalls; for small JSON, main thread is usually fine.
- Pair with Unity Addressables remote catalog not updating when remote content and local saves must stay in sync.
Common Mistakes
- Saving Transform or Mesh data accidentally via nested types
JsonUtilitycannot handle. - Using EditorPrefs for runtime player data.
- Changing enum order without migrating integers.
Troubleshooting
File writes succeed but load returns empty defaults
Check you are reading the same fileName and not wrapping JSON twice (string inside string).
Android cannot find file
Confirm you are not writing to StreamingAssets for mutable data; persistentDataPath is the right sandbox.
IL2CPP stripping removes save types
Add [System.Serializable] and avoid reflection-only models without link.xml preserves.
Recap
You defined a versioned save model, wrote JSON to persistentDataPath, and connected progression to UI and gameplay state without binding saves to transient scene references.
Next Lesson Teaser
Lesson 9 adds an audio pipeline and mix pass so combat, UI, and music sit at healthy loudness before VFX polish.
FAQ
PlayerPrefs instead of files?
Fine for tiny settings; poor for large or human-debuggable state.
Encrypt saves?
Optional obfuscation only; assume players can read files.
Cloud saves?
Out of scope here; start with local reliability first.
Related Links
- Lesson 7: Data Model with ScriptableObjects
- How to Build a Simple Save and Load System in Unity
- Unity guide
Treat your save format like a public API—small, explicit fields age better than clever reflection.