file-userSave Data Versioning

Crystal Save provides three complementary approaches to handle save data evolution as your game grows:

Approach
Use Case
Complexity

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

circle-info

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) and mana (order 1) load normally

  • oldUnusedField (order 2) is ignored (field no longer exists)

  • stamina (order 3) gets its default value (0)

Critical Rules

circle-exclamation

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:

circle-info

The legacy type stub doesn't need any properties—it's only used for type name resolution.

When to Use

Scenario
Use LegacyTypesInitializer?

Renamed class (PlayerDataPlayerSaveData)

✅ Yes

Moved namespace (MyGameMyGame.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

  1. Create the MigrationManager asset:

    • Right-click in Project → CreateCrystal SaveSettingsCreate Migration Manager

    • Place it in a Resources folder

  2. Create MigrationAction scripts for your data transformations

  3. Configure migration steps in the MigrationManager inspector

Creating a MigrationAction

Configuring Migration Steps

In the MigrationManager inspector:

Field
Description

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

  1. Save file is loaded with version 1.0.0

  2. Current game version is 1.2.0

  3. MigrationManager finds all steps between 1.0.0 and 1.2.0

  4. Each step's MigrationActions are executed in order

  5. Save data version is updated after each step

  6. Migrated data is then deserialized normally

Version Configuration

Set your current version in SaveSettings:

  1. Select your SaveSettings asset

  2. 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

circle-check
triangle-exclamation

Quick Reference

I want to...
Use

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