Chapter 23

Save System

Persist high scores, player settings, and unlockable items across play sessions using JSON serialization and file I/O.

Why We Need Saving

Right now, every time the player closes the game, everything resets. Their high score? Gone. Collected coins? Gone. Volume settings? Gone. That is a terrible experience. Players expect their progress to persist, and a save system is what makes that happen.

In our infinite runner, we need to save:

  • High Score — The best distance or score the player has achieved.
  • Total Coins — Lifetime coins collected (used as currency for unlocking items).
  • Settings — Music volume, SFX volume, selected quality level.
  • Unlocked Items — Which characters or themes the player has purchased.
  • Statistics — Total games played, total distance run, etc.

PlayerPrefs: The Simple Approach

Unity provides a built-in saving mechanism called PlayerPrefs. It stores key-value pairs of simple types (int, float, string).

PlayerPrefs ExampleC#
// Saving a value
PlayerPrefs.SetInt("HighScore", 1500);
PlayerPrefs.SetFloat("MusicVolume", 0.8f);
PlayerPrefs.SetString("PlayerName", "Runner42");
PlayerPrefs.Save(); // Write to disk

// Loading a value (second parameter is the default if the key does not exist)
int highScore = PlayerPrefs.GetInt("HighScore", 0);
float musicVolume = PlayerPrefs.GetFloat("MusicVolume", 1.0f);
string playerName = PlayerPrefs.GetString("PlayerName", "Player");

// Checking if a key exists
if (PlayerPrefs.HasKey("HighScore"))
{
    Debug.Log("High score exists!");
}

// Deleting a key
PlayerPrefs.DeleteKey("HighScore");

// Deleting ALL saved data
PlayerPrefs.DeleteAll();

Pros and Cons of PlayerPrefs

ProsCons
Extremely simple — one line to save, one to loadOnly stores int, float, string — no lists, objects, or arrays
Works on all platformsStored in platform-specific locations (Windows Registry on Windows, plist on macOS) — hard to manage
Great for quick prototypingNot secure — easily editable by players
No file management neededNo structure — all data is flat key-value pairs
When to Use PlayerPrefs

PlayerPrefs is fine for one or two simple values during prototyping. But for a production game with multiple data types (scores, settings, unlockables), it becomes unwieldy. You end up with dozens of string keys scattered across your codebase, no type safety, and no way to version or migrate your save format. We will use PlayerPrefs only for quick settings (like a "has seen tutorial" flag) and build a proper JSON-based system for everything else.

JSON Serialization: The Production Approach

For a real game, we serialize all save data into a JSON (JavaScript Object Notation) string and write it to a file. JSON is human-readable, easy to debug, and supported natively by Unity via JsonUtility.

What Is JSON?

JSON is a text format for storing structured data. Here is what our save data might look like:

save_data.jsonJSON
{
    "highScore": 15230,
    "totalCoinsCollected": 4820,
    "gamesPlayed": 47,
    "totalDistanceRun": 125600.5,
    "musicVolume": 0.8,
    "sfxVolume": 1.0,
    "unlockedCharacters": ["default", "ninja", "robot"],
    "selectedCharacter": "ninja",
    "lastPlayedDate": "2026-03-07"
}

Every field is clearly named and easy to read. If a player reports a bug, you can ask them to send their save file and immediately understand their state.

The SaveData Class

We define a C# class that mirrors this JSON structure. Unity's JsonUtility can convert between C# objects and JSON strings automatically.

SaveData.csC#
using System;
using System.Collections.Generic;

namespace InfiniteRunner.Data
{
    /// <summary>
    /// Contains all persistent data that is saved to disk.
    /// This class is serialized to JSON by the SaveManager.
    ///
    /// IMPORTANT: All fields must be public (or have [SerializeField])
    /// for JsonUtility to serialize them. We use public fields here
    /// for simplicity since this is a pure data container.
    /// </summary>
    [Serializable]
    public class SaveData
    {
        // ─── Scores ─────────────────────────────────────────────
        /// <summary>The highest score ever achieved.</summary>
        public int highScore;

        /// <summary>Total coins collected across all games (lifetime).</summary>
        public int totalCoinsCollected;

