Files
suaveplan/packages/gaming/games/game-server/src/enhanced/persistence/game-persistence/game-persistence.md
2026-05-27 23:47:53 +01:00

3.7 KiB

GamePersistence

(no submodules)

Purpose

GamePersistence persists game state to a pluggable key-value backend via the StorageLike structural contract from @suaveplan/types. State, named snapshots, and a bounded audit-trail history are serialised together as a UTF-8 JSON document under a deterministic key (game-server:save:<gameId>:<id>), allowing any conformant AbstractStorage adapter — in-memory, SurrealDB, SQLite, S3 — to back the engine without code changes.

The manager exposes async save, load, delete, createSnapshot, and restoreSnapshot operations that throw stable GAME_SERVER.* errors, plus an OTel save-duration histogram and counter pair so production throughput is observable without bespoke instrumentation.

Features

  • Pluggable StorageLike backend (in-memory, durable, network).
  • Named snapshots with rotating maxSnapshots cap.
  • Bounded audit-trail history with maxHistoryEntries cap.
  • Injectable Clock and IdGenerator for deterministic tests.
  • Stable error codes: PERSISTENCE_SAVE_FAILED, PERSISTENCE_LOAD_FAILED, PERSISTENCE_NOT_FOUND, INVALID_SAVE_RECORD.
  • OTel counters + duration histogram.
  • Tracks currentSaveId for snapshot context.

Usage: Basic

import { GamePersistence } from "@suaveplan/game-server/enhanced/persistence/game-persistence";

const gp = new GamePersistence<MyState>(
    { gameId: "chess" },
    { storage: createSurrealStorage() }
);

const result = await gp.save(state, { name: "quicksave" });
const loaded = await gp.load(result.id!);

Usage: Advanced

import { GamePersistence } from "@suaveplan/game-server/enhanced/persistence/game-persistence";

const gp = new GamePersistence<MyState>(
    { gameId: "chess", maxSnapshots: 50, maxHistoryEntries: 200 },
    {
        storage,
        clock: () => now,
        idGenerator: () => `save-${++counter}`,
        meter,
    }
);

const save = await gp.save(state, { name: "checkpoint", tags: ["wave-4"] });
const snap = await gp.createSnapshot(state, "before-boss", "auto");
const restored = await gp.restoreSnapshot(save.id!, snap.id);
const history = await gp.getHistory(save.id!);
const stats = gp.getStats();

API Reference

  • new GamePersistence(config, deps) — deps.storage: StorageLike.
  • save(state, input): Promise<SaveResult>
  • load(id): Promise<LoadResult>
  • delete(id): Promise<boolean>
  • exists(id): Promise<boolean>
  • createSnapshot(state, name, reason?): Promise<Snapshot>
  • restoreSnapshot(saveId, snapshotId): Promise<LoadResult>
  • getHistory(saveId): Promise<readonly HistoryEntry[]>
  • getStats(): PersistenceStats
  • getCurrentSaveId(): string | null

Implementation Notes

State is encoded as UTF-8 JSON via TextEncoder/TextDecoder so the on-disk shape is debuggable with any text viewer; binary-only stores will treat the payload as opaque bytes. For game states with large embedded binaries (images, audio), callers should chunk those out to a separate asset store and reference them by id in the persisted state to avoid inflating the JSON payload.

Storage keys are namespaced by both gameId and the per-save id so a single shared backend can host saves for many concurrent game instances without prefix collisions. The currentSaveId cursor is in-memory only — restarting the process requires an explicit load() before createSnapshot() will succeed.

JSON decode failures are mapped to PERSISTENCE_LOAD_FAILED rather than silently returning null, so corrupted storage surfaces as an inspectable error chain instead of an empty save list. Save failures preserve their underlying cause through GameServerError.cause, making storage-side diagnostics directly available to the caller.