Skip to main content

View as Markdown · AI documentation index

Save Migration System

A complete solution for versioning and migrating game saves in your text game engine.

Table of Contents​

Overview​

The migration system allows you to make changes to your game's save structure while maintaining compatibility with existing player saves. When a player loads an old save, the system automatically applies registered migrations to bring the save data up to date.

Key Features​

  • ✅ Automatic: Migrations run automatically when loading saves
  • ✅ Sequential: Migrates step-by-step through version history (safer than big jumps)
  • ✅ Testable: Each migration is a pure function
  • ✅ Transparent: Logs what migrations were applied
  • ✅ Safe: Validates migration chain in dev mode
  • ✅ Simple: Just 1 function per breaking change

Quick Start​

1. Register Migrations​

When you make a breaking change to your save structure, register a migration:

import { registerMigration } from "@react-text-game/core/saves";

// After updating your game version to 1.1.0
registerMigration({
from: "1.0.0",
to: "1.1.0",
description: "Added player inventory system",
migrate: (save) => ({
...save,
player: {
...save.player,
inventory: [], // Add default value for new field
},
}),
});

2. That's It!​

Migrations are automatically applied when players load their saves. No additional code needed!

How It Works​

Migration Chain​

When a player loads a save from an older version, the system:

  1. Detects the version difference
  2. Finds the shortest migration path using BFS
  3. Applies each migration sequentially
  4. Validates the result
  5. Loads the migrated data

Example migration chain:

Save v1.0.0 → Migration(1.0.0→1.1.0) → Migration(1.1.0→1.2.0) → Migration(1.2.0→2.0.0) → Current v2.0.0

Validation​

In dev mode, the system validates your migration chain on startup:

  • Checks for orphaned versions
  • Ensures all base versions can reach the current version
  • Warns about dead ends

Usage Guide​

Where to Register Migrations​

Register migrations after Game.init() in your game's entry point:

// src/index.tsx or src/App.tsx

import { Game } from "@react-text-game/core";
import { registerMigration } from "@react-text-game/core/saves";

async function initGame() {
await Game.init({
gameName: "My Adventure Game",
gameVersion: "2.0.0", // Your current version
isDevMode: import.meta.env.DEV,
});

// Register all your migrations
registerMigration({
from: "1.0.0",
to: "1.1.0",
description: "Added inventory",
migrate: addInventorySystem,
});

registerMigration({
from: "1.1.0",
to: "2.0.0",
description: "Renamed 'hp' to 'health'",
migrate: renameHpToHealth,
});
}

Migration Functions​

Migrations should be pure functions that don't mutate the input:

// ✅ GOOD: Returns new object
const migrate = (save) => ({
...save,
newField: "default value",
});

// ❌ BAD: Mutates input
const migrate = (save) => {
save.newField = "default value";
return save;
};

Type Safety with Generic Types​

The migration system supports TypeScript generics for improved type safety. You can specify the exact shape of your save data for each migration, which provides:

  • Better IDE autocomplete when working with save data fields
  • Compile-time type checking for migration logic
  • Self-documenting code showing what fields the migration affects

Using Generic Types​

import type { SaveMigration } from "@react-text-game/core/saves";

// Define the shape of data this migration works with
interface PlayerInventoryData {
player?: {
inventory: string[];
};
}

// The generic lives on the SaveMigration<T> type, not on registerMigration.
// Type the migration object, and `save` inside `migrate` is PlayerInventoryData.
const addPlayerInventory: SaveMigration<PlayerInventoryData> = {
from: "1.0.0",
to: "1.1.0",
description: "Added player inventory",
migrate: (save) => {
const player = save.player || {};
return {
...save,
player: {
...player,
inventory: [], // TypeScript knows this should be string[]
},
};
},
};

registerMigration(addPlayerInventory);

When to Use Generics​

✅ Use generics when:

  • Working with complex nested data structures
  • The migration logic is non-trivial
  • You want IDE autocomplete for specific fields
  • Type safety would help prevent bugs

⚠️ Optional for:

  • Simple migrations (adding a top-level field)
  • One-line transformations
  • Migrations where types are obvious
// Simple migration - generics optional
registerMigration({
from: "1.0.0",
to: "1.1.0",
description: "Add settings",
migrate: (save) => ({ ...save, settings: {} }),
});

// Complex migration - generics recommended
interface OldInventoryFormat {
inventory?: string[];
}

const convertInventory: SaveMigration<OldInventoryFormat> = {
from: "1.0.0",
to: "2.0.0",
description: "Convert inventory to objects",
migrate: (save) => {
const items = (save.inventory || []).map((name, i) => ({
id: `item_${i}`,
name,
quantity: 1,
}));
return { ...save, inventory: items };
},
};

