ignifx0.x · unpublished
GitHub

Recipes·skills/ignifx/references/recipes/save-and-load-game-state.md

Save and load game state

<!-- Generated by `pnpm docs:recipes`. Do not edit by hand. -->

app.storage is the asynchronous key–value store behind save games, settings and input rebindings. Its backend follows the host — IndexedDB in a browser, files under userData in Electron, memory under Node — and the five calls (get, set, delete, keys, namespace) are the same everywhere. A namespace is a scope, not a prefix: namespace("saves") sees none of the root's keys, so a save slot can never collide with a settings key.

Version the document, not the key. A build that reads a slot a newer build wrote must refuse it rather than half-apply it, and a build that reads an older one migrates it forward.

Autosaving is a coroutine: yield waitSeconds(n) runs on the game clock, and yield promise resumes in the first Update after the write settles, so the loop never overlaps itself.

typescript
import { Script, createApp, f32, u32, waitSeconds } from "@ignifx/core";import type { Coroutine, ScriptCallbacks } from "@ignifx/core";/** Everything one slot holds. `version` is what a later build migrates from. */interface SaveFile {  readonly version: number;  readonly level: string;  readonly checkpoint: readonly [number, number, number];  readonly coins: number;}const SAVE_VERSION = 2;const SLOT = "slot1";/** Keeps one save slot up to date while the player plays. */class Autosave extends Script.define({ everySeconds: f32(30), coins: u32(0) }) implements ScriptCallbacks {  static typeId = "recipes/Autosave";  level = "caverns";  start(): void {    this.startCoroutine(this.#loop());  }  /** Writes the slot. Values are JSON; a `Uint8Array` is stored as octets instead. */  async save(): Promise<void> {    const position = this.transform.position;    await this.app.storage.namespace("saves").set<SaveFile>(SLOT, {      version: SAVE_VERSION,      level: this.level,      checkpoint: [position.x, position.y, position.z],      coins: this.coins,    });  }  // Reads the slot, refusing a document a newer build wrote.  async load(): Promise<boolean> {    const file = await this.app.storage.namespace("saves").get<SaveFile>(SLOT);    if (file === null || file.version > SAVE_VERSION) {      return false;    }    this.level = file.level;    this.coins = file.coins;    this.transform.localPosition.set(file.checkpoint[0], file.checkpoint[1], file.checkpoint[2]);    return true;  }  *#loop(): Coroutine {    for (;;) {      yield waitSeconds(this.everySeconds);      // Resumes in the first Update after the write settles, so two writes never overlap.      yield this.save();    }  }}const app = await createApp({ headless: true });app.registerComponents([Autosave]);const hero = app.world.createEntity("Hero");hero.transform.localPosition.set(4, 0, 12);const progress = hero.addComponent(Autosave, { everySeconds: 5, coins: 3 });await app.start();await progress.save();progress.coins = 0;hero.transform.localPosition.set(0, 0, 0);app.log.info("restored, coins, z:", await progress.load(), progress.coins, hero.transform.localPosition.z);app.log.info("slots:", (await app.storage.namespace("saves").keys()).join(", "));app.dispose();

Source: examples/recipes/save-and-load-game-state/main.ts