Skip to Content
DocsTinysaveMigrations & Versioning

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:

  1. Scans all assemblies in AppDomain.CurrentDomain
  2. Finds all classes implementing ISaveMigration
  3. Instantiates them and adds them to the migration list
  4. Sorts migrations by FromVersion for 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 4

If a player has a Version 1 save file and your game is on Version 4:

  1. Apply Migration_1_to_2 (Version 1 → 2)
  2. Apply Migration_2_to_3 (Version 2 → 3)
  3. Apply Migration_3_to_4 (Version 3 → 4)
  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:

  1. Logs the error
  2. Stops the migration chain
  3. Returns the file in its current state
  4. Continues loading with partial data

Best Practices

  1. Never skip versions - Create a migration for every version increment
  2. Test migrations - Load old save files after implementing migrations
  3. Keep migrations simple - One logical change per migration
  4. Validate data - Check if fields exist before accessing them
  5. Log migrations - TinySave logs when migrations run
  6. Handle missing data - Not all save files will have all fields
  7. Document changes - Comment why the migration is needed
  8. 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.

Next Steps

Last updated on