registerMigration(convertInventory);

Do I Need a Migration?​

Answering this by hand is unreliable, because the consequences of a change are not what intuition suggests. Adding a field to an entity is safe — loading merges the save over freshly built defaults. Adding a whole entity is not: a save holding no data for it has its variables cleared rather than defaulted.

@react-text-game/devtools decides for you. It records the shape of your saves as a committed file and compares later versions against it:

# once per release
bunx rtg saves snapshot --entry src/game/registry.ts

# on every commit, exits non-zero when a migration is missing
bunx rtg saves check --entry src/game/registry.ts

It also catches the two failures that are invisible from the code: a shape change whose gameVersion was not bumped (migrations only run when the versions differ, so nothing would migrate), and a deleted passage that leaves old saves pointing at an id which silently resolves to nothing.

See Keep your saves valid for the full workflow, including how to recover a baseline for a game that is already in production.

Semantic Versioning Strategy​

Follow semver to communicate the impact of changes:

Version ChangeMigration?Example
Patch (1.0.0 → 1.0.1)❌ NoBug fixes, no save structure changes
Minor (1.0.0 → 1.1.0)⚠️ OptionalNew optional fields with defaults
Major (1.0.0 → 2.0.0)✅ RequiredBreaking changes, removed/renamed fields

When to Register a Migration​

Register a migration when you:

  • ✅ Add a new entity — old saves have no data for it, so loading clears its variables instead of keeping their defaults
  • ✅ Rename or remove a field, or rename an entity
  • ✅ Change the structure of existing data
  • ✅ Change data types (string → number)
  • ✅ Remove or rename a passage — a save left on it resolves to no passage at all
  • ❌ Add a new field to an existing entity (it keeps its default)
  • ❌ Add a new passage (nothing points at it yet)
  • ❌ Fix a bug (no migration needed)

Best Practices​

1. Use Descriptive Descriptions​

// ✅ GOOD
description: "Renamed 'hp' field to 'health' in player object";

// ❌ BAD
description: "Updated player";

2. Provide Sensible Defaults​

migrate: (save) => ({
...save,
player: {
...save.player,
inventory: [], // Empty array for new players
gold: 0, // Start with 0 gold
level: 1, // Default level
},
});

3. Handle Optional Fields​

migrate: (save) => {
const player = save.player as any;
return {
...save,
player: {
...player,
// Use existing value if present, otherwise default
health: player.hp ?? player.health ?? 100,
},
};
};

4. Test Your Migrations​

import { runMigrations } from "@react-text-game/core/saves";

describe("Save Migrations", () => {
it("should migrate from 1.0.0 to 1.1.0", () => {
const oldSave = {
player: { name: "Hero" },
};

const result = runMigrations(oldSave, "1.0.0", "1.1.0");

expect(result.success).toBe(true);
expect(result.data.player.inventory).toEqual([]);
});
});

5. Keep Migrations Simple​

One migration = one logical change:

// ✅ GOOD: One clear change
registerMigration({
from: "1.0.0",
to: "1.1.0",
description: "Added inventory",
migrate: (save) => ({ ...save, inventory: [] }),
});

// ❌ BAD: Multiple unrelated changes
registerMigration({
from: "1.0.0",
to: "1.1.0",
description: "Added inventory, renamed hp, changed level system",
migrate: (save) => {
/* complex changes */
},
});

API Reference​

registerMigration(migration: SaveMigration<T>)​

Registers a migration. The function itself is not generic — the generic parameter lives on the SaveMigration<T> type. Type the migration object (annotate it with SaveMigration<T>) and pass it in.

Type Parameter (on SaveMigration<T>):

  • T - Optional generic type specifying the shape of the save data this migration operates on. Extends MigrationGameSaveState (which is Partial<GameSaveState> & Record<string, unknown>). Defaults to GameSaveState.

Parameters:

  • migration.from - Source version (e.g., "1.0.0")
  • migration.to - Target version (e.g., "1.1.0")
  • migration.description - Human-readable description
  • migration.migrate - Pure function that transforms the data

Throws: Error if migration already registered for the same from→to path.

Example:

import type { SaveMigration } from "@react-text-game/core/saves";

// With a typed migration object for type safety
interface MyData {
player?: { inventory: string[] };
}
const addInventory: SaveMigration<MyData> = {
from: "1.0.0",
to: "1.1.0",
description: "Add inventory",
migrate: (save) => ({ ...save, player: { ...save.player, inventory: [] } }),
};
registerMigration(addInventory);

// Without a type (uses default GameSaveState)
registerMigration({
from: "1.1.0",
to: "1.2.0",
description: "Add quests",
migrate: (save) => ({ ...save, quests: [] }),
});

