Game Save Systems skill for Codex

Saves that survive crashes and patches. Codex saves a small description of game state built from stable IDs rather than serializing scenes, versions the format with one tested migration per step, and writes through a temp file and backup so a crash mid-save cannot wipe progress. It times autosaves to safe moments, keeps settings separate, handles cloud conflicts by asking the player, and audits that every supported version has a fixture save.

  • Game Development
  • $game-save-systems
  • 193 lines
  • 3 files
  • ~2,250 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- game-save-systems

Installs to ~/.agents/skills/game-save-systems/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $game-save-systems, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Designs save and load for games - choosing what state to persist, stable IDs instead of object references, versioned save formats with step-by-step migrations, atomic writes with backups, autosave and checkpoint timing, settings kept apart from progress, platform save locations (Unity persistentDataPath, Godot user://, Unreal USaveGame), Steam Auto-Cloud and cloud conflict handling, and tamper resistance. Use when adding saving to a game, players report corrupted or lost saves, a patch broke old save files, adding cloud saves, or reviewing a save system that uses BinaryFormatter or serializes whole scenes.

Use it when

  • Adding save and load to a game for the first time
  • A patch changed data structures and old saves fail to load
  • Players report corrupted or missing saves after crashes
  • Adding Steam Cloud or cross-device saves

Use something else when

  • The persistent store is a server database for an online game: use postgres-schema-design
  • You need a settings screen UI rather than storage: use game-ui

How to check the result

Round-trip tests compare saved and loaded state, every fixture version upgrades to current, and killing the process mid-save still leaves a loadable previous save.

The mistake it prevents

Saving object references or array indices instead of stable IDs, so the first scene edit or build change silently maps saved data onto the wrong objects.

~/.agents/skills/game-save-systems/SKILL.md

--- name: game-save-systems description: Designs save and load for games - choosing what state to persist, stable IDs instead of object references, versioned save formats with step-by-step… license: MIT ---

Done well means: a save file is a small, versioned description of game state built from stable IDs, every older version still loads through a chain of tested migrations, a crash or power cut during saving can never destroy the previous good save, and loading a save reconstructs the same game the player left.

Before writing code

  1. Read AGENTS.md; find any existing save code, serializer libraries in use (Newtonsoft com.unity.nuget.newtonsoft-json, MessagePack, Godot JSON/ConfigFile/ResourceSaver, Unreal USaveGame) and the target platforms.
  2. Decide the save model with the user: anywhere-save, checkpoints, autosave only, slots, ironman (single slot, overwritten). This drives everything else.
  3. List what must survive a reload (see below). Anything not listed is rebuilt from game data.

What to save

SaveDo not save
Player progress: level/area ID, checkpoint ID, position if anywhere-saveWhole scene graphs or engine objects
Inventory as item definition IDs plus counts and per-instance data (durability, rolls)Item definitions (they live in game data)
World deltas: opened chests, killed bosses, collected pickups by stable IDEvery enemy’s transform in a level that resets on load
Quest states and flags, dialogue choicesDerived values (max health from level, which can be recomputed)
RNG seeds and generator version for procedural worldsGenerated content that the seed reproduces
Playtime, timestamps, save format version, game buildSettings (separate file)

Stable IDs: give every persistent placed object a GUID or authored string ID that does not change when the scene is edited (Unity: serialized GUID field generated in OnValidate; Godot: exported String ID or a unique-name path; Unreal: FGuid property). Instance IDs and array indices change between runs and builds.

Format and layout

{
  "version": 4,
  "build": "1.3.0+4521",
  "savedAtUtc": "2026-03-02T19:44:10Z",
  "playtimeSeconds": 18342,
  "player": { "area": "forest_02", "checkpoint": "cp_bridge", "health": 72 },
  "inventory": [{ "item": "sword_iron", "count": 1, "durability": 0.64 }],
  "world": { "opened": ["chest_f02_a1", "door_f02_gate"], "defeated": ["boss_treant"] },
  "flags": { "met_blacksmith": true },
  "proc": { "seed": 918273, "genVersion": 3 }
}
FormatUse when
JSON (optionally gzip)Default: debuggable, diffable, easy migrations
Binary (MessagePack, FlatBuffers, custom)Large saves (sims, sandbox worlds) where size or speed is measured to matter
Engine-native (USaveGame, Godot Resource .tres)Small games already deep in that engine; still add an explicit version field

Never use BinaryFormatter (.NET): it is insecure on untrusted input, obsolete, and removed from modern .NET. Avoid Godot ResourceLoader.load on save files from untrusted sources: .tres can reference scripts and execute code. Use JSON or ConfigFile/FileAccess.get_var without objects (allow_objects = false, the default) for data players can share.

Versioning and migrations

  • Every save has an integer version. Every format change bumps it and adds one migration function vN -> vN+1. Loading applies them in order until current.
  • Migrations operate on the raw data (dictionary/JSON tree), before mapping to game types.
  • Never delete a migration while any supported version needs it. If you drop support for very old saves, say so in patch notes and show a clear message, not a crash.
static readonly Func<JObject, JObject>[] Migrations =
{
    /* 1 -> 2 */ s => { s["player"]["maxHealth"] = 100; return s; },
    /* 2 -> 3 */ s => { s["player"]["health"] = s["player"]["hp"]; ((JObject)s["player"]).Remove("hp"); return s; },
    /* 3 -> 4 */ s => { s["proc"] = new JObject { ["seed"] = s["seed"], ["genVersion"] = 2 }; s.Remove("seed"); return s; },
};
const int CurrentVersion = 4;

public static JObject Upgrade(JObject save)
{
    int v = save.Value<int?>("version") ?? 1;
    if (v > CurrentVersion) throw new SaveTooNewException(v);   // save from a newer build
    for (; v < CurrentVersion; v++)
        save = Migrations[v - 1](save);
    save["version"] = CurrentVersion;
    return save;
}

Keep one fixture save per version under tests/save_fixtures/ and test that each loads and upgrades. Audit coverage with the bundled script:

python3 scripts/save_fixtures.py tests/save_fixtures --current 4          # exit 1 if a version lacks a fixture
python3 scripts/save_fixtures.py --diff tests/save_fixtures/v3.json tests/save_fixtures/v4.json

Atomic writes and backups

A crash mid-write must leave the old save intact:

  1. Serialize to memory first; fail without touching disk if serialization throws.
  2. Write to slot1.sav.tmp, flush, close.
  3. Rotate: keep the current file as slot1.sav.bak, then rename the temp over slot1.sav.
  4. On load: if the main file fails to parse or fails a checksum, try .bak, and tell the player.
public static void WriteAtomic(string path, byte[] bytes)
{
    string tmp = path + ".tmp", bak = path + ".bak";
    using (var fs = new FileStream(tmp, FileMode.Create, FileAccess.Write, FileShare.None))
    {
        fs.Write(bytes, 0, bytes.Length);
        fs.Flush(true);                                   // flush OS buffers to disk
    }
    if (File.Exists(path)) File.Replace(tmp, path, bak);  // atomic swap + backup on desktop filesystems
    else File.Move(tmp, path);
}
func write_atomic(path: String, text: String) -> Error:
    var tmp := path + ".tmp"
    var f := FileAccess.open(tmp, FileAccess.WRITE)
    if f == null:
        return FileAccess.get_open_error()
    f.store_string(text)
    f.close()
    if FileAccess.file_exists(path):
        DirAccess.copy_absolute(path, path + ".bak")
    return DirAccess.rename_absolute(tmp, path)

Add a checksum (CRC32 or SHA-256 of the payload) in a header to detect truncation. On WebGL/web, storage is IndexedDB through the engine; flush explicitly where the engine requires it and test a reload of the tab. Consoles have their own save APIs, size limits and certification rules; the user must check platform documentation.

Where saves live

EngineLocation
UnityApplication.persistentDataPath (never Application.dataPath, which is read-only in builds)
Godotuser:// (maps to the OS app data folder)
UnrealUGameplayStatics::SaveGameToSlot / AsyncSaveGameToSlot with a USaveGame subclass
Custom desktopOS app-data folder (%APPDATA%, ~/Library/Application Support, $XDG_DATA_HOME)

Autosave and timing

  • Autosave at safe moments: checkpoint reached, area transition, after a boss, on quit and on app pause/background (mobile: OnApplicationPause(true), Godot NOTIFICATION_APPLICATION_PAUSED), not during combat or mid-cutscene.
  • Serialize state on the main thread (snapshot), write to disk on a background thread; never let two writes to the same slot overlap.
  • Show a saving indicator and tell players not to quit while it is shown (consoles require this).

Cloud saves

  • Steam Auto-Cloud: configure the save folder pattern in Steamworks; Steam syncs files and asks the player on conflicts. Keep file names stable and saves small.
  • Custom cloud or platform APIs: store savedAtUtc, playtime and a device ID with each save; on conflict, show both (time, playtime, area) and let the player choose. Never auto-merge progress silently.
  • Settings and keybindings sync separately or not at all (different devices, different screens).

Tamper resistance

Single-player: do not fight players editing their own saves; a checksum to detect corruption is enough. Competitive or economy games: the server holds the authoritative state; a client save is a cache. Client-side encryption only slows editing and its key ships in the binary.

Verification

  • Round-trip test: build a game state, save, load, compare state field by field
  • Every fixture version loads and upgrades to current (CI test plus save_fixtures.py)
  • Kill the process during a save (debug hook that throws mid-write): previous save still loads
  • Truncated or corrupt main file falls back to .bak with a player-facing message
  • Save from a newer build shows “made with a newer version”, no crash
  • Mobile: background the app mid-level, force-stop, relaunch: progress from the pause is there

Hand-off summary

**Format:** JSON v4 (gzip), slots 1-3 + autosave, settings in settings.json
**IDs:** PersistentId GUID component on chests/doors/bosses; inventory by item definition ID
**Migrations:** v1->v4 chain, fixtures for v1..v4, all loading in CI
**Safety:** temp + File.Replace with .bak, CRC32 header, background write, single writer per slot
**Cloud:** Steam Auto-Cloud on user://saves/*.sav (configure pattern in Steamworks: user action)

What the zip contains

Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Game Save Systems"
  short_description: "Saves that survive crashes and patches"
  default_prompt: "$game-save-systems Add a save system with three slots and autosave to our Godot game"
scripts/save_fixtures.py117 lines, 4.2 KB
#!/usr/bin/env python3
"""Audit a folder of save-file fixtures used for migration tests.

Checks that every fixture parses (JSON or gzip-compressed JSON), carries a
version field, that every version from the oldest supported to the current one
has at least one fixture, and optionally shows which key paths changed between
two saves.

Usage:
  python3 save_fixtures.py tests/save_fixtures --current 7
  python3 save_fixtures.py tests/save_fixtures --current 7 --min 3 --version-key meta.version
  python3 save_fixtures.py --diff old.json new.json

Exit code 0 when all checks pass, 1 when a fixture is unreadable, unversioned
or a version is missing. Standard library only.
"""
from __future__ import annotations

import argparse
import gzip
import json
import sys
from pathlib import Path


def load(path: Path):
    raw = path.read_bytes()
    if raw[:2] == b"\x1f\x8b":
        raw = gzip.decompress(raw)
    return json.loads(raw.decode("utf-8-sig"))


def get_path(data, dotted: str):
    cur = data
    for part in dotted.split("."):
        if not isinstance(cur, dict) or part not in cur:
            return None
        cur = cur[part]
    return cur


def key_paths(data, prefix: str = "") -> set[str]:
    out: set[str] = set()
    if isinstance(data, dict):
        for k, v in data.items():
            p = f"{prefix}.{k}" if prefix else str(k)
            out.add(p)
            out |= key_paths(v, p)
    elif isinstance(data, list):
        for item in data[:50]:                     # sample list items; shapes usually repeat
            out |= key_paths(item, prefix + "[]")
    return out


def audit(folder: Path, current: int, minimum: int | None, version_key: str) -> int:
    files = sorted(p for p in folder.rglob("*") if p.is_file() and not p.name.startswith("."))
    if not files:
        print(f"FAIL: no fixtures in {folder}")
        return 1
    problems = 0
    by_version: dict[int, list[str]] = {}
    for f in files:
        try:
            data = load(f)
        except (OSError, ValueError, UnicodeDecodeError) as exc:
            print(f"FAIL  {f.name}: cannot parse ({exc.__class__.__name__}: {exc})")
            problems += 1
            continue
        version = get_path(data, version_key)
        if not isinstance(version, int):
            print(f"FAIL  {f.name}: missing integer '{version_key}'")
            problems += 1
            continue
        if version > current:
            print(f"FAIL  {f.name}: version {version} is newer than current {current}")
            problems += 1
        by_version.setdefault(version, []).append(f.name)
        print(f"ok    {f.name}: v{version}, {f.stat().st_size} bytes")

    if by_version:
        low = minimum if minimum is not None else min(by_version)
        missing = [v for v in range(low, current + 1) if v not in by_version]
        print(f"\nversions covered: {sorted(by_version)}; supported range v{low}..v{current}")
        if missing:
            print(f"FAIL: no fixture for versions {missing} (migration steps from these are untested)")
            problems += 1
    print("\nPASS" if not problems else f"\n{problems} problem(s)")
    return 1 if problems else 0


def diff(a: Path, b: Path) -> int:
    pa, pb = key_paths(load(a)), key_paths(load(b))
    for p in sorted(pb - pa):
        print(f"+ {p}")
    for p in sorted(pa - pb):
        print(f"- {p}")
    print(f"\n{len(pb - pa)} added, {len(pa - pb)} removed, {len(pa & pb)} unchanged key paths")
    return 0


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    ap.add_argument("folder", nargs="?", type=Path)
    ap.add_argument("--current", type=int, help="current save format version")
    ap.add_argument("--min", type=int, dest="minimum", help="oldest version still supported")
    ap.add_argument("--version-key", default="version", help="dotted path to the version field")
    ap.add_argument("--diff", nargs=2, type=Path, metavar=("OLD", "NEW"))
    args = ap.parse_args()
    if args.diff:
        return diff(*args.diff)
    if not args.folder or args.current is None:
        ap.error("folder and --current are required unless --diff is used")
    return audit(args.folder, args.current, args.minimum, args.version_key)


if __name__ == "__main__":
    sys.exit(main())

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem