DEV Community

Fernando Garcia
Fernando Garcia

Posted on Edited on

Your Godot save file is a single point of failure (and the 40 lines that fix it)

Every Godot project starts with this line:

FileAccess.open("user://save.dat", FileAccess.WRITE).store_var(state)
Enter fullscreen mode Exit fullscreen mode

It works until it doesn't. Three failure modes, none of which show up while you test:

  1. 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.
  2. A full disk. store_var writes less than you asked for and returns nothing you check.
  3. A patch that changes the save shape. Old saves decode into null, and your game either starts a new run or crashes on null["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)
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)