Skip to content

kids.kapish.persistence

unity

Save/load system with slots, auto-save, versioning, and pluggable storage providers.

Installation

Add to your Unity project's package manifest:

json
{
  "kids.kapish.persistence": "file:../../src/unity/kids.kapish.persistence"
}

Requires Unity 6000.0+.

Architecture

Overview

PersistentState (MonoSingleton)
├── ISaveProvider (primary)     -- where bytes go (local files, etc.)
├── ISaveProvider (backup)      -- optional backup (cloud, etc.)
├── SaveMigration               -- version migration pipeline
└── SaveSlot (active)
    ├── SaveSlotMetadata        -- timestamps, version, playtime
    └── ISaveSection[]          -- registered sections (inventory, world, settings...)

Save File Format

Files use a binary format with a fixed header followed by section chunks:

┌─────────────────────────────────┐
│ Magic: "CARROT\0\0" (8 bytes)  │
│ Save Version (8 bytes)          │  ushort major + ushort minor + int build
│ Metadata JSON (length-prefixed) │  SaveSlotMetadata serialized via JsonUtility
├─────────────────────────────────┤
│ Section Chunk 0                 │
│   SectionId (2 bytes)           │
│   Version   (8 bytes)           │
│   Next Addr (8 bytes)           │  absolute position of next chunk
│   Body      (variable)          │  section-specific binary data
├─────────────────────────────────┤
│ Section Chunk 1                 │
│   ...                           │
├─────────────────────────────────┤
│ Section Chunk N                 │
│   ...                           │
└─────────────────────────────────┘

All multi-byte values are big-endian. Strings are UTF-16 (2 bytes per char), length-prefixed with a ushort character count. Addresses are 8-byte absolute positions within the byte stream.

Forward Compatibility

Each chunk header includes a Next address pointing to the start of the following chunk. When the loader encounters an unknown SectionId, it skips to Next without needing to understand the chunk body. This means:

  • Old game versions can load saves that contain new section types.
  • Sections can be added or removed between versions without breaking existing saves.

Provider Model

ISaveProvider is intentionally minimal -- five methods, all synchronous, all byte-oriented:

MethodPurpose
Exists(slotName)Check if a save exists
Load(slotName)Return raw bytes
Save(slotName, data)Write raw bytes
Delete(slotName)Remove a save
ListSlots()Enumerate available saves

The default LocalFileSaveProvider writes .sav files to Application.persistentDataPath/saves/. The optional BackupProvider on PersistentState is fire-and-forget -- failures are logged as warnings but don't block the save.

Migration Pipeline

SaveMigration applies registered transforms in version order. Each step receives a SaveBinaryReader (old data) and SaveBinaryWriter (new data) and is responsible for rewriting the entire save from one version to the next.

Steps are sorted by From version. The pipeline walks the chain: v1 -> v2 -> v3 -> current. If the chain has gaps, a warning is logged.

Binary IO

SaveBinaryWriter and SaveBinaryReader are standalone -- no dependency on the Morsels IO stack. The wire format is byte-compatible with MorselBinaryWriter / MorselBinaryReader, so Morsels-based sections can use either reader/writer interchangeably.

Section Model

ISaveSection is the only interface a game system needs to implement. The SectionId (ushort) is used for chunk identification in the binary format. SectionName (string) is used for the slot's internal registry (dictionary key). SectionVersion is written into the chunk header so the section's Read method can handle its own versioning independently of the global save version.

File Structure

Runtime/
├── PersistentState.cs          # MonoSingleton orchestrator
├── SaveSlot.cs                 # Slot with section registry
├── SaveSlotMetadata.cs         # Slot metadata (JSON-serialized)
├── ISaveProvider.cs            # Storage backend interface
├── LocalFileSaveProvider.cs    # Default file-based provider
├── ISaveSection.cs             # Extension point for game systems
├── JsonSaveSection{T}.cs       # Quick JSON section helper
├── SaveBinaryWriter.cs         # Big-endian binary writer
├── SaveBinaryReader.cs         # Big-endian binary reader + SaveChunkHeader
└── SaveMigration.cs            # Version migration pipeline
Editor/
└── Carrot.Persistence.Editor.asmdef

