A drop-in save system for Godot 4, plus a small 3D demo that exercises it.
This project demonstrates a production-minded save architecture while staying easy to integrate into a normal Godot project.
Four cubes wander a neon-grid arena, bump each other, and lose HP on impact. You control one of them with WASD + Space. Whoever's left standing wins. Save/load at any moment via F5/F9 or the ESC slot menu — positions, velocities, HP, kill/jump counts, the player designation, career totals across sessions, and the event log all restore exactly.
Save systems are often a var my_state = {} + JSON.stringify + FileAccess.open, which breaks the moment you need more than one save, schema changes, or crash resilience. This one tries to do better without ballooning into something you'd be afraid to touch:
-
Decoupled library.
save_system/has zero dependencies on the game. Drop the folder into any Godot 4 project, register one autoload, done. No base classes to inherit; any node in groupsaveablethat implementssave_data()/load_data()participates. -
Atomic writes, won't half-save. Writes go to
<slot>.save.tmp, thenrename()into place, with a.backuprotation in between. A crash mid-save leaves you with either the old file or the new one — never a torn byte stream. -
Encrypted on disk, readable after decrypt. Save payload is JSON wrapped in
FileAccess.open_encrypted_with_pass. Deters casual save-editing, but when debugging you can run a one-liner with the project password and get readable JSON back. -
Separate metadata sidecar. Each slot has a
.save(encrypted) and a.meta.tres(plain Godot Resource). The slot picker reads sidecars — no decryption — so listing 100 slots with timestamps, play-time, and level names is cheap. -
Schema versioning at two levels. A top-level
schema_versionlets the library reject or migrate saves from the future. Individual saveables evolve independently by reading every field with.get(key, default)— add a new field tomorrow, old saves still load. -
Collections through ownership, not magic. Instead of a "saveable collection" concept with special cases, dynamic entities (like the spawned blocks) are owned by a saveable controller (Arena) that serializes them in its own
save_data. The library stays unaware of collection semantics; the pattern scales to any kind of spawned entity. -
Signals for every outcome.
saved,loaded,load_started,save_failed,load_failed— so HUDs, menus, or analytics can react without polling. -
HMAC-SHA256 integrity signature on every save. The JSON payload is hashed with a project-specific key and the signature is verified before any saveable sees the data. Catches tampering and corruption that slipped past encryption.
-
Two-phase load with per-saveable validation. Saveables may implement
validate_data(dict) -> String(empty string = OK, otherwise a failure reason). The library runs every saveable's validator before anyload_datafires — a single rejection aborts the whole load, so a bad save can't leave the world in a partially-applied state. Example checks in the demo:hp > max_hp, NaN positions,winner_indexout of range, negative counters. -
41 unit tests across 7 suites covering roundtrip, slot management, schema compatibility (forward & backward), encryption posture (wrong password → clean failure), signals, atomic writes, and HMAC/validation gates. Every change runs them.
Envelope (library-level, schema v2):
schema_version— format version, gated on loadhmac— SHA256 signature over the bodybody— JSON-stringified inner payload
Inner payload (one dict per slot):
game_version— for cross-build diagnosticssaved_at_unix— timestamp of the saveplay_time_seconds— elapsed time at the moment of savelevel_name— which scene was activesaveables— dict keyed bysave_idholding each saveable's data
Sidecar (.meta.tres, unencrypted, per slot):
slot_id,display_name,saved_at_unix,play_time_seconds,level_name,game_version,thumbnail(field exists, not populated)
Per-saveable contents in this project:
| Saveable | save_id |
Fields |
|---|---|---|
Arena |
"arena" |
elapsed_seconds, num_blocks, blocks (array of {scene, state}), winner_index, event_log (array of {t, kind, block?}) |
Block (inside each blocks[].state) |
— (owned by Arena) | hp, max_hp, color, name, alive, is_player, jumps, walls_bumped, pos, rot, lv (linear velocity), av (angular velocity) |
GameStats |
"game_stats" |
games_played, wins, total_jumps, total_walls_bumped |
Each field in each saveable's load_data uses .get(key, default), so adding or removing any entry is safe against older/newer saves.
-
Install Godot 4.6 (the
.monobuild is fine; the project has no C# code — the[dotnet]section inproject.godotis just leftover from scaffolding). macOS download: https://godotengine.org/download/macos/ -
Clone the repo:
git clone https://github.com/dsarno/save-system-godot-claude.git cd save-system-godot-claude -
Open the project:
/Applications/Godot_mono.app/Contents/MacOS/Godot --editor --path .Or: launch Godot → Project Manager → Import → pick this folder's
project.godot. -
Play: press
F5in the editor (or the Play button in the top-right toolbar). The main scene (game/game_main.tscn) runs by default. -
First-time note: if you see broken shader errors on first import, close the project and reopen it — Godot sometimes needs a second pass to resolve
.gdshaderreferences.
No external dependencies, no package manager, no build step.
| Key | Action |
|---|---|
| WASD | Move the player block (Block 1 — it has a pulsing glow) |
| Space | Jump (if grounded) |
| F5 | Quick-save to the last-used slot |
| F9 | Quick-load from the last-used slot |
| ESC | Open / close the save-slot menu (shows career stats at the bottom) |
| R | Respawn all blocks |
Block.save_datagrewjumpsandwalls_bumpedlate in development; old saves still load with 0 defaults.GameStatsis a completely separate saveable added as an autoload — career totals persist across games and slots without the library knowing it exists.Arena.event_logis an array-of-dicts ({t, kind, block}) that shows structured collection data round-tripping cleanly.
-
The "encryption" is not real security. The password is hardcoded in
save_config.gd. A determined user can dump the binary and decrypt saves. It stops a player from opening the.savein a text editor and editing their HP to 9999 — nothing more. For real security you'd derive a key from the user's OS account or a server. -
Still no migration code written.
SaveSystem._migrate()is a stub. The infrastructure (envelope-levelschema_version, inner-payload_migratefunction) is in place, but the first real migration transform (e.g. v2 → v3) hasn't been needed yet. -
HMAC key lives next to the encryption key. Both are constants in
save_config.gd, so the integrity signature is only as strong as the binary's secrecy. A defender who can extract one secret gets the other. Still catches casual tampering, random corruption, and bit-flips — it doesn't resist an attacker who has reverse-engineered the game. -
No thumbnails.
SaveSlotMeta.thumbnailis a field but the game never populates it. Slot pickers could show the last viewport frame; that plumbing isn't built. -
No async / threaded saves. Everything runs on the main thread. Fine for this demo (a few kilobytes), annoying for a hundred-MB open-world save.
-
No cloud / sync. No Steam Cloud, no iCloud, no custom server. Saves live in
user://saves/on the local machine. -
Group-based discovery has a race. If a node emits
add_to_group("saveable")during a save's_collect_saveablesiteration, behavior is undefined. In practice saveable membership is set up in_readylong before saves happen, so it hasn't bit us — but the guarantee isn't formal. -
No "cloud of saves" UI. The slot picker lists exactly N numbered slots. No "auto-save" rotation, no named saves, no folder view.
save_system/ drop-in library (GDScript, zero game dependencies)
save_system.gd autoload, whole public API (~220 lines)
save_slot_meta.gd Resource class for the .meta.tres sidecar
save_config.gd one-file knobs (password, paths, constants)
atomic_write.gd encrypted write helper with .tmp+rename + .backup
README.md drop-in instructions
game/ the demo — scenes, scripts, Tron-ish visuals
game_main.tscn + .gd root scene, input routing
arena.tscn + .gd floor, walls, camera, spawns blocks, event log
block.tscn + .gd RigidBody3D with player + AI control
hud.tscn + .gd timer, per-block stat cards, victory panel
slot_menu.tscn + .gd ESC slot picker with career stats footer
game_stats.gd career-wide autoload (games, wins, totals)
ui_theme.tres synthwave theme
tron_grid.gdshader emissive grid floor shader
floor_material.tres shader material for the floor
wall_material.tres neon cyan material for arena rails
slick.tres low-friction PhysicsMaterial
tests/ 6 test suites, 34 tests for the save_system library
friction_log.md running notes on godot-ai MCP pain points hit
Built live with Claude Code + the godot-ai MCP plugin. Every scene, script, shader, and theme was created via MCP tool calls; see friction_log.md for what worked and what didn't.

