# Save System

> Save/load patterns — ISaveable interface, JSON serialization, save file management, scene persistence, cloud sync prep. Load when implementing save functionality.

- **Type:** Skill
- **Install:** `agentstack add skill-xeldaralz-everything-claude-unity-save-system`
- **Verified:** Yes — security-reviewed for prompt injection and unsafe behavior
- **Seller:** [XeldarAlz](https://agentstack.voostack.com/s/xeldaralz)
- **Installs:** 0
- **Category:** [AI & ML](https://agentstack.voostack.com/c/ai-and-ml)
- **Latest version:** 0.1.0
- **License:** MIT
- **Upstream author:** [XeldarAlz](https://github.com/XeldarAlz)
- **Source:** https://github.com/XeldarAlz/everything-claude-unity/tree/main/.claude/skills/gameplay/save-system
- **Website:** https://github.com/XeldarAlz/everything-claude-unity#readme

## Install

```sh
agentstack add skill-xeldaralz-everything-claude-unity-save-system
```

Requires the [AgentStack CLI](https://agentstack.voostack.com/docs/cli). Works with Claude Code, Cursor, and any MCP-compatible agent.

## About

# Save/Load System

Patterns for persisting game state to disk: an ISaveable interface for components that need persistence, a central SaveManager that orchestrates capture and restore, JSON serialization, save slot management, and preparation for cloud sync.

## ISaveable Interface

Every component that needs to save state implements this interface. The SaveManager discovers all ISaveable objects in the scene and calls them during save/load.

```csharp
/// 
/// Implement on any MonoBehaviour that needs to persist state across saves.
/// 
public interface ISaveable
{
    /// 
    /// Unique key for this saveable. Must be stable across sessions.
    /// Recommended format: "{scene}_{gameobject}_{component}" or a GUID.
    /// 
    string SaveKey { get; }

    /// 
    /// Capture current state as a serializable object.
    /// Return a plain C# class or struct (no MonoBehaviour, no ScriptableObject).
    /// 
    object CaptureState();

    /// 
    /// Restore state from a previously captured object.
    /// Cast the object to the expected type.
    /// 
    void RestoreState(object state);
}
```

### Example: Saveable Health Component

```csharp
using UnityEngine;

public class Health : MonoBehaviour, ISaveable
{
    [SerializeField] private int maxHealth = 100;
    [SerializeField] private string saveKey;

    private int _currentHealth;

    public string SaveKey => saveKey;

    private void Awake()
    {
        _currentHealth = maxHealth;
    }

    [System.Serializable]
    private struct HealthSaveData
    {
        public int currentHealth;
        public int maxHealth;
    }

    public object CaptureState()
    {
        return new HealthSaveData
        {
            currentHealth = _currentHealth,
            maxHealth = maxHealth
        };
    }

    public void RestoreState(object state)
    {
        if (state is HealthSaveData data)
        {
            _currentHealth = data.currentHealth;
            maxHealth = data.maxHealth;
        }
    }
}
```

### Generating Stable Save Keys

The save key must be the same every time the game runs. Options:

1. **Manual string** (simplest): Assign in Inspector. Works for unique objects like "player_health".
2. **GUID component:** Add a `SaveableEntity` MonoBehaviour with a `[SerializeField] private string uniqueId` that generates a GUID in `Reset()` (called when the component is first added in the editor). This auto-generates stable IDs.

```csharp
using UnityEngine;

public class SaveableEntity : MonoBehaviour
{
    [SerializeField] private string uniqueId;

    public string UniqueId => uniqueId;

    // Called in editor when component is first added
    private void Reset()
    {
        uniqueId = System.Guid.NewGuid().ToString();
    }
}
```

---

## Save Data Structure

A single save file contains all captured state, plus metadata.

```csharp
using System;
using System.Collections.Generic;

[Serializable]
public class SaveData
{
    public int saveVersion = 1;
    public string timestamp;
    public string sceneName;
    public float playTime;

    // All saveable state, keyed by ISaveable.SaveKey
    // Values are JSON strings (serialized individually per saveable)
    public Dictionary stateEntries = new();
}
```

Using `Dictionary` where values are JSON strings (rather than `Dictionary`) avoids polymorphic serialization issues with `JsonUtility`. Each ISaveable's state is serialized independently.

---

## Save Manager

The central orchestrator. Finds all ISaveable components, serializes their state, and writes to disk.

```csharp
using System;
using System.Collections.Generic;
using System.IO;
using UnityEngine;
using UnityEngine.SceneManagement;

public class SaveManager : MonoBehaviour
{
    public static SaveManager Instance { get; private set; }

    [SerializeField] private int maxSaveSlots = 3;

    private float _sessionStartTime;

    public event Action OnSaveCompleted;
    public event Action OnLoadCompleted;

    private void Awake()
    {
        if (Instance != null && Instance != this)
        {
            Destroy(gameObject);
            return;
        }
        Instance = this;
        DontDestroyOnLoad(gameObject);
        _sessionStartTime = Time.time;
    }

    // --- File Paths ---

    private string GetSaveFolderPath()
    {
        return Path.Combine(Application.persistentDataPath, "Saves");
    }

    private string GetSaveFilePath(int slot)
    {
        return Path.Combine(GetSaveFolderPath(), $"Save{slot}.json");
    }

    private string GetAutoSaveFilePath()
    {
        return Path.Combine(GetSaveFolderPath(), "AutoSave.json");
    }

    // --- Save ---

    public void Save(int slot)
    {
        SaveToFile(GetSaveFilePath(slot));
    }

    public void AutoSave()
    {
        SaveToFile(GetAutoSaveFilePath());
    }

    private void SaveToFile(string path)
    {
        var saveData = new SaveData
        {
            saveVersion = 1,
            timestamp = DateTime.Now.ToString("o"),
            sceneName = SceneManager.GetActiveScene().name,
            playTime = Time.time - _sessionStartTime
        };

        // Find all saveables in the scene
        var saveables = FindAllSaveables();

        foreach (var saveable in saveables)
        {
            try
            {
                object state = saveable.CaptureState();
                string json = JsonUtility.ToJson(state);
                saveData.stateEntries[saveable.SaveKey] = json;
            }
            catch (Exception e)
            {
                Debug.LogError($"Failed to capture state for {saveable.SaveKey}: {e.Message}");
            }
        }

        // Serialize the full save data
        // Using Newtonsoft because JsonUtility cannot serialize Dictionary
        string fullJson = Newtonsoft.Json.JsonConvert.SerializeObject(saveData, Newtonsoft.Json.Formatting.Indented);

        // Ensure directory exists
        Directory.CreateDirectory(Path.GetDirectoryName(path));

        // Write atomically: write to temp file, then rename
        string tempPath = path + ".tmp";
        File.WriteAllText(tempPath, fullJson);
        if (File.Exists(path)) File.Delete(path);
        File.Move(tempPath, path);

        Debug.Log($"Game saved to {path}");
        OnSaveCompleted?.Invoke();
    }

    // --- Load ---

    public void Load(int slot)
    {
        LoadFromFile(GetSaveFilePath(slot));
    }

    public void LoadAutoSave()
    {
        LoadFromFile(GetAutoSaveFilePath());
    }

    private void LoadFromFile(string path)
    {
        if (!File.Exists(path))
        {
            Debug.LogWarning($"Save file not found: {path}");
            return;
        }

        string json = File.ReadAllText(path);
        var saveData = Newtonsoft.Json.JsonConvert.DeserializeObject(json);

        if (saveData == null)
        {
            Debug.LogError("Failed to deserialize save data.");
            return;
        }

        // Handle version migration
        if (saveData.saveVersion  File.Exists(GetSaveFilePath(slot));
    public bool AutoSaveExists() => File.Exists(GetAutoSaveFilePath());

    public SaveData GetSaveMetadata(int slot)
    {
        string path = GetSaveFilePath(slot);
        if (!File.Exists(path)) return null;

        string json = File.ReadAllText(path);
        return Newtonsoft.Json.JsonConvert.DeserializeObject(json);
    }

    public void DeleteSave(int slot)
    {
        string path = GetSaveFilePath(slot);
        if (File.Exists(path)) File.Delete(path);
    }

    // --- Helpers ---

    private List FindAllSaveables()
    {
        var result = new List();
        // FindObjectsOfType does not find interfaces directly, so find all MonoBehaviours
        // and filter. For better performance, maintain a registry (see below).
        foreach (var mb in FindObjectsOfType(true))
        {
            if (mb is ISaveable saveable)
                result.Add(saveable);
        }
        return result;
    }

    private void MigrateSaveData(SaveData data)
    {
        // Example: migrate from version 0 to version 1
        // Add new fields, rename keys, transform data
        data.saveVersion = 1;
        Debug.Log("Migrated save data to version 1.");
    }
}
```

### Alternative: ISaveable with JSON String

If you prefer to avoid the `object` cast pattern, have `RestoreState` accept a JSON string directly:

```csharp
public interface ISaveable
{
    string SaveKey { get; }
    string CaptureStateJson();
    void RestoreStateJson(string json);
}

// Implementation:
public string CaptureStateJson()
{
    return JsonUtility.ToJson(new HealthSaveData
    {
        currentHealth = _currentHealth,
        maxHealth = maxHealth
    });
}

public void RestoreStateJson(string json)
{
    var data = JsonUtility.FromJson(json);
    _currentHealth = data.currentHealth;
    maxHealth = data.maxHealth;
}
```

This avoids boxing and unboxing, making the type contract clearer.

---

## JSON Serialization: JsonUtility vs Newtonsoft

**JsonUtility (built-in):**
- Fast, no external dependency
- Cannot serialize Dictionary, polymorphic types, or properties
- Only serializes fields (public or `[SerializeField]`)
- No pretty-print option
- Use for: individual component state (simple structs)

**Newtonsoft Json.NET (via `com.unity.nuget.newtonsoft-json`):**
- Full-featured: Dictionary, polymorphism, properties, custom converters
- Slower than JsonUtility
- Required for the SaveData wrapper (contains Dictionary)
- Install via Package Manager: `com.unity.nuget.newtonsoft-json`

**Strategy:** Use JsonUtility for individual ISaveable state capture (fast, simple structs). Use Newtonsoft for the top-level SaveData that aggregates everything (needs Dictionary support).

---

## Save File Management

### Save Slots UI

```csharp
using UnityEngine;
using TMPro;

public class SaveSlotUI : MonoBehaviour
{
    [SerializeField] private int slotIndex;
    [SerializeField] private TextMeshProUGUI slotInfoText;
    [SerializeField] private GameObject emptyLabel;
    [SerializeField] private GameObject dataPanel;

    private void OnEnable()
    {
        RefreshDisplay();
    }

    public void RefreshDisplay()
    {
        bool exists = SaveManager.Instance.SaveExists(slotIndex);
        emptyLabel.SetActive(!exists);
        dataPanel.SetActive(exists);

        if (exists)
        {
            var metadata = SaveManager.Instance.GetSaveMetadata(slotIndex);
            if (metadata != null)
            {
                var time = System.DateTime.Parse(metadata.timestamp);
                slotInfoText.text = $"{metadata.sceneName}\n{time:yyyy-MM-dd HH:mm}";
            }
        }
    }

    public void OnSaveClicked() => SaveManager.Instance.Save(slotIndex);
    public void OnLoadClicked() => SaveManager.Instance.Load(slotIndex);
    public void OnDeleteClicked() => SaveManager.Instance.DeleteSave(slotIndex);
}
```

---

## Auto-Save

Trigger auto-save on meaningful events rather than a fixed timer. This avoids saving in the middle of combat or dialogue.

```csharp
public class AutoSaveManager : MonoBehaviour
{
    [SerializeField] private float minTimeBetweenAutoSaves = 60f;

    private float _lastAutoSaveTime;

    /// 
    /// Call from checkpoint triggers, level transitions, etc.
    /// 
    public void TriggerAutoSave()
    {
        if (Time.time - _lastAutoSaveTime  _destroyedIndices = new();

    public string SaveKey => saveKey;

    public void MarkDestroyed(int index)
    {
        _destroyedIndices.Add(index);
        if (index >= 0 && index (_destroyedIndices).ToArray() };
    }

    public void RestoreState(object state)
    {
        if (state is string json)
        {
            var data = JsonUtility.FromJson(json);
            _destroyedIndices = new HashSet(data.destroyedIndices);
            foreach (int i in _destroyedIndices)
            {
                if (i >= 0 && i  PlayerPrefs.GetFloat("Settings_MasterVolume", 1f);
        set { PlayerPrefs.SetFloat("Settings_MasterVolume", value); PlayerPrefs.Save(); }
    }

    public static int QualityLevel
    {
        get => PlayerPrefs.GetInt("Settings_QualityLevel", QualitySettings.GetQualityLevel());
        set { PlayerPrefs.SetInt("Settings_QualityLevel", value); QualitySettings.SetQualityLevel(value); PlayerPrefs.Save(); }
    }

    public static bool IsFullscreen
    {
        get => PlayerPrefs.GetInt("Settings_Fullscreen", 1) == 1;
        set { PlayerPrefs.SetInt("Settings_Fullscreen", value ? 1 : 0); Screen.fullScreen = value; PlayerPrefs.Save(); }
    }
}
```

---

## Cloud Sync Preparation

If you plan to support cloud saves (Steam Cloud, platform cloud storage):

1. **Save to a known, flat directory.** Steam Cloud syncs specific paths. Keep all save files in `Application.persistentDataPath/Saves/`.
2. **Keep file sizes small.** Cloud sync has bandwidth limits. Avoid saving large binary blobs.
3. **Include a timestamp in metadata.** Cloud conflict resolution needs to know which save is newer.
4. **Design for conflict resolution.** When local and cloud saves differ, present the player with a choice: "Use local save (2 hours ahead) or cloud save (from another device)?"
5. **Test offline behavior.** The game must work when cloud sync fails. Always fall back to local saves.

---

## Version Migration

Add a version number to every save file. When the save format changes (new fields, renamed keys, restructured data), increment the version and write migration logic.

```csharp
private SaveData MigrateSaveData(SaveData data)
{
    // Migration chain: apply each migration in order
    if (data.saveVersion  v1: Added playTime field
        data.playTime = 0f;
        data.saveVersion = 1;
    }

    if (data.saveVersion  v2: Renamed "player_hp" key to "player_health"
        if (data.stateEntries.ContainsKey("player_hp"))
        {
            data.stateEntries["player_health"] = data.stateEntries["player_hp"];
            data.stateEntries.Remove("player_hp");
        }
        data.saveVersion = 2;
    }

    return data;
}
```

Never break backward compatibility. Always migrate forward. Players who return after months should not lose their saves.

---

## Encryption for Anti-Cheat

For release builds, encrypt save files to discourage casual editing. This is not bulletproof security; it just raises the effort threshold.

```csharp
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;

public static class SaveEncryption
{
    // In production, derive this from a machine-specific value or obfuscate it
    private static readonly byte[] Key = Encoding.UTF8.GetBytes("YourGame16ByteK"); // 16 bytes for AES-128
    private static readonly byte[] IV = Encoding.UTF8.GetBytes("YourGameIV128bit");  // 16 bytes

    public static string Encrypt(string plainText)
    {
        using var aes = Aes.Create();
        aes.Key = Key;
        aes.IV = IV;

        using var encryptor = aes.CreateEncryptor();
        byte[] plainBytes = Encoding.UTF8.GetBytes(plainText);
        byte[] encrypted = encryptor.TransformFinalBlock(plainBytes, 0, plainBytes.Length);
        return Convert.ToBase64String(encrypted);
    }

    public static string Decrypt(string cipherText)
    {
        using var aes = Aes.Create();
        aes.Key = Key;
        aes.IV = IV;

        using var decryptor = aes.CreateDecryptor();
        byte[] cipherBytes = Convert.FromBase64String(cipherText);
        byte[] decrypted = decryptor.TransformFinalBlock(cipherBytes, 0, cipherBytes.Length);
        return Encoding.UTF8.GetString(decrypted);
    }
}
```

Use encryption only in release builds. Keep saves as readable JSON during development for debugging.

---

## Practical Tips

- **Atomic writes:** Always write to a temporary file first, then rename. If the game crashes mid-write, the original save is preserved.
- **Backup previous save:** Before overwriting, copy the existing file to `Save1.json.bak`. One-level backup catches most corr

…

## Source & license

This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.

- **Author:** [XeldarAlz](https://github.com/XeldarAlz)
- **Source:** [XeldarAlz/everything-claude-unity](https://github.com/XeldarAlz/everything-claude-unity)
- **License:** MIT
- **Homepage:** https://github.com/XeldarAlz/everything-claude-unity#readme

Install and usage instructions live in the source repository linked above.

## Pricing

- **Free** — Free

## Security capabilities

Automated source analysis of v0.1.0 — what this tool can access:

- **Network access:** no
- **Filesystem access:** yes
- **Shell / process execution:** no
- **Environment & secrets:** no
- **Dynamic code execution:** no

*"Yes" means the capability is present in the source — more access means more to trust, not that it is unsafe.*


## Versions

- **0.1.0** — security scan: passed — Imported from the upstream source.

## Links

- Listing page: https://agentstack.voostack.com/l/skill-xeldaralz-everything-claude-unity-save-system
- Seller: https://agentstack.voostack.com/s/xeldaralz
- Browse the marketplace: https://agentstack.voostack.com/browse

---
Listed on AgentStack — the marketplace for AI agent skills and MCP servers. Every listing is security-reviewed. Creators keep 70%.