Dependencies

DependencyKind
kids.kapishruntime (Carrot core Unity package -- provides MonoSingleton<T>)

Build

Import via Unity Package Manager. Requires Unity 6000.0+.


Usage Guide

Save/load system with slots, auto-save, versioning, and pluggable storage providers.

Setup

Add kids.kapish.persistence to your Unity project manifest. Add a PersistentState component to a GameObject in your scene (it's a MonoSingleton -- one instance, survives scene loads).

Common Patterns

1. Create and manage save slots

csharp
using Carrot.Persistence;

// Create a new slot
SaveSlot slot = PersistentState.Instance.CreateSlot("save-01", "Chapter 1");

// Set it as active (used by parameterless Save/Load)
PersistentState.Instance.SetActiveSlot(slot);

// List existing saves
string[] slotNames = PersistentState.Instance.ListSlotNames();

// Check if a save exists
bool exists = PersistentState.Instance.SlotExists("save-01");

// Delete a save
PersistentState.Instance.DeleteSlot("save-01");

// Load just the metadata (for save slot UI)
SaveSlotMetadata meta = PersistentState.Instance.LoadMetadata("save-01");
Debug.Log($"{meta.DisplayName} - {meta.ModifiedUtc} - {meta.PlayTimeSeconds}s");

2. Register save sections

Every system that participates in save/load registers an ISaveSection on the slot:

csharp
SaveSlot slot = PersistentState.Instance.CreateSlot("save-01");

// JSON section for simple settings
var prefs = new JsonSaveSection<AudioSettings>("audio", sectionId: 1);
slot.Register(prefs);

// Custom binary section for complex data
var inventory = new InventorySaveSection();
slot.Register(inventory);

// Sections can be unregistered
slot.Unregister("audio");

3. Save and load

csharp
// Save the active slot
PersistentState.Instance.Save();

// Load the active slot
PersistentState.Instance.Load();

// Or save/load a specific slot
PersistentState.Instance.Save(someOtherSlot);
PersistentState.Instance.Load(someOtherSlot);

4. Implement ISaveSection

This is the main extension point. Each section has a unique name, numeric ID, and version:

csharp
using System;
using Carrot.Persistence;

public class InventorySaveSection : ISaveSection
{
    public string SectionName => "inventory";
    public ushort SectionId => 10;
    public Version SectionVersion => new(2, 0, 0);

    private readonly List<InventoryItem> items;

    public InventorySaveSection(List<InventoryItem> items)
    {
        this.items = items;
    }

    public void Write(SaveBinaryWriter writer)
    {
        writer.WriteInt(this.items.Count);

        foreach (InventoryItem item in this.items)
        {
            writer.WriteUShort(item.TypeId);
            writer.WriteInt(item.Quantity);
            writer.WriteString(item.CustomName);
        }
    }

    public void Read(SaveBinaryReader reader, Version version)
    {
        int count = reader.ReadInt();
        this.items.Clear();

        for (int i = 0; i < count; i++)
        {
            ushort typeId = reader.ReadUShort();
            int quantity = reader.ReadInt();

            // Handle section versioning -- CustomName added in v2
            string customName = version >= new Version(2, 0, 0)
                ? reader.ReadString()
                : null;

            this.items.Add(new InventoryItem(typeId, quantity, customName));
        }
    }
}

5. Use JsonSaveSection for simple data

For settings, preferences, and small state objects, skip the binary IO entirely:

csharp
using Carrot.Persistence;

[Serializable]
public class AudioSettings
{
    public float MasterVolume = 1f;
    public float MusicVolume = 0.8f;
    public float SfxVolume = 1f;
    public bool Muted;
}

// Create and register
var audio = new JsonSaveSection<AudioSettings>("audio", sectionId: 1);
slot.Register(audio);

// Read/write the data directly
audio.Data.MasterVolume = 0.5f;

// After loading, the data is populated automatically
PersistentState.Instance.Load();
float vol = audio.Data.MasterVolume;

6. Auto-save

Auto-save is configured on the PersistentState component in the Inspector:

  • Auto Save Enabled -- toggle on/off.
  • Auto Save Interval Seconds -- defaults to 300 (5 minutes). Uses Time.unscaledDeltaTime so it works during slow-mo or pause.

You can also trigger an auto-save manually (resets the timer):

csharp
PersistentState.Instance.TriggerAutoSave();

7. Save/load lifecycle events

csharp
PersistentState.Instance.SaveStarted.Add(() => ShowSaveIcon());
PersistentState.Instance.SaveCompleted.Add(() => HideSaveIcon());
PersistentState.Instance.LoadStarted.Add(() => ShowLoadingScreen());
PersistentState.Instance.LoadCompleted.Add(() => HideLoadingScreen());
// .Add() returns an unsubscribe Action — capture it if you need to detach later.

Add kids.kapish.signals to your asmdef references if using these signals from your own code.

8. Register version migrations

When the save format changes between game versions, register migration steps:

csharp
var migration = PersistentState.Instance.Migration;

// v1.0.0 -> v1.1.0: added player stats section
migration.Register(
    new Version(1, 0, 0),
    new Version(1, 1, 0),
    (reader, writer) =>
    {
        // Copy header and existing sections unchanged
        writer.WriteBytes(reader.ReadBytes(8)); // magic
        writer.WriteVersion(new Version(1, 1, 0)); // bump version
        reader.ReadVersion(); // skip old version

        // Copy the rest as-is -- new section simply won't exist in old saves
        // (the loader handles missing sections gracefully)
        byte[] rest = reader.ReadBytes((int)(reader.Length - reader.Position));
        writer.WriteBytes(rest);
    }
);

// v1.1.0 -> v2.0.0: restructured inventory format
migration.Register(
    new Version(1, 1, 0),
    new Version(2, 0, 0),
    (reader, writer) =>
    {
        // Rewrite the save with the new inventory format
        // ... transform logic here
    }
);

// Pipeline runs automatically: v1.0.0 -> v1.1.0 -> v2.0.0

9. Custom save providers

Implement ISaveProvider for alternative storage backends:

csharp
using Carrot.Persistence;

public class CloudSaveProvider : ISaveProvider
{
    public bool Exists(string slotName)
    {
        return CloudAPI.FileExists($"saves/{slotName}.sav");
    }

    public byte[] Load(string slotName)
    {
        return CloudAPI.Download($"saves/{slotName}.sav");
    }

    public void Save(string slotName, byte[] data)
    {
        CloudAPI.Upload($"saves/{slotName}.sav", data);
    }

    public void Delete(string slotName)
    {
        CloudAPI.Delete($"saves/{slotName}.sav");
    }

    public string[] ListSlots()
    {
        return CloudAPI.ListFiles("saves/")
                       .Select(f => Path.GetFileNameWithoutExtension(f))
                       .ToArray();
    }
}

// Use as primary or backup
PersistentState.Instance.Provider = new LocalFileSaveProvider();
PersistentState.Instance.BackupProvider = new CloudSaveProvider();

Tips

  • SectionId is the wire identity -- the ushort SectionId is what's written into the binary format and used to match chunks during load. SectionName is only used for the slot's internal dictionary. Keep IDs stable across versions.
  • Unknown sections are skipped -- if a save contains a section the current build doesn't know about, it's skipped via the chunk's next-address. No data loss, no errors.
  • Section versioning is independent -- each section carries its own SectionVersion in the chunk header. The Read method receives the version from the file, so sections can handle their own format evolution without global migrations.
  • Backup is fire-and-forget -- BackupProvider failures are logged as warnings. The primary save always completes.
  • Big-endian everywhere -- all multi-byte values use network byte order, matching the Morsels binary format.
  • Metadata is cheap to read -- LoadMetadata reads only the file header, not the section data. Use it for save slot selection UI.

Carrot