Save system core principles
A Godot save system should follow these core principles: every save must have a version field to enable migration of old saves forward; corrupted JSON must not crash the game—show an error and load default instead; writes should be atomic (write to temp file, then rename) to prevent half-written saves if the game crashes; and metadata should be separated so the save list doesn't require parsing every full save file.
Save file directory structure
Save files are stored in user://saves/. Each save occupies two files: slot_NN.json contains the full save data, and slot_NN.meta.json contains only metadata for fast listing. Slots 00 and 01 are typically quicksave and manual slot 1. Autosaves occupy slots 06–10 in a rotation. The directory structure allows the save list to be built by reading only .meta.json files without parsing full saves.
Save file format structure
A save file is a JSON object with two top-level keys: "meta" contains version, slot number, slot name, day, time_minutes, current_room, playtime_seconds, timestamp (ISO 8601), and screenshot; "world" contains all game state needed to restore the session. The version field in meta is used to drive migration logic.
Atomic save write pattern
To prevent corruption on crash, write saves atomically: open a temporary file (path + ".tmp"), write all data to it, close it, then use DirAccess.rename_absolute() to move it to the final path in a single atomic operation. This ensures the final save file is either complete or unchanged.
Save loading with error resilience
When loading a save: check the file exists and is readable, parse JSON and catch parse errors, verify the parsed data is a Dictionary, extract version from meta, migrate world data if version differs, then construct the world object. Return a Dictionary with keys ok (boolean), and either world and meta on success or error message on failure.
Version migration pattern
Each save version transition is a single if-block that checks the from_version string and transforms the data Dictionary in place. Multiple version transitions are composed sequentially: if from_version is "0.9.0", apply that migration, set from_version to "1.0.0", then check if further transitions are needed. Renamed fields are handled by copying the old key to the new key and erasing the old one.
Fast save list without full parse
Build the save list by reading only .meta.json files from the saves directory. Iterate through all files ending in ".meta.json", read each metadata file, and collect non-empty metadata Dictionaries. This avoids parsing full save files when only summary info like slot name and timestamp is needed.
Autosave rotation algorithm
Autosaves use slots 6–10 (5 slots total) in a rotation: scan all autosave slots, find the one with the earliest timestamp, and overwrite it. If any slot is empty, use the first empty slot instead. This ensures at most 5 autosaves without manual slot management, and old autosaves are naturally discarded.
Steam Cloud save integration
After a successful save, if Steam.isLoggedOn() returns true, read the saved file as bytes with FileAccess.get_file_as_bytes(path) and call Steam.fileWrite("slot_NN.json", data, data.size()) to sync it to Steam Cloud. Configure Cloud quota limits in the Steamworks dashboard. Cloud synchronization of user:// happens automatically when properly configured.
Iron mode (permadeath one-save) implementation
For iron mode difficulty: call SaveSystem.delete_all() at new game start to wipe all previous saves. Disable manual save slots in the UI. Enable only autosaves, which trigger on major session-end events. This enforces permadeath—one life, one autosave slot that overwrites.
What to include in WorldState.to_dict()
WorldState.to_dict() must serialize everything needed to restore gameplay: player stats, inventory, position, all room states, all flags and counters, timer values, and random seed (for replay determinism). Do not serialize loaded textures or audio (reload on demand), cached Tweens or Timers (recreate in _ready), or UI state like open overlays (game state is what matters, not UI chrome).
Pitfall: saving Object references
Variants of Object type (nodes, resources) cannot be serialized to JSON. This is a silent failure—the reference will not round-trip. Always convert Objects to plain Dictionaries before adding them to data for serialization.
Pitfall: NaN and Infinity in saves
JSON cannot represent NaN or Infinity values. If any float field might hold these values, either clamp them to valid ranges before save or use string sentinels like "nan" and "inf" and parse them back on load.
Pitfall: forgetting to bump version
If save format changes but SAVE_VERSION is not incremented, old saves will pass the version check and load with the new parsing code, which may fail silently or corrupt state. Always bump the version constant and add a migration rule when the format changes.
Pitfall: skipping atomic write
If a save is written directly to the final path without temp+rename, a crash mid-write will leave a corrupt file. The next load will fail, and there is no fallback. Always use atomic write (temp file + rename).
Pitfall: saving every frame
Calling save() every frame causes excessive disk IO and can stutter gameplay. Save only on explicit player action or time-tick events (e.g., autosave every 60 seconds or on major scene transitions).
SaveSystem skeleton implementation
A basic SaveSystem is a class_name SaveSystem extends RefCounted with static functions save(world, slot=0, name="") and load(slot). The save() function ensures the save directory, builds a meta and world Dictionary, writes to a temp file, renames atomically, writes the metadata file, and returns {"ok": true/false, "error": message}. The load() function reads the JSON, parses it, checks version, migrates if needed, constructs WorldState, and returns {"ok": true/false, "world": world, "meta": meta, "error": message}.