        /// <summary>Coins currently available to spend (not yet spent).</summary>
        public int currentCoins;

        // ─── Statistics ─────────────────────────────────────────
        /// <summary>Total number of games played.</summary>
        public int gamesPlayed;

        /// <summary>Total distance run across all games (in world units).</summary>
        public float totalDistanceRun;

        /// <summary>Longest single run distance.</summary>
        public float longestRun;

        // ─── Settings ───────────────────────────────────────────
        /// <summary>Music volume from 0 (muted) to 1 (full).</summary>
        public float musicVolume;

        /// <summary>Sound effects volume from 0 to 1.</summary>
        public float sfxVolume;

        /// <summary>Quality level index (0 = Low, 1 = Medium, 2 = High).</summary>
        public int qualityLevel;

        // ─── Unlockables ────────────────────────────────────────
        /// <summary>
        /// List of unlocked character IDs. The player starts with "default".
        /// New characters are added when purchased with coins.
        /// </summary>
        public List<string> unlockedCharacters;

        /// <summary>The currently selected character ID.</summary>
        public string selectedCharacter;

        // ─── Meta ───────────────────────────────────────────────
        /// <summary>
        /// Save file version number. Used for migration when the
        /// save format changes between game updates.
        /// </summary>
        public int saveVersion;

        /// <summary>ISO 8601 date string of when the save was last written.</summary>
        public string lastSaveDate;

        // ─── Constructor (defaults for first-time players) ──────

        /// <summary>
        /// Creates a new SaveData with sensible defaults.
        /// This is what first-time players start with.
        /// </summary>
        public SaveData()
        {
            highScore = 0;
            totalCoinsCollected = 0;
            currentCoins = 0;

            gamesPlayed = 0;
            totalDistanceRun = 0f;
            longestRun = 0f;

            musicVolume = 1.0f;
            sfxVolume = 1.0f;
            qualityLevel = 2; // Default to High quality

            unlockedCharacters = new List<string> { "default" };
            selectedCharacter = "default";

            saveVersion = 1;
            lastSaveDate = DateTime.Now.ToString("yyyy-MM-dd");
        }
    }
}
Why [Serializable]?

The [Serializable] attribute tells C# and Unity that this class can be converted to and from a data format (like JSON). Without it, JsonUtility.ToJson() will not see the fields. Also note that JsonUtility requires fields to be public or marked with [SerializeField]. Private fields are ignored.

JsonUtility Limitations

Unity's JsonUtility is fast but limited. It does not support dictionaries, polymorphism, or top-level arrays. For our use case (a single SaveData object with lists), it works perfectly. If you need more advanced JSON features in the future, consider the Newtonsoft.Json package (installable via Unity Package Manager under "com.unity.nuget.newtonsoft-json").

The SaveManager Script

Now we build the manager that handles serialization, file I/O, and provides a clean API for the rest of the game to read and write save data.

SaveManager.csC#
using System;
using System.IO;
using UnityEngine;
using InfiniteRunner.Core;

namespace InfiniteRunner.Data
{
    /// <summary>
    /// Manages saving and loading game data to/from a JSON file.
    /// Access via SaveManager.Instance (Singleton).
    ///
    /// Usage:
    ///   SaveManager.Instance.Data.highScore = 1500;
    ///   SaveManager.Instance.Save();
    ///
    ///   int highScore = SaveManager.Instance.Data.highScore;
    /// </summary>
    public class SaveManager : Singleton<SaveManager>
    {
        // ─── Constants ──────────────────────────────────────────
        // The file name for our save data. Stored in Application.persistentDataPath.
        private const string SAVE_FILE_NAME = "save_data.json";

        // Current save format version. Increment this when you change SaveData fields.
        private const int CURRENT_SAVE_VERSION = 1;

        // ─── Public Properties ──────────────────────────────────

        /// <summary>
        /// The current save data. Read and modify this directly, then call Save().
        /// This is never null — if no save file exists, it holds default values.
        /// </summary>
        public SaveData Data { get; private set; }

        /// <summary>
        /// Whether a save file exists on disk.
        /// Useful for showing "Continue" vs "New Game" on the menu.
        /// </summary>
        public bool SaveExists => File.Exists(GetSavePath());

