Every Godot project starts with this line:
FileAccess.open("user://save.dat", FileAccess.WRITE).store_var(state)
It works until it doesn't. Three failure modes, none of which show up while you test:
- A crash or power loss between open and flush. The file exists, is 0 bytes or half-written, and the player's 8-hour run is gone.
-
A full disk.
store_varwrites less than you asked for and returns nothing you check. -
A patch that changes the save shape. Old saves decode into
null, and your game either starts a new run or crashes onnull["hp"].
None of these are exotic. They are the normal behaviour of writing bytes straight over the only copy of the player's progress.
The fix is boring: temp file, verify, rename
var tmp := path + ".tmp"
var f := FileAccess.open(tmp, FileAccess.WRITE)
f.store_buffer(bytes)
f.flush()
var written := f.get_length()
f.close()
if written != bytes.size():
DirAccess.remove_absolute(tmp)
push_error("short write (%d of %d bytes)" % [written, bytes.size()])
return false
# A rename is atomic: the save is either the old version or the new one.
DirAccess.rename_absolute(tmp, path)
That single change kills failure modes 1 and 2. The file on disk is never in a partial state, because the partial file is never the one being read.
Then make damage visible
Atomic writes stop your code from corrupting the file. They do not stop a bad sector, a sync client, or an antivirus rewriting it. Add a SHA-256 of the payload in the header and verify it before decoding:
var ctx := HashingContext.new()
ctx.start(HashingContext.HASH_SHA256)
ctx.update(plain_payload)
if ctx.finish() != stored_checksum:
push_error("checksum mismatch (file is damaged)")
return {}
Now a damaged save produces a readable error instead of a dictionary full of garbage that silently resets the player to level 1.
And make old saves survive patches
Store a schema version, and apply migrations in memory on load instead of overwriting the file:
SaveKit.register_migration(1, func(d):
d["hp"] = d.get("health", 10)
d.erase("health")
return d)
The original file stays untouched until the player saves again, so a bad migration is a bug you can fix, not data you destroyed.
Test it by breaking a file on purpose
The only way to trust a save system is to corrupt a file and watch it fail. Flip one byte in a copy of a save, load it, and assert that it is rejected:
bytes[bytes.size() - 5] ^= 0xFF
If your loader returns data after that, it is guessing.
What I packaged
I got tired of rewriting this in every project, so I built SaveKit: atomic writes, checksum verification, rotating backups, versioned migrations, optional AES-256 encryption, and a type-preserving format (plain JSON turns 3 into 3.0 and destroys Vector2).
It ships with the headless test suite used to build it — 62 assertions on Godot 4.7.2 covering corruption, truncation, bad magic, migration chains, wrong passphrases, path traversal and 300 KB payloads — plus a benchmark: a 2 KB save takes 1.56 ms, a 300 KB save 10.53 ms and lands at 5.9 KB on disk.
It is a paid addon (€6, one folder, no autoloads, no telemetry): https://robertuskin.itch.io/savekit-godot-save-system
The code above is the free part — take it and put it in your game today, whether or not you use the addon. The save system is the cheapest place to avoid a one-star review that says "lost 6 hours of progress".
Top comments (0)