Skip to content
Back to skills

Save System Patterns

ASecurity

Save system code patterns: a core save manager, a save data model, slot metadata for load screens, and settings persistence kept separate from game saves. Use when implementing saving and loading in a game.

  • 65 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 1, 2026
developmentnode

Security analysis

A100/100

Scanned October 1, 2026

npx -y skills add HermeticOrmus/claude-code-game-development --skill save-system-patterns --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Save System Patterns?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Save System Patterns
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/hermeticormus-save-system-patterns/badge)](https://www.skillsdirectory.com/skills/hermeticormus-save-system-patterns)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: save-system-patterns
description: "Save system code patterns: a core save manager, a save data model, slot metadata for load screens, and settings persistence kept separate from game saves. Use when implementing saving and loading in a game."
---

# Save System Patterns

## Core Save Manager

```gdscript
# Save system with versioning, atomic writes, and migration
class_name SaveManager extends Node

const SAVE_DIR := "user://"
const SAVE_VERSION := 2  # Increment when save schema changes

func save_game(slot: int, data: Dictionary) -> Error:
    var path := _get_save_path(slot)
    var tmp_path := path + ".tmp"

    # Add metadata
    data["version"] = SAVE_VERSION
    data["timestamp"] = Time.get_unix_time_from_system()

    var file := FileAccess.open(tmp_path, FileAccess.WRITE)
    if not file:
        return FileAccess.get_open_error()

    file.store_string(JSON.stringify(data, "\t"))
    file.close()

    # Backup existing save before replacing
    if FileAccess.file_exists(path):
        DirAccess.copy_absolute(path, path + ".bak")

    # Atomic rename: if crash happens before this, .tmp exists but save is intact
    var dir := DirAccess.open(SAVE_DIR)
    return dir.rename(tmp_path.get_file(), path.get_file())

func load_game(slot: int) -> Dictionary:
    var path := _get_save_path(slot)
    if not FileAccess.file_exists(path):
        return {}

    var file := FileAccess.open(path, FileAccess.READ)
    if not file:
        return {}

    var parsed := JSON.parse_string(file.get_as_text())
    if not parsed is Dictionary:
        push_error("Save file corrupt at slot %d, trying backup" % slot)
        return _load_backup(slot)

    return _migrate(parsed)

func _migrate(data: Dictionary) -> Dictionary:
    var version: int = data.get("version", 0)
    if version < 1:
        data = _migrate_v0_to_v1(data)
    if version < 2:
        data = _migrate_v1_to_v2(data)
    return data

func _migrate_v0_to_v1(data: Dictionary) -> Dictionary:
    # Example: v0 stored health as "hp", v1 uses "health"
    if "hp" in data:
        data["health"] = data["hp"]
        data.erase("hp")
    data["version"] = 1
    return data

func _migrate_v1_to_v2(data: Dictionary) -> Dictionary:
    # Example: v1 had no settings section
    if "settings" not in data:
        data["settings"] = {"volume_master": 1.0, "fullscreen": false}
    data["version"] = 2
    return data

func _load_backup(slot: int) -> Dictionary:
    var backup_path := _get_save_path(slot) + ".bak"
    if not FileAccess.file_exists(backup_path):
        return {}
    var file := FileAccess.open(backup_path, FileAccess.READ)
    if not file:
        return {}
    var parsed := JSON.parse_string(file.get_as_text())
    return parsed if parsed is Dictionary else {}

func _get_save_path(slot: int) -> String:
    return "user://save_%d.json" % slot

func delete_save(slot: int) -> void:
    var path := _get_save_path(slot)
    if FileAccess.file_exists(path):
        DirAccess.remove_absolute(path)
    if FileAccess.file_exists(path + ".bak"):
        DirAccess.remove_absolute(path + ".bak")
```

## Save Data Model

```gdscript
# Typed save data container - serialize/deserialize explicitly
class_name PlayerSaveData extends RefCounted

var health: float = 100.0
var max_health: float = 100.0
var position: Vector2 = Vector2.ZERO
var current_level: StringName = &"level_01"
var inventory: Array[StringName] = []
var flags: Dictionary = {}  # quest flags, doors opened, etc.
var playtime: float = 0.0

func to_dict() -> Dictionary:
    return {
        "health": health,
        "max_health": max_health,
        "position": {"x": position.x, "y": position.y},
        "current_level": str(current_level),
        "inventory": inventory.map(func(s): return str(s)),
        "flags": flags,
        "playtime": playtime,
    }

static func from_dict(d: Dictionary) -> PlayerSaveData:
    var data := PlayerSaveData.new()
    data.health = float(d.get("health", 100.0))
    data.max_health = float(d.get("max_health", 100.0))
    var pos: Dictionary = d.get("position", {})
    data.position = Vector2(float(pos.get("x", 0.0)), float(pos.get("y", 0.0)))
    data.current_level = StringName(d.get("current_level", "level_01"))
    data.inventory = Array(d.get("inventory", [])).map(func(s): return StringName(s))
    data.flags = d.get("flags", {})
    data.playtime = float(d.get("playtime", 0.0))
    return data
```

## Save Slot Metadata (for UI)

```gdscript
# Lightweight slot summary for save/load screen - no full deserialization needed
class_name SaveSlotMeta extends RefCounted

var slot: int
var exists: bool = false
var level_name: String = ""
var playtime_seconds: float = 0.0
var timestamp: float = 0.0

func get_playtime_display() -> String:
    var hours := int(playtime_seconds) / 3600
    var minutes := (int(playtime_seconds) % 3600) / 60
    return "%02d:%02d" % [hours, minutes]

func get_date_display() -> String:
    return Time.get_datetime_string_from_unix_time(int(timestamp)).substr(0, 10)

static func read(slot: int) -> SaveSlotMeta:
    var meta := SaveSlotMeta.new()
    meta.slot = slot
    var path := "user://save_%d.json" % slot
    if not FileAccess.file_exists(path):
        return meta  # exists = false
    var file := FileAccess.open(path, FileAccess.READ)
    if not file:
        return meta
    var parsed := JSON.parse_string(file.get_as_text())
    if not parsed is Dictionary:
        return meta
    meta.exists = true
    meta.level_name = str(parsed.get("current_level", "Unknown"))
    meta.playtime_seconds = float(parsed.get("playtime", 0.0))
    meta.timestamp = float(parsed.get("timestamp", 0.0))
    return meta
```

## Settings Persistence (Separate from Game Save)

```gdscript
# Settings use ConfigFile - separate from game saves so reset is safe
class_name GameSettings extends Node

const SETTINGS_PATH := "user://settings.cfg"

@export var volume_master: float = 1.0
@export var volume_music: float = 0.8
@export var volume_sfx: float = 1.0
@export var fullscreen: bool = false
@export var language: String = "en"

func save() -> void:
    var config := ConfigFile.new()
    config.set_value("audio", "volume_master", volume_master)
    config.set_value("audio", "volume_music", volume_music)
    config.set_value("audio", "volume_sfx", volume_sfx)
    config.set_value("display", "fullscreen", fullscreen)
    config.set_value("gameplay", "language", language)
    config.save(SETTINGS_PATH)

func load_settings() -> void:
    var config := ConfigFile.new()
    if config.load(SETTINGS_PATH) != OK:
        return  # Use defaults
    volume_master = config.get_value("audio", "volume_master", 1.0)
    volume_music = config.get_value("audio", "volume_music", 0.8)
    volume_sfx = config.get_value("audio", "volume_sfx", 1.0)
    fullscreen = config.get_value("display", "fullscreen", false)
    language = config.get_value("gameplay", "language", "en")
    _apply()

func _apply() -> void:
    AudioServer.set_bus_volume_db(AudioServer.get_bus_index("Master"), linear_to_db(volume_master))
    DisplayServer.window_set_mode(
        DisplayServer.WINDOW_MODE_FULLSCREEN if fullscreen else DisplayServer.WINDOW_MODE_WINDOWED
    )
    TranslationServer.set_locale(language)
```

## Anti-Patterns

- **Saving node references**: Nodes are not serializable; they don't survive scene reload. Save data values (position, health, item IDs), never `NodePath` or `Node` objects.
- **No version field**: Adding a new key in v2 means v1 saves crash on load. Always include `"version": CURRENT_VERSION` and handle missing keys with `.get("key", default)`.
- **Direct file write**: Writing directly to save path without atomic tmp+rename means crash during write = corrupt file = player loses progress.
- **Mixing settings and game saves**: Settings reset should not erase game progress. Keep them in separate files.
- **ResourceSaver for save games**: `.tres` files are text-readable, easy to cheat, and break when GDScript class names change. Use explicit JSON serialization for save data.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.