        // ─── Unity Lifecycle ────────────────────────────────────

        protected override void Awake()
        {
            base.Awake();

            // Load save data immediately on startup.
            // If no save file exists, Data will be initialized with defaults.
            Load();
        }

        private void OnEnable()
        {
            // Auto-save when the game ends
            GameManager.Instance.OnGameStateChanged += HandleGameStateChanged;
        }

        private void OnDisable()
        {
            if (GameManager.Instance != null)
            {
                GameManager.Instance.OnGameStateChanged -= HandleGameStateChanged;
            }
        }

        private void OnApplicationPause(bool pauseStatus)
        {
            // Auto-save when the app is backgrounded (important on mobile!)
            // On Android/iOS, the OS can kill your app at any time after
            // it goes to the background. Save immediately.
            if (pauseStatus)
            {
                Save();
            }
        }

        private void OnApplicationQuit()
        {
            // Auto-save when the application is closing
            Save();
        }

        // ─── Public Methods ─────────────────────────────────────

        /// <summary>
        /// Saves the current Data object to a JSON file on disk.
        /// Call this after modifying Data (e.g., after a game over).
        /// </summary>
        public void Save()
        {
            try
            {
                // Update the save metadata
                Data.lastSaveDate = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
                Data.saveVersion = CURRENT_SAVE_VERSION;

                // Convert the SaveData object to a JSON string.
                // The 'true' parameter enables pretty-printing (indented JSON).
                // Set to 'false' in a shipping build to save file size.
                string json = JsonUtility.ToJson(Data, true);

                // Write the JSON string to a file.
                // File.WriteAllText creates the file if it does not exist,
                // or overwrites it if it does.
                string path = GetSavePath();
                File.WriteAllText(path, json);

                Debug.Log($"[SaveManager] Game saved to: {path}");
            }
            catch (Exception e)
            {
                // Something went wrong (disk full, permissions, etc.)
                // Log the error but do not crash the game.
                Debug.LogError(
                    $"[SaveManager] Failed to save game data: {e.Message}\n" +
                    $"Stack trace: {e.StackTrace}");
            }
        }

        /// <summary>
        /// Loads save data from the JSON file on disk.
        /// If no file exists, creates a new SaveData with defaults.
        /// If the file is corrupted, creates a new SaveData and logs a warning.
        /// </summary>
        public void Load()
        {
            string path = GetSavePath();

            // Case 1: No save file exists (first-time player)
            if (!File.Exists(path))
            {
                Debug.Log("[SaveManager] No save file found. Creating defaults.");
                Data = new SaveData();
                return;
            }

            try
            {
                // Read the entire file content as a string
                string json = File.ReadAllText(path);

                // Validate that we actually got content
                if (string.IsNullOrWhiteSpace(json))
                {
                    Debug.LogWarning(
                        "[SaveManager] Save file is empty. Creating defaults.");
                    Data = new SaveData();
                    return;
                }

                // Deserialize the JSON string back into a SaveData object
                Data = JsonUtility.FromJson<SaveData>(json);

                // Validate that deserialization succeeded
                if (Data == null)
                {
                    Debug.LogWarning(
                        "[SaveManager] Deserialization returned null. " +
                        "Creating defaults.");
                    Data = new SaveData();
                    return;
                }

                // Check for save version migration
                if (Data.saveVersion < CURRENT_SAVE_VERSION)
                {
                    MigrateSaveData(Data);
                }

                // Ensure lists are not null (can happen with old save files
                // that predate the list being added)
                if (Data.unlockedCharacters == null)
                {
                    Data.unlockedCharacters =
                        new System.Collections.Generic.List<string> { "default" };
                }

                Debug.Log(
                    $"[SaveManager] Save data loaded. " +
                    $"High score: {Data.highScore}, " +
                    $"Games played: {Data.gamesPlayed}");
            }
            catch (Exception e)
            {
                // The file exists but is corrupted or unreadable
                Debug.LogError(
                    $"[SaveManager] Failed to load save data: {e.Message}\n" +
                    "Creating fresh save data. Old file will be overwritten on next save.");
                Data = new SaveData();
            }
        }

