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.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- game-save-systems Installs to ~/.agents/skills/game-save-systems/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install game-save-systems The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project game-save-systems Installs to .agents/skills/game-save-systems/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/game-save-systems.zipunzip -o game-save-systems.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) game-save-systems PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
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.
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
- Read
AGENTS.md; find any existing save code, serializer libraries in use (Newtonsoftcom.unity.nuget.newtonsoft-json, MessagePack, GodotJSON/ConfigFile/ResourceSaver, UnrealUSaveGame) and the target platforms. - Decide the save model with the user: anywhere-save, checkpoints, autosave only, slots, ironman (single slot, overwritten). This drives everything else.
- List what must survive a reload (see below). Anything not listed is rebuilt from game data.
What to save
| Save | Do not save |
|---|---|
| Player progress: level/area ID, checkpoint ID, position if anywhere-save | Whole 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 ID | Every enemy’s transform in a level that resets on load |
| Quest states and flags, dialogue choices | Derived values (max health from level, which can be recomputed) |
| RNG seeds and generator version for procedural worlds | Generated content that the seed reproduces |
| Playtime, timestamps, save format version, game build | Settings (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 }
}
| Format | Use 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 functionvN -> 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:
- Serialize to memory first; fail without touching disk if serialization throws.
- Write to
slot1.sav.tmp, flush, close. - Rotate: keep the current file as
slot1.sav.bak, then rename the temp overslot1.sav. - 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
| Engine | Location |
|---|---|
| Unity | Application.persistentDataPath (never Application.dataPath, which is read-only in builds) |
| Godot | user:// (maps to the OS app data folder) |
| Unreal | UGameplayStatics::SaveGameToSlot / AsyncSaveGameToSlot with a USaveGame subclass |
| Custom desktop | OS 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), GodotNOTIFICATION_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
.bakwith 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.yaml
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.py
#!/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.