Worked examples
Eleven scenarios from "Hello SaveStack" to "machine-bound encryption and TDD migrations". Every snippet compiles against v1.1 — no pseudo-code. Copy, adapt, ship.
1. Hello SaveStack
When you want a working save in 60 seconds. Defaults are sane: JSON, no encryption, OS save folder.
using AvanatroTools.SaveStack;
using UnityEngine;
public class HelloSaveStack : MonoBehaviour
{
void Start()
{
// Save anything serializable. Slot defaults to 0.
SaveSystem.Save("playerName", "Player One");
SaveSystem.Save("highScore", 1337);
SaveSystem.Save("lastLogin", System.DateTime.UtcNow);
// Load. Returns default(T) if the key is missing.
string name = SaveSystem.Load<string>("playerName");
int score = SaveSystem.Load<int>("highScore");
Debug.Log($"Welcome back {name}, your best is {score}");
}
}
That's the whole thing. SaveSystem.Configure(...) later when you need encryption or a custom path — start without it.
2. RPG player data — position, inventory, quests, flags
When you're building a real game and want everything in one slot, atomic.
using AvanatroTools.SaveStack;
using AvanatroTools.SaveStack.Quests;
using UnityEngine;
using System.Collections.Generic;
[System.Serializable]
public class PlayerData
{
public Vector3 position;
public int health;
public int gold;
public List<string> inventory;
}
public class SaveCheckpoint : MonoBehaviour
{
public Transform player;
public int slot = 0;
public void Checkpoint()
{
// 1. Game data
SaveSystem.Save("player", new PlayerData {
position = player.position,
health = 100,
gold = 250,
inventory = new List<string> { "sword", "potion_red", "key_dungeon" }
}, slot);
// 2. Quest progression (separate sub-module, persists under __quests)
var quests = new QuestStore(slot);
quests.Start("rescue_princess");
quests.IncrementCounter("rescue_princess", "guards_defeated", by: 3);
quests.SetVariable("rescue_princess", "chosen_dialogue", "diplomatic");
quests.Persist();
// 3. World flags (persists under __flags)
SaveSystem.SetFlag("met_npc_alice", true, slot);
SaveSystem.SetFlag("tutorial_completed", true, slot);
// 4. UI metadata (shown in load-game menu)
SaveSystem.SetSlotDescription(slot, $"Chapter 2 — Forest Path");
SaveSystem.SetSlotMetaField(slot, "location", "Whisperwood");
SaveSystem.SetSlotMetaField(slot, "level", "12");
}
}
Everything above lands in <persistentDataPath>/saves/slot_0/. Atomic writes, separate reserved keys (__meta / __quests / __flags) that don't appear in slot listings.
3. Settings storage — strictly separate from game data
When players reset their progress, their settings should survive. SaveStack keeps them apart by default.
using AvanatroTools.SaveStack;
public static class GameSettings
{
public static float MasterVolume {
get => SettingsSystem.Get("audio_master", defaultValue: 0.8f);
set => SettingsSystem.Set("audio_master", Mathf.Clamp01(value));
}
public static int QualityLevel {
get => SettingsSystem.Get("quality_level", defaultValue: 2);
set => SettingsSystem.Set("quality_level", value);
}
public static string Language {
get => SettingsSystem.Get("locale", defaultValue: "en");
set => SettingsSystem.Set("locale", value);
}
// "Reset settings to defaults" button:
public static void ResetAll() => SettingsSystem.Clear();
}
<root>/saves/slot_0/, settings in <root>/settings/. SaveSystem.DeleteSlot(0) wipes the save without touching audio volume or key bindings. Easy Save 3 needs a separate file convention for this; SaveStack does it by structure.
All SettingsSystem calls are thread-safe — call from a Settings-Apply coroutine and an OnDestroy in parallel without races.
4. Scene state via saver components — no save code per object
When you have a level with 50 chests, doors, switches, and you want save/load to "just work".
Step 1. Open the scene. Window → SaveStack → Save Wizard. Multi-select your interactive GameObjects. Check the Saver types you need (Position, Rotation, Active…). Click "Add to selected".
Step 2. Drop a single SaveSystemSceneController on a manager GameObject. Set TargetSlot = 0, leave AutoRestoreOnLoad = true.
Step 3. Wire up your save / load buttons:
using AvanatroTools.SaveStack;
using UnityEngine;
using UnityEngine.UI;
public class SaveLoadUI : MonoBehaviour
{
public Button saveButton;
public Button loadButton;
void Awake()
{
// SlotSave iterates every SaveSystemSceneController in the loaded scenes
// and captures their state. One call, every saver in the level handled.
saveButton.onClick.AddListener(() => SaveSystem.SlotSave(slot: 0));
loadButton.onClick.AddListener(() => SaveSystem.SlotLoad(slot: 0));
}
}
For cross-scene persistence (object exists in two scenes, same identity), set OverrideKey = "world_event_dragon_door" on the saver instead of relying on the auto-key.
5. Schema migration with rollback
When your patch changes the player data structure and old saves need to keep working.
using AvanatroTools.SaveStack;
using AvanatroTools.SaveStack.Versioning;
using Newtonsoft.Json.Linq;
// v0 -> v1: renamed "hp" to "health" and added "gold"
public class V0ToV1 : IMigration
{
public int FromVersion => 0;
public int ToVersion => 1;
public JToken Migrate(JToken data) {
var obj = (JObject)data;
if (obj["hp"] != null) {
obj["health"] = obj["hp"];
obj.Remove("hp");
}
obj["gold"] = obj["gold"] ?? 0;
return obj;
}
}
void ConfigureSaveStack() {
var migrations = new MigrationHandler(currentVersion: 1);
migrations.Register(new V0ToV1());
SaveSystem.Configure(migrations: migrations);
}
Now every Load on a v0-stored slot transparently runs the migration. The original cipher bytes are mirrored to player.premigration_v0.bak once, before the migration runs. If your migration code turns out broken in production:
// In a hotfix patch — restore every affected player:
foreach (var slot in SaveSystem.GetUsedSlots())
{
foreach (var version in SaveSystem.ListBackupVersions(slot, "player"))
{
SaveSystem.RestoreBackup(slot, "player", version);
Debug.Log($"Restored slot {slot} from v{version} backup");
}
}
Load against the same v0 slot does not overwrite the backup with the (now migrated) cipher. Once mirrored, always reachable — until you explicitly RestoreBackup or DeleteSlot.
6. AES encryption with a derived key
When you're shipping commercial and the save file should not be a JSON edit away from cheating.
using AvanatroTools.SaveStack;
using AvanatroTools.SaveStack.Defaults;
using System.Security.Cryptography;
using System.Text;
void ConfigureCrypto()
{
// Derive a 32-byte key from a per-install salt + a build secret.
// Real games should use SystemInfo.deviceUniqueIdentifier or a server-issued key.
var saltBytes = Encoding.UTF8.GetBytes(SystemInfo.deviceUniqueIdentifier);
var secretBytes = Encoding.UTF8.GetBytes("build-secret-DO-NOT-COMMIT");
using var pbkdf2 = new Rfc2898DeriveBytes(secretBytes, saltBytes,
iterations: 100_000, HashAlgorithmName.SHA256);
byte[] key = pbkdf2.GetBytes(32);
// AES-256-CBC — production default in v1.0. Works on Mono + IL2CPP.
SaveSystem.Configure(encryption: new AesEncryption(key));
}
AesGcmEncryption exists in v1.0 but is IL2CPP-only — Unity's Mono runtime ships an older .NET that lacks System.Security.Cryptography.AesGcm. For Mono builds use AesEncryption (CBC). v1.1 will swap in a BouncyCastle-backed GCM for full Mono coverage. Format-version byte already in place — Backwards-Compat is handled automatically.
Cipher format: AES-CBC with a fresh IV per encrypt (16 bytes prepended). Decryption accepts both v1.0 cipher and pre-format-byte legacy bytes — your existing saves keep loading.
7. Async save in the background
When you want auto-save during gameplay without a frame hitch.
using AvanatroTools.SaveStack;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;
public class BackgroundSaver : MonoBehaviour
{
CancellationTokenSource _cts;
void OnEnable() { _cts = new CancellationTokenSource(); SaveLoop(); }
void OnDisable() { _cts?.Cancel(); }
async void SaveLoop()
{
var ct = _cts.Token;
while (!ct.IsCancellationRequested)
{
await Task.Delay(System.TimeSpan.FromSeconds(30), ct);
try
{
await SaveSystem.SaveAsync("autosave", BuildSnapshot(), slot: 9, ct);
Debug.Log("Background autosave OK");
}
catch (System.OperationCanceledException) { return; }
catch (System.Exception e) {
Debug.LogWarning($"Autosave failed: {e.Message}");
// OnSaveFailed event already fired — UI can listen there.
}
}
}
PlayerData BuildSnapshot() => new PlayerData { /* fill from current state */ };
}
I/O is awaited via IAsyncStorageProvider.WriteAsync when the storage supports it (FileStorage does). Serialization + encryption still run inline on the calling thread — wrap in Task.Run if your snapshot is huge.
SaveAsync uses SemaphoreSlim.WaitAsync, not Monitor — so the lock survives the I/O await without blocking the calling thread. Concurrent SaveAsync calls on the same slot serialize correctly; calls on different slots run in parallel.
8. Streaming a 100 MB world save with progress (P.1)
When your open-world state is too large for a single in-memory serialize+write cycle. SaveStack chunks the I/O so peak RAM stays bounded.
using AvanatroTools.SaveStack;
using System.Threading;
using UnityEngine;
using UnityEngine.UI;
public class StreamingSaveDemo : MonoBehaviour
{
public Slider loadingBar;
public Text loadingText;
public async void SaveWorld(BigWorldData worldData, CancellationToken ct)
{
var progress = new Progress<StreamProgress>(p =>
{
loadingBar.value = (float)(p.PercentComplete ?? 0);
loadingText.text = $"{p.Phase}: {p.PercentComplete:P0}";
});
await SaveSystem.SaveStreamAsync(
slot: 1,
key: "world",
data: worldData,
options: new StreamSaveOptions { ChunkSizeBytes = 2 * 1024 * 1024 },
progress: progress,
ct: ct);
}
}
Peak memory: roughly chunk-size + size of the current chunk being serialized. No full in-memory copy of the payload at any point.
9. Tamper-evident saves with HMAC-SHA256 (Z.1)
When you need to detect if a player edited the save file in a hex editor. The HMAC tag travels with the file — any byte change is caught on the next Load.
using AvanatroTools.SaveStack;
using AvanatroTools.SaveStack.Security;
using System.Security.Cryptography;
void ConfigureSecureSaves()
{
// AES key for encryption
byte[] aesKey = LoadOrGenerateKey("aes-key");
// Separate HMAC key — never reuse the encryption key
byte[] hmacKey = LoadOrGenerateKey("hmac-key");
SaveSystem.Configure(
encryption: new AesEncryption(aesKey),
hmac: new HmacSha256Provider(hmacKey));
SaveSystem.Save(1, "player", playerData);
// Format on disk: [format-byte 0x07][cipher][32-byte HMAC tag]
}
void LoadSecureSave()
{
try
{
var data = SaveSystem.Load<PlayerData>("player", slot: 1);
}
catch (HmacVerificationFailedException ex)
{
Debug.LogError($"Save file was tampered with: {ex.Message}");
// Offer player: restore from backup or start fresh
}
}
static byte[] LoadOrGenerateKey(string id)
{
if (SaveSystem.HasKey(id, slot: 99)) return SaveSystem.Load<byte[]>(id, slot: 99);
var key = new byte[32];
RandomNumberGenerator.Fill(key);
SaveSystem.Save(id, key, slot: 99);
return key;
}
HMAC verification happens before decryption — a tampered file is rejected without exposing partial plaintext.
10. Anti-sharing: machine-bound encryption keys (Z.2)
When you want save files to be non-transferable between machines. A save file copied to a friend's PC will fail to decrypt.
using AvanatroTools.SaveStack;
using AvanatroTools.SaveStack.Security;
void ConfigureMachineBound()
{
// Load or create a master key (stored in a separate "meta" slot)
byte[] masterKey = LoadOrCreateMasterKey();
// DeriveForLocalMachine runs HKDF-SHA256 using the device's
// IMachineIdentityProvider output as salt. Same master key on
// a different device = different AES key = decryption fails.
byte[] boundKey = MachineBoundKey.DeriveForLocalMachine(masterKey);
SaveSystem.Configure(encryption: new AesEncryption(boundKey));
}
void LoadOnOtherMachine()
{
try
{
SaveSystem.Load<PlayerData>("player", slot: 0);
}
catch (System.Security.Cryptography.CryptographicException)
{
// Decryption failed — this save belongs to a different machine.
Debug.LogWarning("Save file cannot be loaded on this device.");
}
}
11. TDD-style migration tests with MigrationTestBed (M.1)
When you want to test every migration step in isolation before shipping a patch. Given/When/Then — as readable as a spec.
using AvanatroTools.SaveStack.Testing;
using NUnit.Framework;
[TestFixture]
public class PlayerMigrationTests
{
[Test]
public void V1_to_V2_Adds_PlayerXp_DefaultZero()
{
MigrationTestBed
.Given(version: 1, jsonData: "{\"level\":5,\"gold\":100}")
.When(new V1ToV2Handler())
.Then(expectedVersion: 2)
.AssertJsonPath("$.playerXp", 0)
.AssertJsonPath("$.level", 5) // existing fields preserved
.Run();
}
[Test]
public void V2_to_V3_RenamesGoldToCoins()
{
MigrationTestBed
.Given(version: 2, jsonData: "{\"level\":5,\"gold\":100,\"playerXp\":0}")
.When(new V2ToV3Handler())
.Then(expectedVersion: 3)
.AssertJsonPath("$.coins", 100)
.AssertJsonPathMissing("$.gold") // old key removed
.Run();
}
[Test]
public void FullChain_V1_to_V3()
{
// Chain multiple handlers — MigrationTestBed applies them in order
MigrationTestBed
.Given(version: 1, jsonData: "{\"level\":5,\"gold\":100}")
.When(new V1ToV2Handler(), new V2ToV3Handler())
.Then(expectedVersion: 3)
.AssertJsonPath("$.coins", 100)
.Run();
}
}
Each Run() invokes NUnit Assert internally — failures show the actual JSON at the assertion point, making diffs trivial to diagnose.
More patterns
The Asset Store package ships three working sample scenes under Samples~/:
- InventoryDemo — list of items, JSON round-trip, slot 0
- HighscoreDemo — append-only high-score list with sort
- SlotSaveLoadDemo — Saver components +
SlotSave/SlotLoadbuttons - SaverComponentsDemo — full scene with all six built-in savers + Animator + Rigidbody
Import them from Package Manager → SaveStack → Samples tab in Unity.