        /// <summary>
        /// Deletes the save file from disk and resets Data to defaults.
        /// Use this for a "Reset Progress" button in settings.
        /// </summary>
        public void DeleteSave()
        {
            try
            {
                string path = GetSavePath();

                if (File.Exists(path))
                {
                    File.Delete(path);
                    Debug.Log("[SaveManager] Save file deleted.");
                }

                // Reset in-memory data to defaults
                Data = new SaveData();
            }
            catch (Exception e)
            {
                Debug.LogError(
                    $"[SaveManager] Failed to delete save file: {e.Message}");
            }
        }

        // ─── Convenience Methods ────────────────────────────────
        // These wrap common save operations so callers do not need
        // to manipulate Data fields directly.

        /// <summary>
        /// Records the result of a completed game run.
        /// Updates high score, stats, and coins, then saves.
        /// </summary>
        /// <param name="score">The score achieved this run.</param>
        /// <param name="distance">The distance traveled this run.</param>
        /// <param name="coinsCollected">Coins collected this run.</param>
        public void RecordGameResult(int score, float distance, int coinsCollected)
        {
            // Update high score if this run was the best
            if (score > Data.highScore)
            {
                Data.highScore = score;
                Debug.Log($"[SaveManager] New high score: {score}!");
            }

            // Update longest run
            if (distance > Data.longestRun)
            {
                Data.longestRun = distance;
            }

            // Update cumulative statistics
            Data.gamesPlayed++;
            Data.totalDistanceRun += distance;
            Data.totalCoinsCollected += coinsCollected;
            Data.currentCoins += coinsCollected;

            // Save to disk immediately
            Save();
        }

        /// <summary>
        /// Attempts to spend coins on an unlock. Returns true if successful.
        /// </summary>
        /// <param name="characterId">The ID of the character to unlock.</param>
        /// <param name="cost">The coin cost.</param>
        /// <returns>True if the purchase succeeded, false if not enough coins.</returns>
        public bool TryPurchaseCharacter(string characterId, int cost)
        {
            // Check if already unlocked
            if (Data.unlockedCharacters.Contains(characterId))
            {
                Debug.LogWarning(
                    $"[SaveManager] Character '{characterId}' is already unlocked.");
                return false;
            }

            // Check if the player can afford it
            if (Data.currentCoins < cost)
            {
                Debug.Log(
                    $"[SaveManager] Not enough coins. " +
                    $"Need {cost}, have {Data.currentCoins}.");
                return false;
            }

            // Deduct coins and unlock
            Data.currentCoins -= cost;
            Data.unlockedCharacters.Add(characterId);

            Save();
            Debug.Log($"[SaveManager] Unlocked character: {characterId}");
            return true;
        }

        /// <summary>
        /// Updates audio settings and saves.
        /// </summary>
        public void SaveAudioSettings(float musicVolume, float sfxVolume)
        {
            Data.musicVolume = Mathf.Clamp01(musicVolume);
            Data.sfxVolume = Mathf.Clamp01(sfxVolume);
            Save();
        }

        // ─── Private Helpers ────────────────────────────────────

        /// <summary>
        /// Returns the full file path for the save file.
        /// Application.persistentDataPath is a platform-safe directory:
        ///   Windows: C:/Users/USERNAME/AppData/LocalLow/CompanyName/ProductName/
        ///   macOS:   ~/Library/Application Support/CompanyName/ProductName/
        ///   Android: /data/data/com.company.product/files/
        ///   iOS:     /var/mobile/.../Documents/
        /// </summary>
        private string GetSavePath()
        {
            return Path.Combine(Application.persistentDataPath, SAVE_FILE_NAME);
        }

        /// <summary>
        /// Migrates save data from an older version to the current version.
        /// Called when the loaded save file has a lower version number than expected.
        ///
        /// Example: If we add a new field in version 2, we set its default value here
        /// for players upgrading from version 1.
        /// </summary>
        private void MigrateSaveData(SaveData data)
        {
            Debug.Log(
                $"[SaveManager] Migrating save from version {data.saveVersion} " +
                $"to version {CURRENT_SAVE_VERSION}.");

            // Example migration: version 1 -> version 2
            // if (data.saveVersion < 2)
            // {
            //     data.newFieldAddedInV2 = defaultValue;
            //     data.saveVersion = 2;
            // }

            // After all migrations, set to current version
            data.saveVersion = CURRENT_SAVE_VERSION;
        }

        /// <summary>
        /// Handles game state changes. Auto-saves on game over.
        /// </summary>
        private void HandleGameStateChanged(GameState newState)
        {
            if (newState == GameState.GameOver)
            {
                // The actual score recording should be done by the scoring
                // system calling RecordGameResult(). This is a safety net
                // to ensure data is persisted even if something goes wrong.
                Save();
            }
        }
    }
}

Script Breakdown

  • Application.persistentDataPath — This is the correct directory for save files on every platform. On Windows, it is in AppData. On Android, it is in your app's private storage. Unity guarantees this path exists and is writable.
  • JsonUtility.ToJson() — Converts a C# object to a JSON string. The true parameter makes it pretty-printed (readable). Use false in shipping builds to reduce file size.
  • JsonUtility.FromJson<T>() — Converts a JSON string back to a C# object. It matches JSON keys to field names.
  • Error Handling — Every I/O operation is wrapped in try-catch. Disk operations can fail for many reasons (disk full, permissions, corrupted file). We always fall back to default data rather than crashing.
  • Auto-Save — We save on game over, on application pause (critical for mobile!), and on application quit.
  • Version Migration — When you update your game and add new fields to SaveData, old save files will not have those fields. The MigrateSaveData() method detects old versions and sets default values for new fields.

Handling First-Time Players

When someone launches your game for the first time, there is no save file. Our system handles this gracefully:

  1. Load() checks if the file exists with File.Exists().
  2. If the file does not exist, it creates a new SaveData object with the default constructor.
  3. The default constructor sets sensible initial values: score 0, full volume, one unlocked character ("default").
  4. The first save file is created when the game auto-saves (on first game over, pause, or quit).
Detecting First Launch

You can check if this is the player's first time by looking at Data.gamesPlayed == 0. Use this to show a tutorial, skip the main menu and go straight to gameplay, or display a "Welcome" message. This is more reliable than a separate PlayerPrefs flag.

Handling Corrupted Save Files

Save files can get corrupted if the game crashes mid-save, the device runs out of storage, or the player manually edits the file and introduces invalid JSON. Our system handles all of these cases:

Error Handling FlowPseudocode
Load()
  |
  +-- File does not exist?
  |     -> Create default SaveData (first-time player)
  |
  +-- File exists but is empty?
  |     -> Create default SaveData, log warning
  |
  +-- File exists but JSON is invalid?
  |     -> Catch exception, create default SaveData, log error
  |
  +-- File exists, JSON is valid, but SaveData is null?
  |     -> Create default SaveData, log warning
  |
  +-- File exists, JSON is valid, data loaded successfully
        -> Use loaded data
        -> Check saveVersion for migration

The key principle is: never crash, always fall back to defaults. Losing save data is unfortunate, but crashing the game is worse. The player can always play again and earn their progress back.

Backup Strategy (Advanced)

For a commercial game, consider keeping a backup save file. Before writing the main save, copy the existing file to save_data.backup.json. If the main file is corrupted, try loading the backup. This adds complexity but protects against data loss.

What NOT to Save

Not everything should go in the save file. Here is a guideline:

SaveDo NOT Save
High scores and statisticsCurrent score during a run (that is runtime state)
Total coins (lifetime + spendable)Current run coins (reset each run)
Audio/quality settingsCurrent game state (Menu, Playing, Paused)
Unlocked itemsPositions of obstacles or world chunks
Selected character/themeTemporary UI state (which panel is open)

The rule of thumb: save data that must survive between play sessions. Do not save data that is regenerated each time the game starts.

Security Considerations

Our save file is plain text JSON. Any player who finds the file can open it in a text editor and change their high score to 999999 or give themselves infinite coins. For a single-player mobile game, this is usually acceptable — the player is only cheating themselves.

