Install
$ agentstack add skill-xeldaralz-everything-claude-unity-save-system ✓ scanned · ✓ verified, works with Claude Code, Cursor, and more.
Security review
✓ PassedNo 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.
Verified badge
Passed review? Show it. Paste this badge into your README, it links to the public security report.
Reliability & compatibility
Declared compatibility
Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.
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 →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:
- Manual string (simplest): Assign in Inspector. Works for unique objects like "player_health".
- GUID component: Add a
SaveableEntityMonoBehaviour with a[SerializeField] private string uniqueIdthat generates a GUID inReset()(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):
- Save to a known, flat directory. Steam Cloud syncs specific paths. Keep all save files in
Application.persistentDataPath/Saves/. - Keep file sizes small. Cloud sync has bandwidth limits. Avoid saving large binary blobs.
- Include a timestamp in metadata. Cloud conflict resolution needs to know which save is newer.
- 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)?"
- 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.
- Author: XeldarAlz
- Source: 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.
Reviews
No reviews yet, be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.