agentleFS
Nice-Wolf-StudioNice-Wolf-Studio/unity-claude-skills/skills/unity-save-system/SKILL.md

unity-save-system

Skill32 starsChanged 59 days agoMITView on GitHub

Unity save/load system architecture. Serialization format selection, save data DTOs, versioning and migration, cloud sync, PlayerPrefs scoping, auto-save strategies, mobile persistence. DECISION format: WHEN/DECISION/SCAFFOLD/GOTCHA. Based on Unity 6.3 LTS.

The file

We split this skill into the rules it gives an agent

7 rules. About 0 are common, 7 are distinctive, and 0 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈7

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 0

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    WHEN: Choosing how to serialize save data to disk

    line 21
  2. Distinctive:

    WHEN: Deciding what to serialize and how to structure it

    line 80
  3. Distinctive:

    WHEN: Choosing where to write save files

    line 131
  4. Distinctive:

    WHEN: Using PlayerPrefs for settings and small persistent values

    line 155
  5. Distinctive:

    WHEN: Save format changes between game updates (fields added, renamed, restructured)

    line 206
  6. Distinctive:

    WHEN: Save data should sync across devices or persist beyond local storage

    line 269
  7. Distinctive:

    WHEN: Deciding when to trigger saves

    line 308

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Save/Load Systems -- Decision Patterns

Prerequisite skills: unity-lifecycle (OnApplicationPause, OnApplicationQuit, quit sequence), unity-data-driven (JSON serialization, versioning), unity-packages-services (Cloud Save API), unity-async-patterns (BackgroundThreadAsync)

These patterns address the most common save system failure: Claude uses PlayerPrefs for everything, produces brittle serialization with no versioning, and ignores mobile-specific persistence requirements.


PATTERN: Serialization Format Selection

WHEN: Choosing how to serialize save data to disk

DECISION:

  • JSON (JsonUtility) -- Human-readable, debuggable, small save files. No dictionary/polymorphism. Best default for most games.
  • JSON (Newtonsoft) -- Full JSON features (dictionaries, polymorphism, LINQ). Slightly larger dependency. Best when save data is complex.
  • Binary (custom or MessagePack) -- Performance-critical, large saves, anti-cheat (harder to edit). Not human-readable. Best for competitive games or very large worlds.
  • BinaryFormatter -- NEVER USE. Security vulnerability (arbitrary code execution on deserialization). Deprecated by Unity and Microsoft.

SCAFFOLD (JSON save/load):

using System.IO;
using UnityEngine;

public static class SaveFileIO
{
    public static void SaveJson<T>(T data, string fileName)
    {
        string path = GetSavePath(fileName);
        string json = JsonUtility.ToJson(data, prettyPrint: true);

        // Write to temp file first, then rename (atomic write)
        string tempPath = path + ".tmp";
        File.WriteAllText(tempPath, json);
        if (File.Exists(path))
            File.Replace(tempPath, path, path + ".bak");
        else
            File.Move(tempPath, path);
    }

    public static T LoadJson<T>(string fileName) where T : new()
    {
        string path = GetSavePath(fileName);
        if (!File.Exists(path)) return new T();

        string json = File.ReadAllText(path);
        return JsonUtility.FromJson<T>(json);
    }

    public static bool SaveExists(string fileName) =>
        File.Exists(GetSavePath(fileName));

    public static void DeleteSave(string fileName)
    {
        string path = GetSavePath(fileName);
        if (File.Exists(path)) File.Delete(path);
        if (File.Exists(path + ".bak")) File.Delete(path + ".bak");
    }

    static string GetSavePath(string fileName) =>
        Path.Combine(Application.persistentDataPath, fileName);
}

GOTCHA: BinaryFormatter is a security hole -- deserialization can execute arbitrary code. Unity and Microsoft have deprecated it. If you find it in existing code, replace it immediately. JsonUtility cannot serialize dictionaries -- convert to List<SerializableKeyValue<K,V>> or use Newtonsoft. The atomic write pattern (write to .tmp, rename) prevents data corruption if the app crashes mid-write.


