Appearance
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:
| Method | Purpose |
|---|---|
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.asmdefDependencies
| Dependency | Kind |
|---|---|
kids.kapish | runtime (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.unscaledDeltaTimeso 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.signalsto 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.09. 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 SectionIdis what's written into the binary format and used to match chunks during load.SectionNameis 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
SectionVersionin the chunk header. TheReadmethod receives the version from the file, so sections can handle their own format evolution without global migrations. - Backup is fire-and-forget --
BackupProviderfailures 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 --
LoadMetadatareads only the file header, not the section data. Use it for save slot selection UI.