Component Schema Migrations
This guide covers everything you need to know about managing component schema evolution in KeenEyes ECS, from basic versioning to advanced migration patterns.
Table of Contents
- Overview
- Quick Start
- Version Management
- Migration Patterns
- Default Value Injection
- Testing Migrations
- Batch Upgrader Tool
- CI/CD Integration
- Troubleshooting
- API Reference
Overview
Games evolve over time - components gain new fields, remove deprecated ones, or restructure data. Without schema evolution support, updating your game breaks existing save files and frustrates players.
KeenEyes provides a versioned component migration system that:
- Preserves save compatibility - Old saves load after component changes
- Provides explicit control - Define exactly how data transforms
- Works with Native AOT - No reflection, fully source-generated
- Chains automatically - Multi-version migrations handled seamlessly
- Catches errors early - Compile-time validation for migration issues
How It Works
Save File (v1) Component (v3)
┌─────────────┐ ┌─────────────┐
│ Health │ │ Health │
│ Current │ ──→ │ Current │
│ Max │ v1→v2 │ Max │
└─────────────┘ ──→ │ Shield │ ← Added in v2
v2→v3 │ Armor │ ← Added in v3
└─────────────┘
When loading a v1 save into a v3 game:
- Deserialize the old JSON data
- Run v1→v2 migration (adds
Shield = 0) - Run v2→v3 migration (adds
Armor = 0) - Deserialize the migrated data into the current
Healthstruct
Quick Start
Step 1: Add Version to Your Component
// Version 1: Original component
[Component(Serializable = true, Version = 1)]
public partial struct Health : IComponent
{
public int Current;
public int Max;
}
Step 2: When You Add a Field, Bump the Version
// Version 2: Added Shield field
[Component(Serializable = true, Version = 2)]
public partial struct Health : IComponent
{
public int Current;
public int Max;
public int Shield; // New field
[MigrateFrom(1)]
internal static Health MigrateFromV1(JsonElement oldData)
{
return new Health
{
Current = oldData.GetProperty("Current").GetInt32(),
Max = oldData.GetProperty("Max").GetInt32(),
Shield = 0 // Default for new field
};
}
}
Step 3: Load Old Saves Automatically
// Old v1 saves work automatically - migration runs during load
var snapshot = SnapshotManager.FromJson(File.ReadAllText("save.json"));
SnapshotManager.RestoreSnapshot(world, snapshot, ComponentSerializer.Instance);
// Player's Health now has Shield = 0
Version Management
When to Bump Version Numbers
| Change Type | Bump Version? | Migration Needed? |
|---|---|---|
| Add new field | Yes | Yes (explicit or [DefaultValue]) |
| Remove field | Yes | Yes (explicit) |
| Rename field | Yes | Yes (explicit) |
| Change field type | Yes | Yes (explicit) |
| Restructure data | Yes | Yes (explicit) |
| Add non-serialized field | No | No |
| Change default value logic | No | No |
| Refactor internal code | No | No |
Version Numbering Rules
- Start at 1 - All components default to version 1
- Increment by 1 - Always bump to the next integer (1 → 2 → 3)
- Never skip versions - Creates gaps in migration chain (KEEN114 warning)
- Never decrease - Breaks existing saves
// ❌ BAD: Skipped versions
[Component(Serializable = true, Version = 5)] // Where's v2, v3, v4?
// ✅ GOOD: Sequential versions
[Component(Serializable = true, Version = 2)]
Non-Serializable Components
Components without Serializable = true don't need versioning:
// No serialization = no migration needed
[Component]
public partial struct RuntimeState : IComponent
{
public float ElapsedTime;
public bool IsProcessing;
}
Migration Patterns
Pattern 1: Adding a Field
The most common migration - add a new field with a sensible default.
[Component(Serializable = true, Version = 2)]
public partial struct Position : IComponent
{
public float X;
public float Y;
public float Z; // Added in v2
[MigrateFrom(1)]
private static Position MigrateFromV1(JsonElement oldData)
{
return new Position
{
X = oldData.GetProperty("X").GetSingle(),
Y = oldData.GetProperty("Y").GetSingle(),
Z = 0f // New entities start at ground level
};
}
}
Pattern 2: Removing a Field
When removing a field, you simply don't include it in the migration:
// v1 had: Foo, Bar, Baz
// v2 removes: Baz
[Component(Serializable = true, Version = 2)]
public partial struct MyComponent : IComponent
{
public int Foo;
public int Bar;
// Baz removed
[MigrateFrom(1)]
private static MyComponent MigrateFromV1(JsonElement oldData)
{
return new MyComponent
{
Foo = oldData.GetProperty("Foo").GetInt32(),
Bar = oldData.GetProperty("Bar").GetInt32()
// Baz is simply not copied - data is discarded
};
}
}
Pattern 3: Renaming a Field
Map the old field name to the new field name:
[Component(Serializable = true, Version = 2)]
public partial struct Character : IComponent
{
public int Vitality; // Was "Health" in v1
[MigrateFrom(1)]
private static Character MigrateFromV1(JsonElement oldData)
{
return new Character
{
Vitality = oldData.GetProperty("Health").GetInt32() // Old name
};
}
}
Pattern 4: Changing Field Types
Convert between types during migration:
// v1: Used int for coordinates
// v2: Changed to float for precision
[Component(Serializable = true, Version = 2)]
public partial struct Position : IComponent
{
public float X; // Was int
public float Y; // Was int
[MigrateFrom(1)]
private static Position MigrateFromV1(JsonElement oldData)
{
return new Position
{
X = oldData.GetProperty("X").GetInt32(), // int → float
Y = oldData.GetProperty("Y").GetInt32()
};
}
}
Pattern 5: Restructuring Data
Transform data from a flat structure to nested, or vice versa:
// v1: Flat structure with X, Y
// v2: Nested structure with Vitality containing Current/Max
[Component(Serializable = true, Version = 2)]
public partial struct Character : IComponent
{
public VitalityData Vitality;
[MigrateFrom(1)]
private static Character MigrateFromV1(JsonElement oldData)
{
return new Character
{
Vitality = new VitalityData
{
Current = oldData.GetProperty("Health").GetInt32(),
Max = oldData.GetProperty("MaxHealth").GetInt32()
}
};
}
}
public struct VitalityData
{
public int Current;
public int Max;
}
Pattern 6: Computing Derived Values
Calculate new field values from old data:
[Component(Serializable = true, Version = 2)]
public partial struct Stats : IComponent
{
public int Strength;
public int Agility;
public int Intelligence;
public int TotalPower; // New: computed field
[MigrateFrom(1)]
private static Stats MigrateFromV1(JsonElement oldData)
{
var str = oldData.GetProperty("Strength").GetInt32();
var agi = oldData.GetProperty("Agility").GetInt32();
var intel = oldData.GetProperty("Intelligence").GetInt32();
return new Stats
{
Strength = str,
Agility = agi,
Intelligence = intel,
TotalPower = str + agi + intel // Computed during migration
};
}
}
Pattern 7: Multi-Version Migration Chain
When a component evolves through multiple versions:
[Component(Serializable = true, Version = 4)]
public partial struct Health : IComponent
{
public int Current;
public int Max;
public int Shield; // Added in v2
public int Armor; // Added in v3
public float Regen; // Added in v4
[MigrateFrom(1)]
private static Health MigrateFromV1(JsonElement oldData)
{
return new Health
{
Current = oldData.GetProperty("Current").GetInt32(),
Max = oldData.GetProperty("Max").GetInt32(),
Shield = 0,
Armor = 0,
Regen = 0f
};
}
[MigrateFrom(2)]
private static Health MigrateFromV2(JsonElement oldData)
{
return new Health
{
Current = oldData.GetProperty("Current").GetInt32(),
Max = oldData.GetProperty("Max").GetInt32(),
Shield = oldData.GetProperty("Shield").GetInt32(),
Armor = 0,
Regen = 0f
};
}
[MigrateFrom(3)]
private static Health MigrateFromV3(JsonElement oldData)
{
return new Health
{
Current = oldData.GetProperty("Current").GetInt32(),
Max = oldData.GetProperty("Max").GetInt32(),
Shield = oldData.GetProperty("Shield").GetInt32(),
Armor = oldData.GetProperty("Armor").GetInt32(),
Regen = 0f
};
}
}
Automatic Chaining: Loading a v1 save into this v4 component runs:
v1 → v2 → v3 → v4 automatically.
Pattern 8: Conditional Migration
Apply different logic based on old data values:
[MigrateFrom(1)]
private static Equipment MigrateFromV1(JsonElement oldData)
{
var weaponType = oldData.GetProperty("WeaponType").GetString();
// Different defaults based on weapon type
var baseDamage = weaponType switch
{
"Sword" => 10,
"Bow" => 8,
"Staff" => 5,
_ => 1
};
return new Equipment
{
WeaponType = weaponType ?? "Unknown",
BaseDamage = baseDamage,
EnchantmentLevel = 0 // New field
};
}
Default Value Injection
For simple additions (new fields with constant defaults), use [DefaultValue] to auto-generate migrations:
[Component(Serializable = true, Version = 2)]
public partial struct Health : IComponent
{
public int Current;
public int Max;
[DefaultValue(0)]
public int Shield; // Auto-migrated with Shield = 0
}
The source generator creates the migration automatically - no explicit [MigrateFrom] needed.
When to Use [DefaultValue] vs Explicit Migration
| Scenario | Use [DefaultValue] |
Use [MigrateFrom] |
|---|---|---|
| Add field with constant default | ✅ | Overkill |
| Add field computed from old data | ❌ | ✅ |
| Rename a field | ❌ | ✅ |
| Change field type | ❌ | ✅ |
| Restructure data | ❌ | ✅ |
| Remove a field | ❌ | ✅ |
Combining [DefaultValue] and [MigrateFrom]
You can use both - [DefaultValue] handles simple additions while explicit migrations handle complex transformations:
[Component(Serializable = true, Version = 3)]
public partial struct Character : IComponent
{
public string Name;
public int Level;
[DefaultValue(100)]
public int MaxHealth; // v2: auto-migrated
public int CurrentHealth;
// v2→v3: Complex migration (CurrentHealth was "HP" in v2)
[MigrateFrom(2)]
private static Character MigrateFromV2(JsonElement oldData)
{
return new Character
{
Name = oldData.GetProperty("Name").GetString() ?? "",
Level = oldData.GetProperty("Level").GetInt32(),
MaxHealth = oldData.GetProperty("MaxHealth").GetInt32(),
CurrentHealth = oldData.GetProperty("HP").GetInt32() // Renamed
};
}
}
Testing Migrations
Unit Testing Migration Functions
Test each migration function in isolation:
public class HealthMigrationTests
{
[Fact]
public void MigrateFromV1_AddsDefaultShield()
{
// Arrange: Create v1 JSON data
var v1Data = JsonSerializer.SerializeToElement(new
{
Current = 50,
Max = 100
});
// Act: Run migration
var result = InvokeMigration<Health>(1, v1Data);
// Assert: Shield has default value
Assert.Equal(50, result.Current);
Assert.Equal(100, result.Max);
Assert.Equal(0, result.Shield);
}
[Fact]
public void MigrateFromV2_PreservesAllFields()
{
var v2Data = JsonSerializer.SerializeToElement(new
{
Current = 75,
Max = 100,
Shield = 25
});
var result = InvokeMigration<Health>(2, v2Data);
Assert.Equal(75, result.Current);
Assert.Equal(100, result.Max);
Assert.Equal(25, result.Shield);
Assert.Equal(0, result.Armor); // New default
}
private static T InvokeMigration<T>(int fromVersion, JsonElement data)
where T : struct, IComponent
{
var migrator = ComponentSerializer.Instance as IComponentMigrator;
var currentVersion = ComponentMigrationMetadata.GetVersion<T>();
var migrated = migrator!.Migrate(typeof(T), data, fromVersion, currentVersion);
return JsonSerializer.Deserialize<T>(migrated!.Value.GetRawText());
}
}
Integration Testing with Snapshots
Test full save/load cycles with old data:
[Fact]
public void LoadV1Save_MigratesToCurrentVersion()
{
// Load pre-made v1 save file
var v1Json = File.ReadAllText("TestData/save_v1.json");
var snapshot = SnapshotManager.FromJson(v1Json);
using var world = new World();
SnapshotManager.RestoreSnapshot(world, snapshot!, ComponentSerializer.Instance);
// Verify migrated data
var player = world.GetEntityByName("Player");
var health = world.Get<Health>(player);
Assert.Equal(100, health.Current);
Assert.Equal(100, health.Max);
Assert.Equal(0, health.Shield); // Default from migration
}
Creating Test Save Files
Create saves at each version for regression testing:
# Create saves at each version
samples/SchemaMigration/
TestData/
save_v1.json # Health with Current, Max only
save_v2.json # Health with Current, Max, Shield
save_v3.json # Current version
See the samples/SchemaMigration project for pre-made test files.
Batch Upgrader Tool
Upgrade save files in bulk using the CLI tool:
Preview Mode (Dry Run)
See what would be migrated without making changes:
dotnet keeneyes migrate --path ./saves/ --dry-run
Output:
Analyzing save files...
save1.json:
Health v1 → v3 (12 entities)
Inventory v2 → v3 (5 entities)
save2.json:
Health v1 → v3 (8 entities)
Summary:
2 files need migration
25 component instances to upgrade
No errors detected
Apply Migrations
Upgrade files with automatic backup:
dotnet keeneyes migrate --path ./saves/ --backup
This creates .backup files before modifying originals.
Options
| Flag | Description |
|---|---|
--path <dir> |
Directory containing save files |
--pattern <glob> |
File pattern (default: *.json) |
--dry-run |
Preview only, don't modify files |
--backup |
Create .backup files before upgrading |
--recursive |
Search subdirectories |
--verbose |
Show detailed migration steps |
CI/CD Integration
Pre-Commit Validation
Add migration gap detection to your build:
# .github/workflows/build.yml
- name: Check Migration Gaps
run: |
dotnet build 2>&1 | grep -E "KEEN114|KEEN115" && exit 1 || exit 0
Migration Test Matrix
Test migrations from all historical versions:
strategy:
matrix:
save_version: [1, 2, 3]
steps:
- name: Test Migration from v${{ matrix.save_version }}
run: |
dotnet test --filter "Category=Migration&Version=${{ matrix.save_version }}"
Version Tracking
Track component versions in your build output:
// Build-time: Generate version manifest
// Generated by source generator
public static class ComponentVersionManifest
{
public static IReadOnlyDictionary<string, int> Versions { get; } = new Dictionary<string, int>
{
["Health"] = 3,
["Inventory"] = 2,
["Position"] = 1,
// ...
};
}
Upgrade Path Documentation
Document supported upgrade paths:
## Supported Save Versions
| Component | Min Version | Current | Notes |
|-----------|-------------|---------|-------|
| Health | 1 | 3 | Full migration chain |
| Inventory | 2 | 3 | v1 no longer supported |
| Position | 1 | 1 | No migrations |
Troubleshooting
Common Errors
KEEN110: Migration method must be static
// ❌ BAD: Instance method
[MigrateFrom(1)]
private Health MigrateFromV1(JsonElement data) { ... }
// ✅ GOOD: Static method
[MigrateFrom(1)]
private static Health MigrateFromV1(JsonElement data) { ... }
KEEN111: Migration method must return component type
// ❌ BAD: Wrong return type
[MigrateFrom(1)]
private static object MigrateFromV1(JsonElement data) { ... }
// ✅ GOOD: Returns containing type
[MigrateFrom(1)]
private static Health MigrateFromV1(JsonElement data) { ... }
KEEN112: Migration method must take JsonElement parameter
// ❌ BAD: Wrong parameter type
[MigrateFrom(1)]
private static Health MigrateFromV1(string data) { ... }
// ✅ GOOD: Takes JsonElement
[MigrateFrom(1)]
private static Health MigrateFromV1(JsonElement data) { ... }
KEEN113: Migration version must be less than component version
// ❌ BAD: MigrateFrom(2) but component is Version = 2
[Component(Serializable = true, Version = 2)]
public partial struct Health
{
[MigrateFrom(2)] // Error: 2 >= 2
private static Health MigrateFromV2(JsonElement data) { ... }
}
// ✅ GOOD: MigrateFrom version < component version
[Component(Serializable = true, Version = 3)]
public partial struct Health
{
[MigrateFrom(2)] // OK: 2 < 3
private static Health MigrateFromV2(JsonElement data) { ... }
}
KEEN114: Missing migration for version (Warning)
// ⚠️ WARNING: Gap in migration chain
[Component(Serializable = true, Version = 3)]
public partial struct Health
{
[MigrateFrom(1)] // v1 → v2 defined
// Missing [MigrateFrom(2)] // v2 → v3 not defined!
}
// ✅ GOOD: Complete chain
[Component(Serializable = true, Version = 3)]
public partial struct Health
{
[MigrateFrom(1)]
private static Health MigrateFromV1(JsonElement data) { ... }
[MigrateFrom(2)]
private static Health MigrateFromV2(JsonElement data) { ... }
}
KEEN115: Duplicate migration for version
// ❌ BAD: Two migrations for same version
[MigrateFrom(1)]
private static Health MigrateFromV1A(JsonElement data) { ... }
[MigrateFrom(1)] // Error: duplicate
private static Health MigrateFromV1B(JsonElement data) { ... }
KEEN116: Migration method must be in a component type
// ❌ BAD: Migration in non-component type
public static class Migrations
{
[MigrateFrom(1)] // Error: not in [Component] type
public static Health MigrateHealth(JsonElement data) { ... }
}
// ✅ GOOD: Migration inside component
[Component(Serializable = true, Version = 2)]
public partial struct Health
{
[MigrateFrom(1)]
private static Health MigrateFromV1(JsonElement data) { ... }
}
KEEN117: Component must be serializable to use migrations
// ❌ BAD: Not serializable
[Component(Version = 2)] // Missing Serializable = true
public partial struct Health
{
[MigrateFrom(1)] // Error: no serialization = no migration
private static Health MigrateFromV1(JsonElement data) { ... }
}
// ✅ GOOD: Serializable component
[Component(Serializable = true, Version = 2)]
public partial struct Health
{
[MigrateFrom(1)]
private static Health MigrateFromV1(JsonElement data) { ... }
}
KEEN118: DefaultValue type mismatch
// ❌ BAD: Type mismatch
[DefaultValue("hello")] // String, but field is int
public int Count;
// ✅ GOOD: Matching types
[DefaultValue(0)]
public int Count;
[DefaultValue("")]
public string Name;
Runtime Exceptions
ComponentVersionException: Future Version
Cannot load Health v5 (current version is v3).
The save file was created with a newer version of the application.
Update the application to load this save file.
Cause: Save file has newer component version than current code.
Solution: You cannot downgrade. Either:
- Update to the newer version of your game
- Use the save file with the version that created it
ComponentVersionException: Missing Migration Path
Cannot migrate Health from v1 to v3. No migration path is available.
Cause: Gap in migration chain (missing v2 migration).
Solution: Add the missing migration or use [DefaultValue] for simple additions.
Debugging Migrations
Use IMigrationDiagnostics for detailed debugging:
var diagnostics = ComponentSerializer.Instance as IMigrationDiagnostics;
// Get migration graph for a component
var graph = diagnostics!.GetMigrationGraph(typeof(Health));
Console.WriteLine(graph?.ToDiagnosticString());
// Output:
// Migration Graph for Health
// Current Version: 3
// Edges: 2
//
// Migrations:
// v1 → v2
// v2 → v3
// Find all migration gaps
var gaps = diagnostics.FindAllMigrationGaps();
foreach (var (component, versions) in gaps)
{
Console.WriteLine($"{component} missing: {string.Join(", ", versions)}");
}
// Generate full diagnostic report
Console.WriteLine(diagnostics.GenerateDiagnosticReport());
API Reference
Attributes
[Component]
Marks a struct as an ECS component.
[Component(Serializable = true, Version = 2)]
public partial struct Health : IComponent { }
| Property | Type | Default | Description |
|---|---|---|---|
Serializable |
bool |
false |
Enable serialization support |
Version |
int |
1 |
Current schema version |
[MigrateFrom]
Marks a static method as a migration handler.
[MigrateFrom(1)]
internal static Health MigrateFromV1(JsonElement data) { ... }
| Parameter | Type | Description |
|---|---|---|
fromVersion |
int |
Source version (must be ≥ 1 and < component version) |
Method Requirements:
- Must be
static - Must be
internalorpublic(the generated serializer needs to access the method) - Must return the containing component type
- Must take a single
System.Text.Json.JsonElementparameter
[DefaultValue]
Specifies a default value for new fields during auto-migration.
[DefaultValue(100)]
public int MaxHealth;
| Parameter | Type | Description |
|---|---|---|
value |
object? |
Default value (must be compatible with field type) |
Interfaces
IComponentMigrator
Core interface for component migration.
public interface IComponentMigrator
{
bool CanMigrate(Type type, int fromVersion, int toVersion);
JsonElement? Migrate(Type type, JsonElement data, int fromVersion, int toVersion);
IEnumerable<int> GetMigrationVersions(Type type);
}
IMigrationDiagnostics
Extended interface with diagnostic capabilities.
public interface IMigrationDiagnostics : IComponentMigrator
{
MigrationResult MigrateWithDiagnostics(Type type, JsonElement data, int from, int to);
MigrationGraph? GetMigrationGraph(Type type);
IEnumerable<string> GetComponentsWithMigrations();
IReadOnlyDictionary<string, IReadOnlyList<int>> FindAllMigrationGaps();
string GenerateDiagnosticReport();
}
Classes
MigrationGraph
Represents the migration paths for a component.
var graph = new MigrationGraph("Health", currentVersion: 3);
graph.AddEdge(1, 2);
graph.AddEdge(2, 3);
graph.HasPath(1, 3); // true
graph.GetMigrationChain(1, 3); // [(1,2), (2,3)]
graph.FindGaps(); // []
graph.ToDiagnosticString(); // Human-readable output
MigrationResult
Encapsulates migration execution results.
var result = migrator.MigrateWithDiagnostics(typeof(Health), data, 1, 3);
if (result.Success)
{
Console.WriteLine($"Migrated in {result.TotalElapsed.TotalMilliseconds}ms");
foreach (var step in result.StepTimings)
{
Console.WriteLine($" {step.Step}: {step.Elapsed.TotalMilliseconds}ms");
}
}
else
{
Console.WriteLine($"Failed at v{result.FailedAtVersion}: {result.ErrorMessage}");
}
ComponentVersionException
Thrown when version mismatch prevents loading.
catch (ComponentVersionException ex)
{
Console.WriteLine($"Component: {ex.ComponentName}");
Console.WriteLine($"Saved: v{ex.SerializedVersion}");
Console.WriteLine($"Current: v{ex.CurrentVersion}");
}
Generated Classes
ComponentMigrationMetadata
Provides version lookup for components.
// Generic (compile-time optimized)
int version = ComponentMigrationMetadata.GetVersion<Health>();
// Runtime type lookup
int version = ComponentMigrationMetadata.GetVersion(typeof(Health));
// Type name lookup
int version = ComponentMigrationMetadata.GetVersion("MyGame.Health");
// Get all versions
var allVersions = ComponentMigrationMetadata.GetAllVersions();
Related Documentation
- Serialization Guide - World snapshots and save/load
- ADR-015: Component Schema Migrations - Design decision record
- Sample: SchemaMigration - Working example with v1→v2→v3 evolution