
Persistence — A source-generated save/load system for Godot 4 C#
I've been working on a C# library for Godot called Persistence, a save/load system built around Roslyn source generation.
The main goal is to avoid manually building dictionaries and writing serialization/deserialization boilerplate for every object that needs to be saved.
You mark the data you want to persist with [Save], implement ISavable, and Persistence generates the serialization code for you:
public partial class Player : CharacterBody2D, ISavable
{
public string SaveKey => "player";
public int SaveVersion => 1;
[Save] public int Health = 100;
[Save] public string PlayerName;
[Save] public Vector2 Position;
}
Saving and loading can then be handled through SaveManager:
SaveManager.Save("slot1", savableNodes);
SaveManager.Load("slot1", savableNodes);
Persistence currently uses JSON for its save files. I chose JSON because, for the kinds of games I typically work on, it provides more than enough capacity while keeping save files easy to inspect and debug.
Custom data
For types that aren't directly serializable, Persistence provides ISerializable.
This is especially useful for custom game data such as inventory slots, quest data, stats, or other structures that don't map directly to Godot's Variant types:
public class InventorySlot : ISerializable
{
public string ItemId;
public int Count;
public Dictionary Serialize() => new()
{
["itemId"] = ItemId,
["count"] = Count
};
public void Deserialize(Dictionary data)
{
ItemId = data["itemId"].AsString();
Count = data["count"].AsInt32();
}
}
It can then be used directly inside an ISavable:
[Save] public InventorySlot Weapon;
[Save] public List<InventorySlot> Inventory;
ISerializable is also the main way to handle custom types that don't have built-in serialization support.
Manual serialization alongside [Save]
You don't have to choose between generated and manual serialization. Both can be used together.
Persistence provides OnSerialize and OnDeserialize hooks for cases where [Save] isn't enough:
public void OnSerialize(SaveData saveData)
{
saveData.Set("customField", someValue);
}
public void OnDeserialize(SaveData saveData)
{
someValue = saveData.Get("customField", default);
}
This lets you use [Save] for the straightforward fields while manually handling special cases in the same class.
Save versions & migrations
Save data can also be versioned so changes to a game's data structure don't immediately invalidate existing saves.
For example, if version 0 stored health under "HP" and version 1 changed it to "Health":
public class PlayerMigration_V0_To_V1 : SaveMigration
{
public override string SaveKey => "player";
public override int FromVersion => 0;
public override SaveData Migrate(SaveData saveData)
{
saveData.Set("Health", saveData.Get("HP", 100));
return saveData;
}
}
The migration can then be registered when loading:
var migrations = new MigrationRegistry(new SaveMigration[]
{
new PlayerMigration_V0_To_V1()
});
SaveManager.Load("slot1", savableNodes, migrations);
Migrations can be chained, allowing old saves to be upgraded through multiple versions:
Save v0
↓
V0 → V1
↓
V1 → V2
↓
Save v2
Other features
- Source-generated serialization/deserialization
- Save slots
- Custom save keys
- Manual serialization hooks
- Godot Variant-compatible types
List<T>, arrays, andGodot.Collections.Array<T>- Nested
ISerializabletypes - Save data versioning and migrations
The project is still relatively young, but the core system is usable and covers the save/load needs I've encountered so far.