PATTERN: Save Data Architecture (DTO Pattern)

WHEN: Deciding what to serialize and how to structure it

DECISION:

  • Single monolithic save -- One SaveData class with everything. Load/save is atomic. Best for small games with fast save/load.
  • Per-system save files -- PlayerSave.json, InventorySave.json, WorldSave.json. Partial load, smaller writes, easier to migrate individual systems.

SCAFFOLD (ISaveable interface):

// Save Data Transfer Object -- plain C# class, NOT MonoBehaviour
[System.Serializable]
public class GameSaveData
{
    public int version = 1;
    public PlayerSaveData player;
    public InventorySaveData inventory;
    public WorldSaveData world;
}

[System.Serializable]
public class PlayerSaveData
{
    public float[] position = new float[3]; // Vector3 as array for JSON compat
    public int health;
    public int level;
    public int xp;

    public void FromPlayer(Transform t, int hp, int lvl, int xp)
    {
        position[0] = t.position.x;
        position[1] = t.position.y;
        position[2] = t.position.z;
        health = hp; level = lvl; this.xp = xp;
    }

    public Vector3 GetPosition() => new(position[0], position[1], position[2]);
}

// Systems implement ISaveable to participate in save/load
public interface ISaveable
{
    void GatherSaveData(GameSaveData data);  // Write state to DTO
    void ApplySaveData(GameSaveData data);   // Read state from DTO
}

GOTCHA: Save DTOs must be plain C# classes with [System.Serializable], NOT MonoBehaviours or ScriptableObjects. They contain only serializable types (primitives, arrays, lists, nested [Serializable] classes). Unity types like Vector3 serialize fine with JsonUtility but NOT with Newtonsoft without a custom converter -- use float[] for portability.


PATTERN: Save File Location

WHEN: Choosing where to write save files

DECISION:

  • Application.persistentDataPath (correct default) -- Writable, survives app updates, platform-appropriate. Use for all save data.
  • Application.streamingAssetsPath -- READ-ONLY in builds. Never write here. For bundled read-only data only.
  • PlayerPrefs -- Key-value only. For settings/preferences. NOT for game state.

SCAFFOLD:

// Always use Path.Combine -- never concatenate with "/"
string savePath = Path.Combine(Application.persistentDataPath, "saves", "slot1.json");

// Ensure directory exists before writing
string dir = Path.GetDirectoryName(savePath);
if (!Directory.Exists(dir))
    Directory.CreateDirectory(dir);

GOTCHA: persistentDataPath locations differ per platform: AppData/LocalLow/<company>/<product> (Windows), ~/Library/Application Support/<bundleID> (macOS), internal storage (Android/iOS). On WebGL, it uses IndexedDB (async, may fail in private browsing). Always use Path.Combine -- forward slashes work on macOS/Linux but Path.Combine is cross-platform safe.


PATTERN: PlayerPrefs Scoping

WHEN: Using PlayerPrefs for settings and small persistent values

DECISION:

  • Direct PlayerPrefs calls -- Under 10 settings, minimal project. Simple.
  • Wrapper class -- 10+ settings, need type safety, change events, defaults. Prevents key collision.

SCAFFOLD (Settings wrapper):

public static class GameSettings
{
    // Prefixed keys prevent collision between systems
    private const string Prefix = "settings.";

    public static float MasterVolume
    {
        get => PlayerPrefs.GetFloat(Prefix + "masterVolume", 1f);
        set { PlayerPrefs.SetFloat(Prefix + "masterVolume", value); OnSettingsChanged?.Invoke(); }
    }

    public static bool Fullscreen
    {
        get => PlayerPrefs.GetInt(Prefix + "fullscreen", 1) == 1;
        set { PlayerPrefs.SetInt(Prefix + "fullscreen", value ? 1 : 0); OnSettingsChanged?.Invoke(); }
    }

    public static int QualityLevel
    {
        get => PlayerPrefs.GetInt(Prefix + "quality", QualitySettings.GetQualityLevel());
        set { PlayerPrefs.SetInt(Prefix + "quality", value); OnSettingsChanged?.Invoke(); }
    }

