Save & Load
Save and load is more than serialisation. The hard parts are versioning (old saves load into a new game), atomic writes (no half-written file after a crash), and migrating data between schema changes. The shape that scales: a header with a version number and a payload that is JSON or another schemaful format with explicit migration steps.
Versioned save with migrations and atomic writes
EXAMPLE
// ============================================================
// 1) Define the current shape in a single place. Bump VERSION
// whenever the on-disk format changes.
// ============================================================
const VERSION = 3;
export interface SaveFile {
version: number;
meta: { id: string; createdAt: string; playtimeSeconds: number };
player: { name: string; level: number; gold: number; pos: { x: number; y: number; z: number } };
inventory: { id: string; count: number }[];
flags: Record<string, boolean>;
}
// ============================================================
// 2) Atomic write — write to a temp file and rename. A crash mid-write
// leaves the old file intact, never a half-written save.
// ============================================================
import { promises as fs } from 'node:fs';
import { join } from 'node:path';
import { randomUUID } from 'node:crypto';
async function save(saveDir: string, slot: string, data: SaveFile) {
await fs.mkdir(saveDir, { recursive: true });
const target = join(saveDir, \`${slot}.json\`);
const tmp = join(saveDir, \`${slot}.${randomUUID()}.tmp\`);
await fs.writeFile(tmp, JSON.stringify(data, null, 2), { encoding: 'utf8' });
await fs.rename(tmp, target); // atomic on POSIX/NTFS
}
// ============================================================
// 3) Load with version detection + migrations
// ============================================================
async function load(saveDir: string, slot: string): Promise<SaveFile> {
const raw = JSON.parse(await fs.readFile(join(saveDir, \`${slot}.json\`), 'utf8'));
return migrate(raw);
}
function migrate(raw: any): SaveFile {
let v = raw.version ?? 1;
while (v < VERSION) {
raw = MIGRATIONS[v](raw);
v++;
}
if (raw.version !== VERSION) throw new Error(\`unknown save version: ${raw.version}\`);
return raw as SaveFile;
}
// Each entry migrates FROM version N to N+1, in place semantics.
const MIGRATIONS: Record<number, (s: any) => any> = {
1: (s) => {
// v1 -> v2: split 'position' [x,y,z] into a {x,y,z} object
const [x, y, z] = s.player.position ?? [0, 0, 0];
s.player.pos = { x, y, z };
delete s.player.position;
s.version = 2;
return s;
},
2: (s) => {
// v2 -> v3: introduce 'flags' map; convert legacy boolean fields
s.flags = {
tutorial_done: !!s.player.tutorialDone,
forest_unlocked: !!s.world?.forestUnlocked,
};
delete s.player.tutorialDone;
delete s.world?.forestUnlocked;
s.version = 3;
return s;
},
};
// ============================================================
// 4) Forwards-incompatible loads — refuse loudly
// ============================================================
async function safeLoad(dir: string, slot: string) {
try {
return await load(dir, slot);
} catch (err: any) {
if (/unknown save version/.test(err.message)) {
throw new Error('Save from a newer game version. Update the game.');
}
throw err;
}
}
// ============================================================
// 5) Auto-save + corruption resistance
// ============================================================
// - Rotate: keep last N autosaves so one bad save does not eat them all.
// - Checksum: append a SHA-256 to the file; verify on load.
// - Cloud sync: upload AFTER local atomic write; resolve conflicts by mtime + checksum.
Why it matters
Treat the save file like a database schema with migrations. A version field and a chain of migrate-from-N-to-N+1 functions lets you ship breaking changes without breaking players save files — and lets you test the migration path with old saves checked into the repo.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
// JSON for indie games. Schema-evolve carefully — keep a version field. // Encrypt + sign saves only if cheating leaks money or competition.Try it Yourself »
Discussion
Loading…