Avanatro Tools

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.

v1.1.0 tested against Unity 6000.0.x using AvanatroTools.SaveStack;

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();
}
Why this matters Game data lives in <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");
    }
}
Backup is idempotent A second 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));
}
AES-GCM caveat 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.

The per-slot lock holds across await 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.");
    }
}
Note on device resets If the device identifier changes (factory reset, OS reinstall), the derived key changes too. Store the master key in a location that survives device reset (e.g., iCloud Keychain, Android Keystore, or a server-side vault) if you need cloud resilience.

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

Import them from Package Manager → SaveStack → Samples tab in Unity.