iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

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…