Imported from Xyrces/godot-ecs-gamedev-playbook (
skills/save_system_design/SKILL.md). Install upstream withnpx skills add Xyrces/godot-ecs-gamedev-playbook --skill save_system_design. Copyright stays with the author.
Save System Design
1. Save Architecture Overview
SaveGameManager (Coordinator)
│
├── EntitySerializer ← Iterates world, serializes entities
│ └── ComponentSerializer ← Per-component serialization logic
│
├── SaveFileWriter ← Writes header + data + checksum
│
├── SaveFileReader ← Reads and validates save files
│ └── VersionMigrator ← Upgrades old save formats
│
└── AutosaveScheduler ← Periodic background saves
SaveGameManager
public sealed class SaveGameManager
{
private readonly EntitySerializer _entitySerializer;
private readonly SaveFileWriter _writer;
private readonly SaveFileReader _reader;
private readonly VersionMigrator _migrator;
public SaveGameManager(World world)
{
_entitySerializer = new EntitySerializer(world);
_writer = new SaveFileWriter();
_reader = new SaveFileReader();
_migrator = new VersionMigrator();
}
public void Save(string filePath)
{
var snapshot = _entitySerializer.SerializeWorld();
_writer.Write(filePath, snapshot);
}
public void Load(string filePath, World targetWorld)
{
var rawData = _reader.Read(filePath);
var migrated = _migrator.MigrateIfNeeded(rawData);
_entitySerializer.DeserializeWorld(targetWorld, migrated);
}
}
2. Component Serialization
ISaveable Marker Interface
// Only components marked as ISaveable are included in save files
// Transient components (particles, visual state, input) are excluded
public interface ISaveable { }
// Saveable components
public readonly record struct Position(float X, float Y) : ISaveable;
public readonly record struct Health(float Current, float Max) : ISaveable;
public readonly record struct Inventory(int[] ItemIds) : ISaveable;
public readonly record struct StableId(Guid Id) : ISaveable;
// Transient components — NOT saved
public readonly record struct Velocity(float X, float Y); // Recomputed
public readonly record struct AnimationState(int Frame, float T); // Visual only
public readonly record struct IsVisible(bool Value); // Runtime only
public readonly record struct InputState(); // Per-frame
ComponentSerializer with Type Registry
public sealed class ComponentSerializer
{
// Type ID → serializer mapping (registered at startup)
private readonly Dictionary<Type, IComponentWriter> _writers = new();
private readonly Dictionary<ushort, IComponentReader> _readers = new();
private readonly Dictionary<Type, ushort> _typeIds = new();
public void Register<T>(ushort typeId) where T : struct, ISaveable
{
_typeIds[typeof(T)] = typeId;
_writers[typeof(T)] = new ComponentWriter<T>();
_readers[typeId] = new ComponentReader<T>();
}
// Register all saveable components at startup
public void RegisterDefaults()
{
Register<Position>(1);
Register<Health>(2);
Register<Inventory>(3);
Register<StableId>(4);
// Add new components with new IDs — never reuse old IDs
}
public void Serialize(BinaryWriter writer, Type componentType, object component)
{
if (!_typeIds.TryGetValue(componentType, out var typeId))
return; // Skip non-saveable components
writer.Write(typeId);
_writers[componentType].Write(writer, component);
}
public (ushort TypeId, object Component) Deserialize(BinaryReader reader)
{
ushort typeId = reader.ReadUInt16();
var component = _readers[typeId].Read(reader);
return (typeId, component);
}
}
// Generic writer/reader
public interface IComponentWriter
{
void Write(BinaryWriter writer, object component);
}
public interface IComponentReader
{
object Read(BinaryReader reader);
}
public sealed class ComponentWriter<T> : IComponentWriter where T : struct
{
public void Write(BinaryWriter writer, object component)
{
var typed = (T)component;
// Use System.Text.Json for flexibility, or manual binary for speed
var json = JsonSerializer.SerializeToUtf8Bytes(typed);
writer.Write(json.Length);
writer.Write(json);
}
}
Binary vs JSON Serialization
| Format | Speed | Size | Debugging | Use Case |
|---|---|---|---|---|
| Binary | Fast | Small | Hard | Production saves |
| JSON | Slow | Large | Easy | Debug saves, modding support |
| MessagePack | Fast | Small | Moderate | Good compromise |
// JSON for debug builds, binary for release
public static class SerializationFormat
{
public static byte[] Serialize<T>(in T component) where T : struct
{
#if DEBUG
return JsonSerializer.SerializeToUtf8Bytes(component,
new JsonSerializerOptions { WriteIndented = true });
#else
// Binary serialization for production
using var ms = new MemoryStream();
using var writer = new BinaryWriter(ms);
WriteBinary(writer, in component);
return ms.ToArray();
#endif
}
}
3. Versioned Schemas
Save File Versioning
public static class SaveVersion
{
// Increment when save format changes
public const ushort Current = 3;
// History:
// v1: Initial format (Position, Health)
// v2: Added Inventory component
// v3: Changed Position from int to float
}
public sealed class VersionMigrator
{
public SaveData MigrateIfNeeded(SaveData data)
{
while (data.Version < SaveVersion.Current)
{
data = data.Version switch
{
1 => MigrateV1ToV2(data),
2 => MigrateV2ToV3(data),
_ => throw new InvalidOperationException(
$"Unknown save version: {data.Version}")
};
}
return data;
}
private SaveData MigrateV1ToV2(SaveData data)
{
// Add empty inventory to all entities that don't have one
foreach (var entity in data.Entities)
{
if (!entity.Components.ContainsKey(3)) // Inventory typeId
entity.Components[3] = new Inventory(Array.Empty<int>());
}
data.Version = 2;
return data;
}
private SaveData MigrateV2ToV3(SaveData data)
{
// Convert Position from int to float
foreach (var entity in data.Entities)
{
if (entity.Components.TryGetValue(1, out var posObj))
{
// Migration logic for Position format change
}
}
data.Version = 3;
return data;
}
}
4. Entity Serialization
Stable Entity IDs
// ECS entity IDs are NOT stable across save/load (they are runtime indices)
// Use a stable GUID-based ID for persistence
public readonly record struct StableId(Guid Id) : ISaveable
{
public static StableId New() => new(Guid.NewGuid());
}
// Mapping between stable IDs and runtime entities
public sealed class EntityIdMap
{
private readonly Dictionary<Guid, Entity> _stableToRuntime = new(256);
private readonly Dictionary<Entity, Guid> _runtimeToStable = new(256);
public void Register(Entity entity, Guid stableId)
{
_stableToRuntime[stableId] = entity;
_runtimeToStable[entity] = stableId;
}
public Entity Resolve(Guid stableId)
=> _stableToRuntime.TryGetValue(stableId, out var e) ? e : default;
public Guid GetStableId(Entity entity)
=> _runtimeToStable.TryGetValue(entity, out var id) ? id : Guid.Empty;
}
Entity References in Components
// Store Guid references, not Entity references (which are runtime-only)
public readonly record struct FollowTarget(Guid TargetStableId) : ISaveable;
public readonly record struct Parent(Guid ParentStableId) : ISaveable;
public readonly record struct Owner(Guid OwnerStableId) : ISaveable;
// After loading, resolve Guid → Entity via EntityIdMap
public class ResolveReferencesSystem : GameSystem
{
private readonly EntityIdMap _idMap;
public void Execute()
{
// Resolve FollowTarget Guid → runtime Entity
World.Query(in _followQuery, (ref FollowTarget follow, ref ResolvedTarget resolved) =>
{
var target = _idMap.Resolve(follow.TargetStableId);
if (World.IsAlive(target))
resolved = new ResolvedTarget(target);
});
}
}
5. Autosave Patterns
public sealed class AutosaveScheduler
{
private readonly SaveGameManager _saveManager;
private readonly float _intervalSeconds;
private float _timeSinceLastSave;
private bool _isDirty;
public AutosaveScheduler(SaveGameManager saveManager, float intervalSeconds = 300f)
{
_saveManager = saveManager;
_intervalSeconds = intervalSeconds;
}
public void MarkDirty() => _isDirty = true;
public void Update(float deltaTime)
{
if (!_isDirty) return;
_timeSinceLastSave += deltaTime;
if (_timeSinceLastSave >= _intervalSeconds)
{
_ = SaveAsync(); // Fire and forget on background thread
_timeSinceLastSave = 0f;
_isDirty = false;
}
}
private async Task SaveAsync()
{
// Capture snapshot on main thread, write on background
var snapshot = _saveManager.CaptureSnapshot();
await Task.Run(() =>
{
string path = GetAutosavePath();
_saveManager.WriteSnapshot(path, snapshot);
RotateAutosaves(maxSlots: 3);
});
}
private static string GetAutosavePath()
=> Path.Combine(OS.GetUserDataDir(), $"autosave_{DateTime.UtcNow:yyyyMMdd_HHmmss}.sav");
}
6. Save Data Integrity
Magic Bytes and Checksums
public sealed class SaveFileWriter
{
private static readonly byte[] MagicBytes = "GMSV"u8.ToArray(); // Game Save
public void Write(string filePath, SaveData data)
{
using var fs = File.Create(filePath);
using var writer = new BinaryWriter(fs);
// Header
writer.Write(MagicBytes); // 4 bytes: magic
writer.Write(SaveVersion.Current); // 2 bytes: version
writer.Write(DateTime.UtcNow.ToBinary()); // 8 bytes: timestamp
// Data
long dataStart = fs.Position;
WriteEntityData(writer, data);
long dataEnd = fs.Position;
// Checksum (over data section only)
fs.Position = dataStart;
byte[] dataBytes = new byte[dataEnd - dataStart];
fs.ReadExactly(dataBytes);
uint checksum = Crc32.HashToUInt32(dataBytes);
fs.Position = dataEnd;
writer.Write(checksum); // 4 bytes: CRC32
}
}
public sealed class SaveFileReader
{
public SaveData Read(string filePath)
{
using var fs = File.OpenRead(filePath);
using var reader = new BinaryReader(fs);
// Validate magic bytes
byte[] magic = reader.ReadBytes(4);
if (!magic.AsSpan().SequenceEqual("GMSV"u8))
throw new InvalidDataException("Not a valid save file");
// Read version
ushort version = reader.ReadUInt16();
long timestamp = reader.ReadInt64();
// Read data
long dataStart = fs.Position;
var data = ReadEntityData(reader);
long dataEnd = fs.Position;
// Validate checksum
uint storedChecksum = reader.ReadUInt32();
fs.Position = dataStart;
byte[] dataBytes = new byte[dataEnd - dataStart];
fs.ReadExactly(dataBytes);
uint computedChecksum = Crc32.HashToUInt32(dataBytes);
if (storedChecksum != computedChecksum)
throw new InvalidDataException("Save file corrupted (checksum mismatch)");
data.Version = version;
return data;
}
}
7. Save File Format
Offset Size Field
─────────────────────────────────
0x00 4 Magic bytes ("GMSV")
0x04 2 Schema version (ushort)
0x06 8 Timestamp (DateTime binary)
0x0E 4 Entity count (int)
0x12 ... Entity table
│ Per entity:
│ 16 StableId (Guid)
│ 2 Component count (ushort)
│ ... Component data
│ 2 ComponentTypeId (ushort)
│ 4 Data length (int)
│ N Component bytes
... 4 CRC32 checksum
8. Cloud Save Considerations
// Cloud save conflict resolution strategies:
// 1. Latest wins (by timestamp)
// 2. User chooses (show both saves)
// 3. Merge (complex, game-specific)
public enum ConflictStrategy { LatestWins, UserChooses, Merge }
public sealed class CloudSaveManager
{
public async Task<SaveData> ResolveConflict(
SaveData local, SaveData cloud, ConflictStrategy strategy)
{
return strategy switch
{
ConflictStrategy.LatestWins =>
local.Timestamp > cloud.Timestamp ? local : cloud,
ConflictStrategy.UserChooses =>
await PromptUserChoice(local, cloud),
_ => local // Default to local
};
}
}
9. Testing Save Systems
[TestClass]
public class SaveSystemTests
{
[TestMethod]
public void RoundTrip_PreservesAllComponents()
{
// Arrange
using var world = World.Create();
var entity = world.Create(
StableId.New(),
new Position(42.5f, -17.3f),
new Health(75f, 100f)
);
var manager = new SaveGameManager(world);
// Act — save and load
string tempPath = Path.GetTempFileName();
manager.Save(tempPath);
using var loadWorld = World.Create();
manager.Load(tempPath, loadWorld);
// Assert — verify round-trip fidelity
var query = new QueryDescription().WithAll<Position, Health>();
int count = 0;
loadWorld.Query(in query, (ref Position pos, ref Health hp) =>
{
Assert.AreEqual(42.5f, pos.X, 0.001f);
Assert.AreEqual(-17.3f, pos.Y, 0.001f);
Assert.AreEqual(75f, hp.Current, 0.001f);
count++;
});
Assert.AreEqual(1, count);
File.Delete(tempPath);
}
[TestMethod]
public void VersionMigration_UpgradesOldSaves()
{
var migrator = new VersionMigrator();
var oldData = new SaveData { Version = 1 };
// ... populate with v1 format
var migrated = migrator.MigrateIfNeeded(oldData);
Assert.AreEqual(SaveVersion.Current, migrated.Version);
}
[TestMethod]
public void CorruptedFile_ThrowsOnChecksumMismatch()
{
// Create valid save, then corrupt a byte
string path = CreateValidSaveFile();
CorruptByte(path, offset: 20);
var reader = new SaveFileReader();
Assert.ThrowsException<InvalidDataException>(() => reader.Read(path));
File.Delete(path);
}
[TestMethod]
public void EntityReferences_SurviveSaveLoad()
{
using var world = World.Create();
var parentId = StableId.New();
var parent = world.Create(parentId, new Position(0, 0));
var child = world.Create(StableId.New(), new Parent(parentId.Id));
// Save → Load → Verify reference intact
var manager = new SaveGameManager(world);
string path = Path.GetTempFileName();
manager.Save(path);
using var loadWorld = World.Create();
manager.Load(path, loadWorld);
var parentQuery = new QueryDescription().WithAll<Parent>();
loadWorld.Query(in parentQuery, (ref Parent p) =>
{
Assert.AreEqual(parentId.Id, p.ParentStableId);
});
File.Delete(path);
}
}