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:

  1. A GameSaveData plain C# model with a formatVersion field and only serializable primitives and lists
  2. SaveService (or GamePersistence) that writes JSON under Application.persistentDataPath
  3. 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

  1. Add a GameBootstrap object in your first scene (Lesson 2 structure).
  2. On Awake, attempt ReadSave().
  3. If the file does not exist, create a default GameSaveData and 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 (OnApplicationQuit is 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 persistentDataPath once 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 JsonUtility cannot 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

Treat your save format like a public API—small, explicit fields age better than clever reflection.