Skip to content
Development
Skill

/save-systems

Design save/load for game state — choosing what to serialize, file formats, save slots, atomic crash-safe writes, schema versioning and migration, and autosave. Engine-neutral. Use when the user mentions save system, save/load, game state persistence, save slots, autosave, save

From plugin
awesome-gamedev-agent-skills
6.3k77 skills

Claude Code

$ npx -y skills add gamedev-skills/awesome-gamedev-agent-skills --skill save-systems --agent claude-code

SKILL.md

save-systems.SKILL.md
name: save-systems
description: >
  Design save/load for game state — choosing what to serialize, file formats,
  save slots, atomic crash-safe writes, schema versioning and migration, and
  autosave. Engine-neutral. Use when the user mentions save system, save/load,
  game state persistence, save slots, autosave, save file corruption, or
  migrating old saves to a new version.

Save systems

A save file is a **serialized snapshot of game state** that survives restarts. The hard parts aren't writing bytes — they're choosing *what* to save, writing it so a crash mid-save can't corrupt it, and reading *old* saves after you ship a patch. Get those three right and the rest is plumbing.

When to use

  • Use to persist progress: player stats, inventory, world flags, settings,

positions — across sessions and game updates.

  • Use to design save slots, quicksave/autosave, and crash-safe writes.
  • Use when old save files break after a content/code change (versioning &

migration).

**When *not* to use:** for Roblox cloud persistence specifics, use `roblox-datastores`. For the data model the save serializes (resources/SOs), use `godot-resources` / `unity-scriptableobjects`. For Godot's `FileAccess`/ `ResourceSaver` and `user://` paths, defer to the Godot engine skill while applying the patterns here.

Core workflow

1. **Decide what state is authoritative.** Save the *data* (hp, position, seed, unlocked flags), not engine objects or scene nodes. You will reconstruct objects from data on load — never serialize live node references. 2. **Define a versioned schema.** Every save embeds a `version` integer. This is the single most important field for a game you intend to patch. 3. **Pick a format.** JSON/text for readability and debuggability; a binary format for size/speed or mild tamper-resistance. Start with JSON. 4. **Write atomically.** Serialize to a temp file, flush, then rename over the real file. A crash leaves either the old save or the new one — never a half-written one. 5. **Load defensively.** Read version → migrate up to current → validate → instantiate. Keep a backup of the last good save and fall back on parse error. 6. **Autosave on safe boundaries** (level change, checkpoint), throttled, and to a separate slot so it can't clobber a manual save. 7. **Verify**: save, fully quit, relaunch, load — and confirm by inspection that state matches. Test loading a save from the previous version.

Patterns

1. Serialize state as plain data (not engine objects)

# Build a dictionary of pure data. Each savable object reports its own state.
func capture_state() -> Dictionary:
    return {
        "version": SAVE_VERSION,                 # ALWAYS stamp the schema version
        "player": { "hp": player.hp, "pos": [player.position.x, player.position.y] },
        "inventory": player.inventory.to_array(),  # ids + counts, not Item nodes
        "flags": world.flags,                    # e.g. {"met_guard": true}
        "seed": world.seed,                      # regenerate procedural content
    }

# On load, RECONSTRUCT objects from the data — do not expect live references back.
func apply_state(data: Dictionary) -> void:
    player.hp = data["player"]["hp"]
    player.position = Vector2(data["player"]["pos"][0], data["player"]["pos"][1])
    player.inventory.from_array(data["inventory"])
    world.flags = data["flags"]

2. Atomic, crash-safe write (temp + rename)

# Godot 4.7. Write a temp file, keep the previous save as .bak, then rename.
func save_atomic(path: String, data: Dictionary) -> Error:
    var tmp := path + ".tmp"
    var f := FileAccess.open(tmp, FileAccess.WRITE)
    if f == null:
        return FileAccess.get_open_error()       # disk full, read-only, no permission...
    if not f.store_string(JSON.stringify(data)):
        return ERR_FILE_CANT_WRITE
    f.close()                                    # flushes Godot's buffer to the OS
    if FileAccess.file_exists(path):
        DirAccess.rename_absolute(path, path + ".bak")   # keep the last good save
    return DirAccess.rename_absolute(tmp, path)

func load_save(path: String) -> Dictionary:      # empty = nothing loadable
    for p in [path, path + ".bak"]:              # .bak covers a crash between renames
        if FileAccess.file_exists(p):
            var data = JSON.parse_string(FileAccess.get_file_as_string(p))
            if data is Dictionary and data.has("version"):
                data["version"] = int(data["version"])   # JSON numbers load as float
                return data
    return {}
# WRONG: opening `path` directly and writing in place — a crash mid-write leaves a
# truncated, unloadable save and destroys the player's progress.

A crash now leaves the old save, the new one, or (between the two renames) only the `.bak` — which the loader tries next. Two limits to know: `rename()` replaces the target atomically on POSIX (same volume), but Godot's Windows `rename_absolute` deletes the target and then moves, so that platform relies on the `.bak` too; and neither `flush()` nor `close()` calls `fsync`, so a power cut right after a save can still lose the newest file. Check the returned `Error` and tell the player when a save fails instead of carrying on.

3. Versioned load with migration

SAVE_VERSION = 3

def load_save(raw_bytes):
    data = parse(raw_bytes)                  # JSON/binary -> dict
    v = int(data.get("version", 1))          # no field = the first shipped schema
    if v > SAVE_VERSION:
        raise NewerSaveError(v)              # save is from a newer build; refuse
    while v < SAVE_VERSION:                   # apply migrations in order, v -> v+1
        data = MIGRATIONS[v](data)
        v += 1
        data["version"] = v
    validate(data)                            # check required keys / ranges
    return data

# Each migration is a pure function from one version's shape to the next.
def migrate_1_to
Read more
@gamedev-skills
Ships withawesome-gamedev-agent-skills

<img src="docs/assets/awesome-gamedev-agent-skills-banner.png" width="820" alt="awesome-gamedev-agent-skills — game-dev skills for AI coding agents.

Stars on GitHub

Momentum

stars in total
6.3k
Star history of gamedev-skills/awesome-gamedev-agent-skills on GitHub, a chart by star-history.com

More skills from other plugins.

brainstorming
Auto-invoked

brainstorming

You MUST use this before any creative work - creating features, building components, adding functionality, modifying behavior, or…

@obra@obraInstall Skill
ask-matt

ask-matt

Ask which skill or flow fits your situation. A router over the skills in this repo.

skills10d309k
@mattpocock@mattpocockInstall Skill
accessibility
Auto-invoked

accessibility

Design, implement, and audit accessible UI to WCAG 2.2 Level AA across Web, iOS, and Android — semantic ARIA roles and labels,…

ecc12d293k
@affaan-m@affaan-mInstall Skill
compiler-commit

compiler-commit

Use when you want to verify compiler changes and commit with the correct convention. Runs tests, lint, and format, then commits…

react1d252k
@facebook@facebookInstall Skill

Questions about save-systems.

Which coding agents can use save-systems?
save-systems installs in Claude Code, Codex, Cursor, Gemini CLI, GitHub Copilot, OpenCode and Windsurf with the skills CLI, using each agent's flag shown on this page. Flowy's auto-invocation, which fires a skill when your prompt matches, runs in Claude Code only.