You shipped a save system, it worked on your machine, and now the console is red on
load. Save/load bugs are nasty because they only show up after a write already
happened — the bad data is already on disk. Here are the three errors that break
Godot 4 saves most often, why each one happens, and the exact fix.
1. JSON Parse Error: ... at line N
Symptom. On load you get something like:
JSON Parse Error: Unexpected end of stream at line 1
or a parse error pointing at a spot in the middle of your file.
Cause. The file was written partially. This almost always means the game
crashed (or was force-quit) during the write, or two systems wrote to the same
file at once. FileAccess flushes on close(); if you never reach close(), you
get a truncated, unparseable file.
Fix. Never write in place. Write to a temp file, then rename it over the real
one — rename is atomic on every desktop OS, so the save is either the old file or
the complete new one, never a half of each:
func save_state(data: Dictionary, path := "user://save.json") -> void:
var tmp := path + ".tmp"
var f := FileAccess.open(tmp, FileAccess.WRITE)
if f == null:
push_error("Save failed to open %s: %d" % [tmp, FileAccess.get_open_error()])
return
f.store_string(JSON.stringify(data))
f.close() # flush BEFORE the rename
DirAccess.rename_absolute(ProjectSettings.globalize_path(tmp),
ProjectSettings.globalize_path(path))
And always guard the read instead of trusting JSON.parse_string:
func load_state(path := "user://save.json") -> Dictionary:
if not FileAccess.file_exists(path):
return {}
var text := FileAccess.get_file_as_string(path)
var parsed = JSON.parse_string(text)
if parsed == null or typeof(parsed) != TYPE_DICTIONARY:
push_warning("Save at %s is corrupt — falling back to empty." % path)
return {}
return parsed
2. Invalid call ... Attempt to call ... on a null instance
Symptom.
Invalid call. Nonexistent function 'get' in base 'Nil'.
right after you read the save back.
Cause. JSON.parse_string returns null on failure, and you used the result
without checking. It also cannot return your original types — a Vector2 you
stored becomes a plain array or null, a custom object becomes a Dictionary. So
saved.player_pos.x blows up because player_pos is not a Vector2 anymore.
Fix. Serialize non-JSON types explicitly on the way out, and rebuild them on the
way in. Don't assume the shape survived:
# on save
data["player_pos"] = {"x": player.position.x, "y": player.position.y}
# on load
var d: Dictionary = load_state()
if d.has("player_pos"):
var p = d["player_pos"]
player.position = Vector2(p.get("x", 0.0), p.get("y", 0.0))
Every read from the loaded dictionary should use .get(key, default) so a missing
key gives a sane value instead of a Nil.
3. Error opening file / values silently wrong after an update
Symptom. Either the file just isn't there on a fresh install, or a returning
player loads an old save and half their state is missing after you added fields.
Cause. Two separate issues wearing the same coat: (a) you read before any save
exists, and (b) the save's schema changed between versions and old files don't
have the new keys.
Fix. Version your saves and migrate forward. Stamp a version number on write,
and run migrations on read so an old file becomes a valid current one:
const SAVE_VERSION := 3
func migrate(d: Dictionary) -> Dictionary:
var v := int(d.get("version", 1))
if v < 2:
d["settings"] = d.get("settings", {"volume": 1.0}) # added in v2
if v < 3:
d["playtime"] = d.get("playtime", 0.0) # added in v3
d["version"] = SAVE_VERSION
return d
Now old saves keep loading instead of erroring, and new fields get safe defaults.
The pattern behind all three
Every one of these bugs is the same root cause: treating the disk as trustworthy.
It isn't. A robust save layer writes atomically, validates on read, defaults every
missing key, and migrates old schemas — none of which is hard, but all of which is
easy to forget until a player loses a save. If you'd rather not re-derive the atomic
write, corruption guard, backup fallback, and schema migration in every project,
that's exactly the boring, tested wiring Saltmire Save is.
If you'd rather drop this in than build it, Saltmire Save does it as a ready-made tool: https://saltmire.itch.io/saltmire-save
Originally published at https://saltmire.github.io/godot-4-save-file-load-errors.html
Top comments (0)