| name | migrate-state |
| description | Safely evolve GameState shape — bump stateVersion, write a pure migration function, update replay fixtures and snapshot baselines |
Migrate State
Use whenever GameState shape changes (adding, removing, or renaming fields).
Skipping this process silently breaks replay tests and deterministic run snapshots.
Rule
Every structural change to GameState requires:
- A
stateVersion increment
- A pure migration function, unless the active spec explicitly closes the compatibility window and requires old state rejection
- Updated replay fixtures and snapshot baselines
Never change field types or names without bumping stateVersion.
Steps
1. Add stateVersion if absent
GameState must have:
export type GameState = Readonly<{
stateVersion: number;
seed: number;
prng: PrngState;
frameIndex: number;
}>;
createInitialState must set stateVersion: CURRENT_STATE_VERSION from
packages/game/lib/core/constants.ts.
2. Increment stateVersion
In packages/game/lib/core/types.ts, bump the version constant or literal and update
all places that construct a literal GameState (tests, createInitialState, factories).
export const CURRENT_STATE_VERSION = 2;
3. Write a pure migration function, or document explicit rejection
If old states remain supported, create or update packages/game/lib/state/migrations.ts:
import type { GameState } from "../core/types.ts";
type GameStateV1 = Omit<GameState, "newField"> & { stateVersion: 1 };
export const migrateV1toV2 = (old: GameStateV1): GameState => ({
...old,
stateVersion: 2,
newField: deriveDefaultValue(old),
});
Rules for migration functions:
- Pure — no side effects
- Exhaustive — every field of the new shape must be present in the return value
- Tested — unit test with a representative V1 fixture
If the active spec intentionally drops old-state compatibility, delete stale
migration code and update parser/tests so old versions fail explicitly.
4. Update snapshot / replay test baselines
After the migration, replay fixture and snapshot tests will fail because the state shape changed. Update committed JSON baselines:
pnpm --filter @bruff/game run test
Review the diff before committing — the snapshot changes should exactly match the
new fields you added.
5. Update deterministic replay fixtures
Replay fixtures live in packages/game/tests/fixtures/*.json; final-state snapshots live in packages/game/tests/snapshots/*.json. If the fixture format changes, update ReplayFixture, ReplayError, parseReplayFixture, and runReplay tests together.
Migration Chain
If multiple migrations exist, compose them in order:
export const migrateToLatest = (
raw: unknown,
): Result<GameState, MigrationError> => {
if (!isGameStateV1(raw)) return error({ type: "unrecognised-state-shape" });
const v2 = migrateV1toV2(raw);
return ok(v2);
};
Checklist