If you need basic tamper protection (e.g., for leaderboards), here are some options:

  • Simple hash check: When saving, compute a hash of the JSON string plus a secret salt, and store it alongside the data. On load, recompute the hash and compare. If they do not match, the file was tampered with.
  • Encryption: Encrypt the JSON string using AES before writing to disk. Decrypt on load. This prevents casual editing but is not uncrackable.
  • Server-side validation: For competitive leaderboards, validate scores on a server. The server can reject impossible scores (e.g., 1 million points after 2 seconds of play time).
Pragmatic Security

Do not over-engineer save security for a tutorial project. Plain JSON is fine for learning. If you ship a competitive game with leaderboards, add server-side validation. Client-side encryption is a speed bump, not a wall — determined cheaters will always find a way.

Using the Save System in Practice

Here is how other scripts in your game interact with the SaveManager:

Usage ExamplesC#
// ─── In your ScoreManager (end of a game run) ──────────────
public void OnGameOver()
{
    int finalScore = CalculateFinalScore();
    float distance = GetDistanceTraveled();
    int coins = GetCoinsCollectedThisRun();

    // Record the result — this updates high score, stats, and saves
    SaveManager.Instance.RecordGameResult(finalScore, distance, coins);
}

// ─── In your UI (displaying high score on main menu) ────────
public void ShowMainMenu()
{
    int highScore = SaveManager.Instance.Data.highScore;
    int totalCoins = SaveManager.Instance.Data.currentCoins;

    highScoreText.text = $"Best: {highScore}";
    coinText.text = $"Coins: {totalCoins}";
}

// ─── In your Settings panel (audio sliders) ─────────────────
public void OnMusicVolumeChanged(float value)
{
    // Update the audio mixer (see Chapter 20)
    // AudioManager.Instance.SetMusicVolume(value);

    // Persist the setting
    SaveManager.Instance.SaveAudioSettings(value, SaveManager.Instance.Data.sfxVolume);
}

// ─── In your Shop screen (buying a character) ───────────────
public void OnBuyCharacterClicked(string characterId, int cost)
{
    bool success = SaveManager.Instance.TryPurchaseCharacter(characterId, cost);

    if (success)
    {
        // Update UI to show the character as unlocked
        RefreshShopUI();
    }
    else
    {
        // Show "Not enough coins" message
        ShowNotEnoughCoinsPopup();
    }
}

// ─── In your Settings panel (reset progress button) ─────────
public void OnResetProgressClicked()
{
    // Show a confirmation dialog first!
    // "Are you sure? This cannot be undone."
    SaveManager.Instance.DeleteSave();
    // Reload the game or return to main menu
}

Setting Up in the Scene

  1. Create an empty GameObject named [SaveManager].
  2. Attach the SaveManager script. It inherits from Singleton, so it will persist across scenes.
  3. Press Play and check the Console — you should see either "No save file found. Creating defaults." or "Save data loaded." with the high score.
  4. To find the save file on disk, add a temporary Debug.Log(Application.persistentDataPath) to see the path. Navigate there and you will find save_data.json after the first save.

Project Files

Project StructureFolder
Assets/
  Scripts/
    Data/
      SaveData.cs          <-- The data container class
      SaveManager.cs       <-- The save/load manager (Singleton)

What We Built

In this chapter, we created a complete save system:

  • A SaveData class that holds all persistent data — scores, stats, settings, and unlockables — with sensible defaults for first-time players.
  • A SaveManager Singleton that serializes SaveData to JSON and writes it to Application.persistentDataPath, with full error handling for corrupted or missing files.
  • Auto-save on game over, app pause, and app quit.
  • Version migration to handle save format changes between game updates.
  • Convenience methods like RecordGameResult() and TryPurchaseCharacter() that encapsulate common operations.

In the next chapter, we will focus on performance and optimization — using the Unity Profiler to find bottlenecks, reducing draw calls, eliminating garbage collection spikes, and ensuring our game runs at a smooth 60 FPS on mobile devices.

Save Your Progress

The save system persists player data. Let's persist our code too.

git add .
git commit -m "Add save system with JSON serialization"
git push

Almost done. Three more chapters.