    public static event Action OnSettingsChanged;

    public static void Save() => PlayerPrefs.Save();

    public static void ResetToDefaults()
    {
        PlayerPrefs.DeleteKey(Prefix + "masterVolume");
        PlayerPrefs.DeleteKey(Prefix + "fullscreen");
        PlayerPrefs.DeleteKey(Prefix + "quality");
        OnSettingsChanged?.Invoke();
    }
}

GOTCHA: PlayerPrefs keys are global strings -- no namespacing. Use prefixed keys ("audio.masterVolume", "video.fullscreen") to prevent collisions. PlayerPrefs has no bool type -- use int (0/1). On Windows, PlayerPrefs stores to the Windows Registry -- avoid large values. PlayerPrefs.Save() is automatic on OnApplicationQuit but call it manually after important changes on mobile (app may be killed without quit).


PATTERN: Save Versioning and Migration

WHEN: Save format changes between game updates (fields added, renamed, restructured)

DECISION:

  • Version field + sequential migrators -- For breaking changes. Store int version in save. On load, run migration chain: v1 -> v2 -> v3 -> current.
  • Tolerant reader -- For additive-only changes. New fields get defaults, removed fields are ignored. Works with JsonUtility automatically.

SCAFFOLD (Migration chain):

public static class SaveMigrator
{
    private static readonly int CurrentVersion = 3;

    public static GameSaveData LoadAndMigrate(string json)
    {
        // Parse version from raw JSON first
        var versionCheck = JsonUtility.FromJson<VersionOnly>(json);
        int version = versionCheck.version;

        if (version == CurrentVersion)
            return JsonUtility.FromJson<GameSaveData>(json);

        // Sequential migration using Newtonsoft for raw JSON manipulation
        var jObj = Newtonsoft.Json.Linq.JObject.Parse(json);

        if (version < 2) MigrateV1ToV2(jObj);
        if (version < 3) MigrateV2ToV3(jObj);

        jObj["version"] = CurrentVersion;
        return Newtonsoft.Json.JsonConvert.DeserializeObject<GameSaveData>(jObj.ToString());
    }

    static void MigrateV1ToV2(Newtonsoft.Json.Linq.JObject data)
    {
        // v2 moved "playerX/Y/Z" into "player.position" array
        var player = (Newtonsoft.Json.Linq.JObject)data["player"];
        if (player != null && player["playerX"] != null)
        {
            float x = (float)player["playerX"];
            float y = (float)player["playerY"];
            float z = (float)player["playerZ"];
            player["position"] = new Newtonsoft.Json.Linq.JArray(x, y, z);
            player.Remove("playerX"); player.Remove("playerY"); player.Remove("playerZ");
        }
    }

    static void MigrateV2ToV3(Newtonsoft.Json.Linq.JObject data)
    {
        // v3 added inventory system (default to empty)
        if (data["inventory"] == null)
            data["inventory"] = Newtonsoft.Json.Linq.JObject.FromObject(new InventorySaveData());
    }

    [System.Serializable]
    private class VersionOnly { public int version; }
}

