Save Data Versioning
Crystal Save provides three complementary approaches to handle save data evolution as your game grows:
Schema Evolution
Adding/removing fields in SaveData classes
Low
LegacyTypesInitializer
Renaming classes or changing namespaces
Medium
MigrationManager
Transforming data values between versions
High
Schema Evolution with MemoryPack
MemoryPack provides two serialization modes for handling schema changes. Choose the one that fits your needs:
Mode
Add Fields
Delete Fields
Requires [MemoryPackOrder]
Performance
Default (GenerateType.Object)
✅ Yes
❌ No
❌ No
Fastest
VersionTolerant (GenerateType.VersionTolerant)
✅ Yes
✅ Yes
✅ Yes
Slightly slower
Recommendation: Use GenerateType.VersionTolerant for SaveData classes in games under active development. The small performance cost is worth the flexibility to both add AND remove fields.
Default Mode (Add-Only)
The default [MemoryPackable] attribute supports adding new fields at the end, but you cannot delete fields.
Rules for Default Mode
✅ Can add new members (append only)
❌ Cannot delete members
✅ Can rename members
❌ Cannot change member order
❌ Cannot change member type
VersionTolerant Mode (Add & Delete)
Use GenerateType.VersionTolerant when you need the flexibility to both add and delete fields.
The Problem with Default Mode
If you delete a field in default mode, old saves will break because MemoryPack uses positional serialization:
The Solution
Use GenerateType.VersionTolerant with [MemoryPackOrder] attributes:
Rules for VersionTolerant Mode
✅ Can add members (with new order numbers)
✅ Can delete members (but cannot reuse their order numbers)
✅ Can rename members
❌ Cannot change member order
❌ Cannot change member type
⚠️ All members must have explicit
[MemoryPackOrder]
How It Works
MemoryPack serializes by order number instead of position
Old saves missing new fields → fields get default values
New saves with deleted fields → missing orders are skipped during deserialization
Example: Adding and Deleting Fields
When loading a v1.0 save in v1.1:
health(order 0) andmana(order 1) load normallyoldUnusedField(order 2) is ignored (field no longer exists)stamina(order 3) gets its default value (0)
Critical Rules
Critical Rules for VersionTolerant:
Never change existing order numbers
Never reuse a deleted order number
Always add new fields with the next available order number
All members must have
[MemoryPackOrder]- no exceptions
LegacyTypesInitializer
Use this when you need to rename a class or move it to a different namespace.
The Problem
Save files store the full type name (including namespace). If you rename a class:
Old saves reference MyGame.PlayerData which no longer exists → deserialization fails.
The Solution
Register the legacy type mapping in LegacyTypesInitializer.cs:
Creating a Legacy Type Stub
You need to keep a minimal stub of the old type for the registry to reference:
The legacy type stub doesn't need any properties—it's only used for type name resolution.
When to Use
Renamed class (PlayerData → PlayerSaveData)
✅ Yes
Moved namespace (MyGame → MyGame.Save)
✅ Yes
Added/removed fields
❌ No (use VersionTolerant)
Changed field types
❌ No (use MigrationManager)
MigrationManager
Use this when you need to transform data values between versions, such as converting field types or restructuring data.
Overview
The MigrationManager is a ScriptableObject that holds a list of migration steps, each targeting a specific version.
Setup
Create the MigrationManager asset:
Right-click in Project → Create → Crystal Save → Settings → Create Migration Manager
Place it in a
Resourcesfolder
Create MigrationAction scripts for your data transformations
Configure migration steps in the MigrationManager inspector
Creating a MigrationAction
Configuring Migration Steps
In the MigrationManager inspector:
Target Version
The version this migration upgrades TO
Description
Human-readable description of the migration
Migration Actions
List of MigrationAction assets to execute
How It Works
Save file is loaded with version
1.0.0Current game version is
1.2.0MigrationManager finds all steps between
1.0.0and1.2.0Each step's MigrationActions are executed in order
Save data version is updated after each step
Migrated data is then deserialized normally
Version Configuration
Set your current version in SaveSettings:
Select your SaveSettings asset
Set the Version field (Major.Minor.Patch)
The VersionManager compares save file versions against this setting.
Choosing the Right Approach
Combined Example
A major refactor might require all three:
Best Practices
Recommendations:
Use VersionTolerant on SaveData classes if you anticipate needing to delete fields later
Use Default mode if you only ever add fields (simpler, slightly faster)
Add order numbers sequentially, never skip numbers
Document each order number's purpose in comments
Test migrations with actual save files before release
Keep legacy type stubs even after several versions—some players may have very old saves
Avoid:
Changing existing
[MemoryPackOrder]numbersReusing order numbers from deleted fields
Deleting fields when using default mode (use VersionTolerant instead)
Removing legacy type registrations too early
Deploying without testing migration paths
Quick Reference
Add a new field to my SaveData
Default mode (append) or VersionTolerant
Remove/delete an old field
VersionTolerant mode only
Rename a SaveData class
LegacyTypesInitializer
Move a class to a different namespace
LegacyTypesInitializer
Change a field's type (int → float)
MigrationManager
Transform data values
MigrationManager
Last updated