findMigrationPath(from: string, to: string): SaveMigration[] | null​

Finds the shortest migration path between two versions.

Returns: Array of migrations to apply, or null if no path exists.

runMigrations(data, from, to, options?): MigrationResult​

Runs a migration chain. The function is not generic and always returns MigrationResult, whose data is typed GameSaveState. To read a known result shape, cast result.data.

Parameters:

  • data - The save data to migrate
  • from - Source version (e.g., "1.0.0")
  • to - Target version (e.g., "2.0.0")
  • options? - Optional migration options

Options:

  • strict?: boolean - Throw error if no path found (default: false)
  • verbose?: boolean - Log migration steps (default: dev mode setting)

Returns: MigrationResult with the following structure:

{
success: boolean;
data?: GameSaveState; // Migrated data (if successful)
error?: string; // Error message (if failed)
migrationsApplied: Array<{ // List of applied migrations
from: string;
to: string;
description: string;
}>;
}

Examples:

// Basic usage
const result = runMigrations(oldSave, "1.0.0", "2.0.0");
if (result.success) {
console.log("Migration successful:", result.data);
}

// Reading a known result shape: runMigrations always returns MigrationResult
// (data typed GameSaveState), so cast result.data when drilling in.
type NewSaveFormat = {
player: { stats: { health: number; mana: number } };
} & Record<string, unknown>;

const migrated = runMigrations(oldSave, "1.0.0", "2.0.0");
if (migrated.success && migrated.data) {
const data = migrated.data as NewSaveFormat;
console.log("Health:", data.player.stats.health);
}

validateMigrations(latestVersion: string)​

Validates the migration chain.

Returns:

{
valid: boolean;
issues: string[]; // List of validation issues
}

Examples​

Example 1: Adding a New Field​

// Game v1.1.0: Added inventory system
registerMigration({
from: "1.0.0",
to: "1.1.0",
description: "Added player inventory",
migrate: (save) => ({
...save,
player: {
...save.player,
inventory: [],
},
}),
});

Example 2: Renaming a Field​

// Game v1.2.0: Renamed 'hp' to 'health'
registerMigration({
from: "1.1.0",
to: "1.2.0",
description: "Renamed 'hp' to 'health'",
migrate: (save) => {
const { hp, ...playerRest } = save.player as any;
return {
...save,
player: {
...playerRest,
health: hp ?? 100, // Use existing hp or default to 100
},
};
},
});

Example 3: Restructuring Data​

// Game v2.0.0: Split player stats into separate object
registerMigration({
from: "1.2.0",
to: "2.0.0",
description: "Restructured player stats",
migrate: (save) => {
const player = save.player as any;
return {
...save,
player: {
name: player.name,
stats: {
health: player.health,
mana: player.mana,
strength: player.strength,
},
},
};
},
});

Example 4: Complex Migration Chain​

// Registering multiple migrations for a complex update path
const migrations = [
{
from: "1.0.0",
to: "1.1.0",
description: "Added inventory",
migrate: addInventory,
},
{
from: "1.1.0",
to: "1.2.0",
description: "Added quest system",
migrate: addQuests,
},
{
from: "1.2.0",
to: "2.0.0",
description: "Complete game redesign",
migrate: redesignGame,
},
];

migrations.forEach(registerMigration);

// A player with save v1.0.0 will automatically go through:
// 1.0.0 → 1.1.0 → 1.2.0 → 2.0.0

Troubleshooting​

"No migration path found"​

Problem: No registered migrations connect the save version to the current version.

Solution: Register missing migrations to complete the chain.

// If you have migrations 1.0.0→1.1.0 and 1.2.0→2.0.0
// You need to add 1.1.0→1.2.0 to complete the chain

registerMigration({
from: "1.1.0",
to: "1.2.0",
description: "Bridge version",
migrate: (save) => save, // No changes needed
});

"Dead end detected"​

Problem: A version has incoming migrations but no outgoing migrations to reach the current version.

Solution: Add a migration from the dead-end version to the next version.

Migration Fails​

Problem: A migration throws an error or returns invalid data.

Debug:

  1. Check console for error details
  2. Test the migration function in isolation
  3. Verify the input data structure matches expectations
// Add defensive checks
migrate: (save) => {
if (!save.player) {
console.error("Migration failed: save.player is undefined");
return save; // Return unchanged if something is wrong
}

return {
...save,
player: {
...save.player,
inventory: [],
},
};
};

Dev Mode Warnings​

In dev mode, you may see warnings about:

  • Orphaned versions: Versions with no path to current version
  • Dead ends: Versions that can't reach the current version
  • Missing migrations: Gaps in the migration chain

These are helpful for catching migration issues before they affect players.


Support​

For issues or questions: