AgentStack
Browse Sign in
Browse Why AgentStack Sell Docs
Sign in
SKILL verified MIT Self-run

Save System

skill-xeldaralz-everything-claude-unity-save-system · by XeldarAlz

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

— No reviews yet
0 installs
37 views
0.0% view→install

Install

$ agentstack add skill-xeldaralz-everything-claude-unity-save-system

✓ scanned · ✓ verified, works with Claude Code, Cursor, and more.

Security review

✓ Passed

No issues found. Passed automated security review. · v0.1.0 How review works →

  • ✓ Prompt-injection patterns
  • ✓ Secret / credential exfiltration
  • ✓ Dangerous shell & filesystem operations
  • ✓ Untrusted network calls
  • ✓ Known-malicious package signatures

What it can access

  • ✓ Network access No
  • ● Filesystem access Used
  • ✓ Shell / process execution No
  • ✓ Environment & secrets No
  • ✓ Dynamic code execution No

From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.

View the full security report →

Verified badge

Passed review? Show it. Paste this badge into your README, it links to the public security report.

AgentStack Verified badge Links to your public security report.
[![AgentStack Verified](https://agentstack.voostack.com/badges/verified.svg)](https://agentstack.voostack.com/security/report/skill-xeldaralz-everything-claude-unity-save-system)

Reliability & compatibility

✓ Security review passed
0 installs to date
— no reviews yet
○ 5mo ago

Declared compatibility

Claude CodeClaude Desktop

Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.

Preview Execution monitoring

We're building live execution health for every listing: tool-call success rate, median latency, uptime, and last-checked timestamps, measured, not self-reported. It isn't live yet, so we don't show numbers we can't stand behind.

How agent discovery & health will work →
Are you the author of Save System? Claim this listing to set pricing, connect Stripe payouts, and keep 70% of every sale.
Sign up to claim

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.

/// 
/// 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

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.
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.

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.

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:

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

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.

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.

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.

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.

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

Reviews

No reviews yet, be the first.

Versions

  • v0.1.0 Imported from the upstream source.