Players forgive a lot: a clumsy menu, a hard level, even a crash. What they do not forgive is losing twenty hours of progress. In Godot, the first save system most of us write is three lines: open a file, write some JSON, close it. It works on the developer's machine for months. Then a player's laptop runs out of battery during an autosave, or the game is updated and the old save no longer fits, and the next review is about lost progress. This guide shows how saves break and how to write them so they survive crashes, edits and updates, with GDScript we ran in Godot 4.7.1.
#How saves get lost
- A crash in the middle of a write.
FileAccess.open(path, FileAccess.WRITE)empties the file the moment it opens it. If the game crashes, the power goes or the phone kills the app before the new content is written, what is left is an empty or cut-off file, and the old save is already gone. - Values that change type.
JSON.stringify()does not know Godot types. AVector2(3, 4)becomes the string"(3.0, 4.0)", and every number comes back as a float, so an inventory count of 3 returns as 3.0. - Game updates. Version 1.1 renames a field or splits "coins" into "gold" and "silver". Old saves load without errors and the player starts with nothing.
- Edited files. Some players open the file in a text editor. Some editors save it with a different encoding, or the player deletes a bracket.
None of these show up in testing unless you test for them on purpose.
#Keep Godot's types with JSON.from_native
Since Godot 4.4, JSON.from_native() turns any Variant into JSON that remembers its type, and JSON.to_native() turns it back. A Vector2 is stored as {"type": "Vector2", "args": [3.0, 4.0]}, integers stay integers, and dictionaries with Vector2i or integer keys survive. Objects are ignored on the way back unless you allow them, which is what you want: a save file should never be able to create an object with code in it.
JSON is not the only option. FileAccess.store_var() writes Godot's binary format, which is smaller but unreadable when something goes wrong. For saves, readable JSON is worth its size: when a player sends you a broken file, you can open it. Settings belong in a separate ConfigFile, so a broken save never resets the player's volume or key bindings.
#Write to a temporary file, check it, then swap
The safe pattern is old and simple: never write over the only good copy. Write the new save next to it, make sure it is complete, keep the previous save as a backup, and only then put the new one in place. A checksum inside the file lets you tell a complete save from a cut-off or edited one later:
const BACKUPS := 2
func save_game(path: String, data: Dictionary) -> Error:
var body := JSON.stringify(JSON.from_native(data))
var text := JSON.stringify({"version": 1, "sha256": body.sha256_text(), "body": body})
var tmp := path + ".tmp"
var f := FileAccess.open(tmp, FileAccess.WRITE)
if f == null:
return FileAccess.get_open_error()
f.store_string(text)
f.flush()
f.close()
# Read it back before trusting it.
if FileAccess.get_file_as_string(tmp) != text:
DirAccess.remove_absolute(tmp)
return ERR_FILE_CORRUPT
# Keep older copies: save.json.1 is the previous save, save.json.2 the one before.
for i in range(BACKUPS - 1, 0, -1):
if FileAccess.file_exists(path + "." + str(i)):
DirAccess.rename_absolute(path + "." + str(i), path + "." + str(i + 1))
if FileAccess.file_exists(path):
DirAccess.rename_absolute(path, path + ".1")
return DirAccess.rename_absolute(tmp, path)
If anything goes wrong before the last line, the previous save is still on disk, either as the main file or as the first backup. Use paths under user://: Godot maps them to the right folder on each system, such as %APPDATA%\Godot\app_userdata\ on Windows and ~/.local/share/godot/app_userdata/ on Linux, unless the project sets its own folder name.
#Load the newest save that is intact
Loading is where the checksum pays off. Try the main file, and if it is missing, cut off or edited, fall back to the backups in order:
func read_save(path: String) -> Variant:
if not FileAccess.file_exists(path):
return null
var env = JSON.parse_string(FileAccess.get_file_as_string(path))
if typeof(env) != TYPE_DICTIONARY or not env.has("body") or not env.has("sha256"):
return null
if String(env["body"]).sha256_text() != env["sha256"]:
return null
return JSON.to_native(JSON.parse_string(env["body"]))
func load_game(path: String) -> Dictionary:
for candidate in [path, path + ".1", path + ".2"]:
var data = read_save(candidate)
if data is Dictionary:
return data
return {}
We ran both functions headless in Godot 4.7.1. After two saves we cut the main file off in the middle, as a crash would; load_game() returned the previous save from the first backup, with its Vector2 and Color intact. A file whose body no longer matched its checksum was refused. Tell the player when this happens ("Your last save was damaged; we loaded the one before it") and write the recovered save back as the main file, so the next start is clean.
The checksum is not protection against cheating: anyone who can read the code can compute it. It stops accidents, and accidents are what lose progress. If you want to discourage casual editing, Godot can also write encrypted files with a pass, which is a speed bump, not a lock.
#Version your data from the first release
Put a version number in every save, starting with 1, and keep one small function per change:
- version 1 to 2: rename "coins" to "gold";
- version 2 to 3: add "silver" with a default of 0.
On load, run the steps one after another until the data is current, then save it in the new format. A save from a newer version of the game, which happens when a player switches between a beta and the stable branch, should be refused with a clear message rather than read wrongly and overwritten.
#When to save
Save at moments that mean something: a checkpoint, the end of a level, closing a menu, buying something. Add an autosave on a timer for long sessions, to its own slot so it never overwrites a save the player made on purpose. And save when the game is leaving: NOTIFICATION_WM_CLOSE_REQUEST on desktop, and NOTIFICATION_APPLICATION_PAUSED on Android and iOS, where the system can end a paused app without warning.
Then test the failures on purpose: end the process during a save, cut a save file in half, change one number in it, and load a save from your previous release. Each should end with a working game and the newest progress that could be kept.
#An addon that does all of this
We packed these rules into Skrinka, an addon for Godot 4 with save slots, player settings and ready menus. Saves go through a temporary file, a read-back check and a SHA-256 checksum, with up to nine rotating backups, and a damaged slot is restored from the newest good backup with a signal you can show to the player. Values keep their Godot types, migrations upgrade old saves step by step, and slots have thumbnails, play time and an optional autosave. Settings apply themselves to the engine, players can rebind keyboard and gamepad controls, and the menus work in English and Bulgarian with mouse, keyboard, gamepad and touch. It is tested with 26 headless tests in Godot 4.7.1 on Linux and listed as Coming soon.
Our free native games, such as Tumblekeep and Nudgeholt, are built in Godot for Windows, Linux and Android and they too keep progress and settings in two separate files, so a problem with one never costs the other.
#Frequently asked questions
Is JSON safe for saves, or should I use binary?
JSON with JSON.from_native() keeps Godot's types and stays readable, which helps when a player sends you a broken file. Binary is smaller. Either way, do not allow objects when loading.
Does a checksum stop players from cheating?
No. Anyone who reads your code can recompute it. It catches damaged and accidentally edited files, which is its job.
How many backups should I keep?
Two is a good default: the previous save and the one before it. Saves are small, so more is cheap, but the newest intact copy is almost always the one players want.
Where does Godot keep save files on a player's computer?
Under user://, which Godot maps to a per-game folder, for example under %APPDATA% on Windows and ~/.local/share on Linux. Tell players where it is, so they can back it up.
Comments
Comments
Be the first to leave a comment.
Leave a comment