GOTCHA: Migration must work on raw JSON (before deserializing to the new C# type) because the old type definition may no longer exist in code. Use Newtonsoft JObject for structural changes. Each migration step is a function from version N to N+1 -- compose them sequentially. Test migrations with saved JSON from each version (store test fixtures).


PATTERN: Cloud Save Integration

WHEN: Save data should sync across devices or persist beyond local storage

DECISION:

  • Unity Cloud Save (UGS) -- Managed service, key-value or file storage, 200KB free per player. See unity-packages-services for API. Best for most indie/mid-size games.
  • Custom backend -- Full control, unlimited storage, custom conflict resolution. You build and maintain it.

SCAFFOLD (Local-first with cloud sync):

public class SaveManager : MonoBehaviour
{
    public async Awaitable Save(GameSaveData data)
    {
        // Always save locally first (fast, reliable)
        SaveFileIO.SaveJson(data, "savegame.json");

        // Then sync to cloud in background (best-effort)
        try
        {
            var cloudData = new Dictionary<string, object>
            {
                { "savegame", JsonUtility.ToJson(data) }
            };
            await Unity.Services.CloudSave.CloudSaveService.Instance
                .Data.Player.SaveAsync(cloudData);
        }
        catch (Exception e)
        {
            Debug.LogWarning($"Cloud save failed (local save preserved): {e.Message}");
        }
    }
}

GOTCHA: Cloud saves must handle offline play. Always save locally first, sync to cloud in the background. Handle CloudSaveException gracefully -- network failures should never prevent local play or crash the game. For conflict resolution, "last-write-wins" is simplest; use timestamps if you need merge logic.


PATTERN: Auto-Save Strategy

WHEN: Deciding when to trigger saves

DECISION:

  • Checkpoint-based -- Save at specific game events (level complete, rest point, manual save). Explicit, designer-controlled. Traditional approach.
  • Periodic auto-save -- Save every N seconds or on scene transition. Safety net against crashes. Can be combined with checkpoints.
  • Event-driven -- Save whenever significant state changes (item acquired, quest completed). Granular but more I/O.

SCAFFOLD (Auto-save + mobile safety):

public class AutoSaveManager : MonoBehaviour
{
    [SerializeField] private float autoSaveInterval = 60f; // seconds
    private float _timeSinceLastSave;

    void Update()
    {
        _timeSinceLastSave += Time.unscaledDeltaTime;
        if (_timeSinceLastSave >= autoSaveInterval)
        {
            _timeSinceLastSave = 0f;
            _ = PerformAutoSave(); // Fire-and-forget with error handling
        }
    }

    // Mobile: save on app background (OnApplicationQuit may not fire)
    void OnApplicationPause(bool paused)
    {
        if (paused)
            _ = PerformAutoSave();
    }

    void OnApplicationQuit()
    {
        // Synchronous save on quit (async may not complete)
        var data = GatherSaveData();
        SaveFileIO.SaveJson(data, "autosave.json");
    }

    async Awaitable PerformAutoSave()
    {
        try
        {
            await Awaitable.BackgroundThreadAsync();
            var data = GatherSaveData();
            SaveFileIO.SaveJson(data, "autosave.json");
            await Awaitable.MainThreadAsync();
        }
        catch (Exception e)
        {
            Debug.LogError($"Auto-save failed: {e.Message}");
        }
    }

    GameSaveData GatherSaveData()
    {
        var data = new GameSaveData();
        // Gather from all ISaveable systems
        return data;
    }
}

GOTCHA: Saving is I/O-bound. On mobile, use OnApplicationPause(true) -- OnApplicationQuit may not fire when the OS kills the app. In OnApplicationQuit, use synchronous I/O (async may not complete before the process exits). Never save in Update synchronously -- use BackgroundThreadAsync or a timer. Cross-ref: unity-lifecycle covers the quit sequence.


Anti-Patterns

Anti-PatternProblemFix
BinaryFormatterSecurity vulnerability, deprecatedUse JsonUtility or Newtonsoft
Game state in PlayerPrefsNo structure, no versioning, key collisionUse JSON files + persistentDataPath
Saving MonoBehaviour directlyNot serializable, couples save to sceneUse plain C# DTOs with [Serializable]
No version field in save dataCannot migrate when format changesAlways include int version
Synchronous save in UpdateFrame hitches, especially on mobileUse BackgroundThreadAsync or timer
No backup/atomic writeCrash during write corrupts saveWrite to .tmp, rename atomically

Related Skills

  • unity-lifecycle -- OnApplicationPause, OnApplicationQuit, quit sequence timing
  • unity-packages-services -- Unity Cloud Save API (do not duplicate)
  • unity-async-patterns -- BackgroundThreadAsync for async I/O
  • unity-data-driven -- JsonUtility vs Newtonsoft, versioning patterns for config data

Additional Resources

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.