In Godot 4, the most-used save addon on GitHub writes a file called save_data.sav. It is plain JSON.
That is not a bug. It is a naming choice, and it is fine for the game. It breaks every other program that touches the file - an external editor, a debug script, a CI check, or a coding agent - as soon as that program decides the file format from the extension.
What the file actually is
SaveMadeEasy (220+ stars, MIT) is the Godot 4 save/load addon you find first when you search for one. In its source, the default path is
const default_file_path := "user://save_data.sav"
and it writes with
file.store_string(JSON.stringify(current_state_dictionary, "\t"))
Encryption is off by default. So the file a lot of Godot games produce today is tab-indented JSON with a .sav extension, sitting in user://.
I found this the embarrassing way. A save-file reader I maintain picks its parser from the extension. Handed save_data.sav, it reported a config-file parse error and refused the whole file. The same bytes renamed to save_data.json opened immediately: 8 values, including "hp": 12.5, "tutorial_done": true, and a position stored as the string "Vector3(2, 5, 0)".
The content was never in doubt. Only the name was.
Why an agent in the loop makes this worse
A human who gets "this file is not a config file" opens it in a text editor and sees JSON. An agent that gets the same error, with write access to the file, can conclude the file is corrupt and rewrite it. That is a data-loss path that begins with a mis-detected format and looks like a fix from the inside.
Two rules remove it:
- Detect the format from the bytes, not from the name.
- Make the read path read-only by construction, not by a flag. If the code that inspects a save file contains no write call at all, "just checking" cannot damage it.
Sniffing instead of guessing
# Returns a parsed JSON value, a ConfigFile, or null. Never writes.
# The second argument receives a human-readable description of what was found.
static func open_save(path: String, found: Array) -> Variant:
var f := FileAccess.open(path, FileAccess.READ)
if f == null:
found.append("could not be opened")
return null
var text := f.get_as_text()
f.close()
if text.begins_with("\ufeff"):
text = text.substr(1) # UTF-8 BOM
var head := text.strip_edges()
if head.begins_with("{") or head.begins_with("["):
var json := JSON.new()
if json.parse(text) == OK:
found.append("JSON")
return json.data
found.append("JSON, but it does not parse")
return null
var cfg := ConfigFile.new()
if cfg.load(path) == OK:
found.append("Godot config file")
return cfg
found.append("not text (binary or encrypted)")
return null
Then report what was detected. "Refused: JSON, but it does not parse" tells a person - or an agent - which branch was taken and what to do next. "Parse failed" does not.
Things the file tells you that the addon does not
Most of the open issues on that addon are about what ends up inside the file, not about saving:
- A
global_transformcomes back as the string"[X: (0.99956, 0, -0.029666), ...]". Values that are not numbers, strings or booleans get serialized into text, and something has to parse them back. - A nested resource is written as
<Resource#-9223369771041419952>- an opaque id with no meaning outside the process that wrote it. - Dictionary keys that are not strings come back as strings.
None of that is visible unless someone opens the file. Which is the argument for choosing a format you can read:
-
store_var()writes a binary variant stream. It round-trips perfectly, and you cannot open, diff or review it. - JSON costs a conversion on load - numbers come back as floats,
Vector2has to be rebuilt - and costs nothing while debugging. -
ConfigFilekeeps Godot literals (Vector2(...),Array[int]([...]),Object(InputEventKey, "...")), so types survive, at the price of a format nothing else reads.
Whichever you pick, put a version in it now ("version": 1, or version=1 as a top-level key in a config file). Saves written before that key existed are the ones you cannot migrate later.
Four checks before you ship a save format
- Open the save file with something that is not the game.
- Change one value, and confirm only that value's bytes moved.
- Rename the file - your game should behave exactly the same. If it does not, the extension is doing work it should not be doing.
- Delete the file and confirm the game starts clean instead of crashing.
I maintain Save Value Editor, a Godot addon that lists and edits ConfigFile/JSON save values from a panel or a read-only command line. None of the checks above need it - a text editor and a bit of GDScript cover them - but if you are the one who keeps opening these files at 2am, that is the job it does.
Top comments (0)