Tips for Saving Game Data in Godot

Saving game data is easy. Making your save code survive every kind of hardware failure is not.

A lot of these issues will never show up during testing, but if your game reaches a wide audience with many different kinds of hard drives in different states of degradation, you will start to get bug reports about save games not working.

I decided to write up my approach, learned over the course of shipping two Godot games, that has reduced our corrupted save game bug reports to virtually nothing.

Formats and Threads

There are many ways to save your game’s data using Godot. For our first game, The Roottrees are Dead, we used a custom binary format. It was very fast to write and the file sizes were tiny.

Our follow-up, The Incident at Galley House, uses JSON. In theory JSON is slower to write, but in practice the difference is a few milliseconds per save. And it comes with the huge benefit that you don’t need a hex editor or tools to debug a user’s save game.

Both games save frequently. Any major change to user interface state, game progress, or user actions tells the save thread that game data is ready to be saved. The save thread has a debouncer which makes sure save data isn’t written too often. It works as a throttle: if multiple things trigger a save within a short window (say 30 seconds), it will wait until the window is over and then write once. The game also forces a final save when the player quits, so nothing is left sitting in the queue.

Godot makes threading easy and it’s well documented. The important thing is to take care to pass the data through to the thread with some kind of mutex/semaphore.

No Disk Space

This is a rare issue, but if your game sells hundreds of thousands of copies it will come up. Some players will play your game with their hard drives full, or will be in the middle of running something in the background, like a download, that fills up the hard drive while playing.

Our games check that there is at least 5MB of space available when they start. If not, they bail with an error message. OS.alert is good for this.

The check is also run right before game data is saved. If it fails, the alert appears and dismissing it closes the game. This means they will lose their unsaved progress, but our games save so frequently that very little is lost.

Temporary Files and Swap

It’s a bit buried in the documentation, but Godot ships with an option that ensures all FileAccess writes go to a temporary file and are swapped over when finished. Turning it on is a one-liner:

OS.set_use_file_access_save_and_swap(true)

This protects you against a crash or power loss in the middle of a write: the old file stays intact until the new one is completely written. What it doesn’t protect against is a drive that tells the operating system the write succeeded when it didn’t.

The Big One: Backups and Verified Writes

The worst kind of bug from a failing hard drive is one that reports to the operating system that the file was written when it was not. Again, this is rare, but it will start to show up if many people play your game.

The defense is to keep a backup copy that is always a known-good file from a previous successful save. If you copy the existing save aside before you overwrite it, then a failed write can never leave the player with zero valid saves.

The following steps will remove 99% of issues related to this. When I say “bail” below, I mean show an alert message and quit the game. Something devastating has happened and the user should look at their computer right away.

  1. Check free disk space. If not enough, bail.
  2. Make a copy of the existing file (e.g. my.save) to my.bak. Check for errors. If there are any, bail.
  3. Check the file size of my.bak. It should be exactly the same as the one you copied. If not, bail.
  4. Write the new my.save, checking for errors and bailing if there are any.
  5. Check the file size of my.save. It should match the number of bytes you just wrote. If not, bail. (Checking for a size above 0 will catch empty files, but comparing against the expected length also catches truncated ones.)

In GDScript the save sequence looks roughly like this (simplified from our actual code):

const SAVE_PATH := "user://my.save"
const BACKUP_PATH := "user://my.bak"

func file_size(path: String) -> int:
    var f := FileAccess.open(path, FileAccess.READ)
    return f.get_length() if f else -1

func write_save(data: String) -> void:
    if not has_enough_disk_space():
        bail("Not enough disk space to save the game.")
        return

    if FileAccess.file_exists(SAVE_PATH):
        if DirAccess.copy_absolute(SAVE_PATH, BACKUP_PATH) != OK:
            bail("Could not back up save file.")
            return

        if file_size(SAVE_PATH) != file_size(BACKUP_PATH):
            bail("Backup file size mismatch.")
            return

    var file := FileAccess.open(SAVE_PATH, FileAccess.WRITE)
    if file == null:
        bail("Could not open save file for writing.")
        return
    file.store_string(data)
    file.close()

    if file_size(SAVE_PATH) != data.to_utf8_buffer().size():
        bail("Save file size mismatch.")
        return

At this point you should have written the save successfully. However, there is more work to do when the user attempts to load a game:

  1. Try to load my.save. Your loading code should check all sorts of data integrity issues, like missing fields, errors loading data, etc.
  2. If anything fails, you should try to load my.bak instead, transparently.
  3. If you can’t load either my.save or my.bak, bail with an error message.

There is one gotcha here. If my.save was corrupt and you recovered from my.bak, the next save would copy the corrupt my.save over your good my.bak in step 2, and now you have no backup. To avoid this, once the backup loads successfully, immediately write it back out as my.save (using the same verified write procedure above) so that both files are good again before the game continues.

Good Luck

I hope this post is useful to you, as this is all information I wish I’d known when shipping my first PC game.