Migrations & Versioning
As your game evolves, your save data structure will change. TinySave’s migration system lets you upgrade old save files to work with new code, ensuring players never lose their progress.
How Migrations Work
TinySave tracks save format versions using the formatVersion field in save metadata. When loading a save file with an older version, TinySave automatically applies migrations to bring it up to date.
Format Version
public static class SaveDatabase
{
public const int CurrentFormatVersion = 1;
}The current format version is defined in SaveDatabase. Each save file includes this version number in its metadata:
public struct SaveMetadata
{
public int formatVersion; // Version of this save file
public string version; // Your game version (optional)
public long timestamp;
public string build;
public List<string> scenes;
public bool compressed;
}ISaveMigration Interface
Create migrations by implementing the ISaveMigration interface:
public interface ISaveMigration
{
int FromVersion { get; }
int ToVersion { get; }
bool TryMigrate(ref SaveFile file);
}Migration Properties
- FromVersion - The version this migration upgrades from
- ToVersion - The version this migration upgrades to
- TryMigrate - Performs the migration, returns true on success
Creating a Migration
Example: Renaming a Field
Let’s say you renamed a field from playerHealth to health:
using TinySave.Runtime;
public class Migration_1_to_2 : ISaveMigration
{
public int FromVersion => 1;
public int ToVersion => 2;
public bool TryMigrate(ref SaveFile file)
{
// Iterate through all entities in the save file
foreach (var entity in file.entities)
{
// Find PlayerStats components
foreach (var component in entity.components)
{
if (component.type == "PlayerStats")
{
// Rename the field if it exists
if (component.fields.ContainsKey("playerHealth"))
{
var health = component.fields["playerHealth"];
component.fields["health"] = health;
component.fields.Remove("playerHealth");
}
}
}
}
return true;
}
}Example: Adding a Default Field
Adding a new field with a default value:
public class Migration_2_to_3 : ISaveMigration
{
public int FromVersion => 2;
public int ToVersion => 3;
public bool TryMigrate(ref SaveFile file)
{
foreach (var entity in file.entities)
{
foreach (var component in entity.components)
{
if (component.type == "PlayerStats")
{
// Add new field with default value if it doesn't exist
if (!component.fields.ContainsKey("maxHealth"))
{
component.fields["maxHealth"] = 100;
}
}
}
}
return true;
}
}Example: Changing Data Types
Converting an int to a float:
public class Migration_3_to_4 : ISaveMigration
{
public int FromVersion => 3;
public int ToVersion => 4;
public bool TryMigrate(ref SaveFile file)
{
foreach (var entity in file.entities)
{
foreach (var component in entity.components)
{
if (component.type == "PlayerStats")
{
// Convert health from int to float
if (component.fields.ContainsKey("health"))
{
int oldHealth = (int)component.fields["health"];
component.fields["health"] = (float)oldHealth;
}
}
}
}
return true;
}
}Example: Restructuring Data
Converting a flat structure to nested:
public class Migration_4_to_5 : ISaveMigration
{
public int FromVersion => 4;
public int ToVersion => 5;
public bool TryMigrate(ref SaveFile file)
{
foreach (var entity in file.entities)
{
foreach (var component in entity.components)
{
if (component.type == "PlayerStats")
{
// Old structure: health, maxHealth, shield, maxShield
// New structure: healthData { current, max }, shieldData { current, max }
var healthData = new Dictionary<string, object>
{
{ "current", component.fields["health"] },
{ "max", component.fields["maxHealth"] }
};
var shieldData = new Dictionary<string, object>
{
{ "current", component.fields["shield"] },
{ "max", component.fields["maxShield"] }
};
component.fields["healthData"] = healthData;
component.fields["shieldData"] = shieldData;
// Remove old fields
component.fields.Remove("health");
component.fields.Remove("maxHealth");
component.fields.Remove("shield");
component.fields.Remove("maxShield");
}
}
}
return true;
}
}Migration Discovery
TinySave automatically discovers all migration classes at startup:
- Scans all assemblies in
AppDomain.CurrentDomain - Finds all classes implementing
ISaveMigration - Instantiates them and adds them to the migration list
- Sorts migrations by
FromVersionfor sequential application
You don’t need to register migrations manually - just create the class and TinySave finds it.
Migration Execution
When loading a save file, TinySave checks the format version and applies migrations:
public static SaveFile Migrate(SaveFile file)
{
// Already current version - skip
if (file.meta.formatVersion == CurrentFormatVersion)
return file;
// Future version - cannot load
if (file.meta.formatVersion > CurrentFormatVersion)
{
TinyLog.Warning($"Save file version {file.meta.formatVersion} is newer than current version {CurrentFormatVersion}");
return file;
}
// Apply migrations sequentially
int currentVersion = file.meta.formatVersion;
while (currentVersion < CurrentFormatVersion)
{
bool migrated = false;
foreach (ISaveMigration migration in _migrations)
{
if (migration.FromVersion == currentVersion)
{
if (migration.TryMigrate(ref file))
{
currentVersion = migration.ToVersion;
file.meta.formatVersion = currentVersion;
migrated = true;
break;
}
}
}
if (!migrated)
{
TinyLog.Warning($"No migration found from version {currentVersion}");
break;
}
}
return file;
}Migration Chain
Migrations are applied sequentially, so you can chain them:
Version 1 → Migration_1_to_2 → Version 2
Version 2 → Migration_2_to_3 → Version 3
Version 3 → Migration_3_to_4 → Version 4If a player has a Version 1 save file and your game is on Version 4:
- Apply Migration_1_to_2 (Version 1 → 2)
- Apply Migration_2_to_3 (Version 2 → 3)
- Apply Migration_3_to_4 (Version 3 → 4)
- Save file is now current
Working with SaveFile Structure
Understanding the save file structure is crucial for migrations:
public struct SaveFile
{
public SaveMetadata meta; // File metadata
public List<EntityRecord> entities; // GameObject save data
public List<AssetRecord> assets; // ScriptableObject save data
}
public struct EntityRecord
{
public string id; // SaveID
public string scene; // Scene name
public string prefab; // Prefab path
public bool active; // GameObject.activeSelf
public string name; // GameObject.name
public string tag; // GameObject.tag
public int layer; // GameObject.layer
public UnityEngine.HideFlags hideFlags; // GameObject.hideFlags
public List<ComponentRecord> components; // Component data
}
public struct ComponentRecord
{
public string type; // Component type name
public Dictionary<string, object> fields; // Field name → value
}
public struct AssetRecord
{
public string guid; // Asset GUID
public string type; // Asset type name
public Dictionary<string, object> fields; // Field name → value
}Advanced Migration Examples
Migrating Across Multiple Components
public class Migration_5_to_6 : ISaveMigration
{
public int FromVersion => 5;
public int ToVersion => 6;
public bool TryMigrate(ref SaveFile file)
{
foreach (var entity in file.entities)
{
// Find both PlayerStats and PlayerInventory on same entity
ComponentRecord stats = null;
ComponentRecord inventory = null;
foreach (var component in entity.components)
{
if (component.type == "PlayerStats")
stats = component;
else if (component.type == "PlayerInventory")
inventory = component;
}
// Migrate gold from PlayerStats to PlayerInventory
if (stats != null && inventory != null)
{
if (stats.fields.ContainsKey("gold"))
{
inventory.fields["gold"] = stats.fields["gold"];
stats.fields.Remove("gold");
}
}
}
return true;
}
}Migrating Asset Data
public class Migration_6_to_7 : ISaveMigration
{
public int FromVersion => 6;
public int ToVersion => 7;
public bool TryMigrate(ref SaveFile file)
{
// Migrate ScriptableObject save data
foreach (var asset in file.assets)
{
if (asset.type == "GameSettings")
{
// Add new graphics setting with default
if (!asset.fields.ContainsKey("shadowQuality"))
{
asset.fields["shadowQuality"] = 2; // Medium
}
}
}
return true;
}
}Conditional Migration
public class Migration_7_to_8 : ISaveMigration
{
public int FromVersion => 7;
public int ToVersion => 8;
public bool TryMigrate(ref SaveFile file)
{
foreach (var entity in file.entities)
{
foreach (var component in entity.components)
{
if (component.type == "Enemy")
{
// Only migrate enemies in specific scenes
if (entity.scene == "BossLevel")
{
// Boss enemies get a health multiplier
if (component.fields.ContainsKey("health"))
{
int health = (int)component.fields["health"];
component.fields["health"] = health * 2;
}
}
}
}
}
return true;
}
}Handling Migration Failures
Migrations can fail gracefully:
public class Migration_8_to_9 : ISaveMigration
{
public int FromVersion => 8;
public int ToVersion => 9;
public bool TryMigrate(ref SaveFile file)
{
try
{
// Attempt migration
foreach (var entity in file.entities)
{
// ... migration logic ...
}
return true;
}
catch (System.Exception ex)
{
TinyLog.Error($"Migration 8→9 failed: {ex.Message}");
return false;
}
}
}If a migration returns false or throws an exception, TinySave:
- Logs the error
- Stops the migration chain
- Returns the file in its current state
- Continues loading with partial data
Best Practices
- Never skip versions - Create a migration for every version increment
- Test migrations - Load old save files after implementing migrations
- Keep migrations simple - One logical change per migration
- Validate data - Check if fields exist before accessing them
- Log migrations - TinySave logs when migrations run
- Handle missing data - Not all save files will have all fields
- Document changes - Comment why the migration is needed
- Preserve old migrations - Don’t delete migrations even after players update
Updating Format Version
When you create a new migration, update the format version:
// In your own code or configuration
public static class SaveDatabase
{
public const int CurrentFormatVersion = 9; // Increment when adding migration
}TinySave will automatically apply all migrations from the save file’s version to the current version.
Debugging Migrations
Enable verbose logging to see migration details:
// In your project settings or at startup
TinySaveSettings.defaultSettings.logLevel = LogLevel.Verbose;Migration logs will show:
- Which migrations were discovered
- Which migrations are being applied
- Success/failure of each migration
- Version transitions
Example: Complete Migration Workflow
Let’s walk through a real-world scenario:
Version 1 - Initial Release:
public class PlayerStats : MonoBehaviour
{
[SaveField] public int health;
[SaveField] public string playerName;
}Version 2 - Add max health:
public class PlayerStats : MonoBehaviour
{
[SaveField] public int health;
[SaveField] public int maxHealth; // NEW
[SaveField] public string playerName;
}
public class Migration_1_to_2 : ISaveMigration
{
public int FromVersion => 1;
public int ToVersion => 2;
public bool TryMigrate(ref SaveFile file)
{
foreach (var entity in file.entities)
{
foreach (var component in entity.components)
{
if (component.type == "PlayerStats")
{
if (!component.fields.ContainsKey("maxHealth"))
{
// Default to current health
int health = component.fields.ContainsKey("health") ? (int)component.fields["health"] : 100;
component.fields["maxHealth"] = health;
}
}
}
}
return true;
}
}Version 3 - Convert to float:
public class PlayerStats : MonoBehaviour
{
[SaveField] public float health; // Changed from int
[SaveField] public float maxHealth; // Changed from int
[SaveField] public string playerName;
}
public class Migration_2_to_3 : ISaveMigration
{
public int FromVersion => 2;
public int ToVersion => 3;
public bool TryMigrate(ref SaveFile file)
{
foreach (var entity in file.entities)
{
foreach (var component in entity.components)
{
if (component.type == "PlayerStats")
{
if (component.fields.ContainsKey("health"))
component.fields["health"] = (float)(int)component.fields["health"];
if (component.fields.ContainsKey("maxHealth"))
component.fields["maxHealth"] = (float)(int)component.fields["maxHealth"];
}
}
}
return true;
}
}Now if a player loads a Version 1 save file, both migrations run automatically.