doc 3 of 4

Part 02 — Writing it down

You will build: working save and load — a file on disk, read back correctly, with the game state restored through the same code paths gameplay uses.

Part 01 did the hard thinking. This part is mechanical, and short.

Where the file goes#

const SAVE_PATH: String = "user://save_01.json"

user:// resolves to a real writable directory per user, per project. Not res:// — that's read-only in an exported game, and every save silently fails for players while working perfectly in your editor. Project → Open User Data Folder shows you the real location.

Writing#

func save_game() -> Error:
	var data: Dictionary = {
		"version": SAVE_VERSION,
		"saved_at": int(Time.get_unix_time_from_system()),
		"player": player.to_dict(),
		"inventory": inventory.to_dict(),
		"level": current_level_path,
		"flags": flags.duplicate(),
	}

	var file := FileAccess.open(SAVE_PATH, FileAccess.WRITE)
	if file == null:
		push_error("could not open save: %s" % error_string(FileAccess.get_open_error()))
		return FileAccess.get_open_error()

	file.store_string(JSON.stringify(data, "\t"))
	return OK

Three things:

FileAccess.open returns null on failure, and it does fail — disk full, permissions, a path that doesn't exist. The null check is not defensive padding; it's the difference between an error message and a crash on a player's machine you'll never see. FileAccess.get_open_error() tells you which failure it was.

No file.close(). FileAccess closes when the reference goes out of scope. Calling close() isn't wrong, but if you've seen file.close() in a tutorial next to File.new(), you were reading Godot 3 — File doesn't exist any more.

JSON.stringify(data, "\t") — the second argument indents the output. Costs a few bytes and makes save files you can open in a text editor and read. During development that is worth an enormous amount; you can diff two saves and see exactly what changed.

Reading#

func load_game() -> bool:
	if not FileAccess.file_exists(SAVE_PATH):
		return false   # no save yet is normal, not an error

	var file := FileAccess.open(SAVE_PATH, FileAccess.READ)
	if file == null:
		push_error("save exists but could not be opened")
		return false

	var parsed: Variant = JSON.parse_string(file.get_as_text())
	if parsed is not Dictionary:
		push_error("save file is corrupt — ignoring")
		return false

	var data: Dictionary = parsed
	# ... version check goes here (part 03) ...

	current_level_path = data.get("level", DEFAULT_LEVEL)
	flags = data.get("flags", {})
	inventory.from_dict(data.get("inventory", {}), catalog)
	player.from_dict(data.get("player", {}))
	return true

"No save file" is not an error. First launch has no save. Return false, start a new game, don't log anything alarming.

JSON.parse_string returns null on malformed input. A save file can be corrupt — a crash mid-write, a player editing it, cloud sync losing a race. Check the type before you use it.

data.get(key, default) everywhere, never data[key]. A missing key with [] throws and takes the whole load down. With get() and a sensible default, an old save missing a field that didn't exist yet loads fine. This one habit does more for save compatibility than anything else in this shelf.

The types JSON loses#

JSON has strings, numbers, booleans, arrays, objects, null. That's the whole list. Everything else converts:

# out
"position": [position.x, position.y],
"tint": tint.to_html(),

# back
var p: Array = data.get("position", [0.0, 0.0])
position = Vector2(p[0], p[1])
tint = Color.from_string(data.get("tint", "ffffff"), Color.WHITE)

And the one that bites everyone: JSON has a single number type. An int you wrote comes back as a float. 12 becomes 12.0, and now your item count is a float, your array index throws, and your UI shows "12.0 wheat". Wrap every integer on the way in:

stack.count = int(entry["count"])

Not sometimes. Every one.

Order matters on load#

Restore the data layer first, the scene second. Set the inventory and player stats while nothing is looking, then load the level, then let the level's nodes read from the restored data as they enter the tree.

Do it the other way — load the level first — and every node initialises from default state, briefly, and anything that reacts on _ready (spawning, autosaving, playing a sound) fires with the wrong values before you correct them. Players see one frame of full health. Autosaves overwrite the real save with defaults. Load order is not a detail.

Testing it properly#

func _unhandled_input(event: InputEvent) -> void:
	if event.is_action_pressed("debug_save"):
		save_game()
	elif event.is_action_pressed("debug_load"):
		load_game()

Then run this checklist, because each item catches a different class of bug:

  1. Save, change things, load → everything reverts.
  2. Save, quit the game entirely, relaunch, load → still correct. (Catches state that happened to still be in memory. This is the one people skip and it's the one that matters.)
  3. Delete the save, launch → clean new game, no errors.
  4. Corrupt the save on purpose — open it and delete a brace — and load → a friendly failure, not a crash.

Predict before you run: open your save file in a text editor. Every value in it should be something you deliberately chose to store. Anything you can't justify is either derived state that shouldn't be there, or a fact you didn't realise you were saving.

Checkpoint — definition of done#

  • A readable, indented JSON file appears in the user data folder
  • All four test cases above pass, including the quit-and-relaunch one
  • Every integer is wrapped with int() on load
  • Every read uses get() with a default
  • Loading refreshes the UI without any explicit refresh call
  • You can explain: why must the data layer be restored before the level scene loads?

Stretch (no instructions)#

Add three save slots and a menu listing them with their timestamps, showing "empty" for slots with no file. Then add an autosave — and decide where it's safe to trigger one. (Hint: not during a scene transition. Work out why before you find out.)

If you get stuck#

  • File never appears → print the resolved path with ProjectSettings.globalize_path(SAVE_PATH) and look at where it actually points.
  • Loads but everything is zero → your from_dict keys don't match your to_dict keys. Print both dictionaries side by side; it's almost always a typo, which is exactly why the schema got written down in part 01.
  • Counts display as 12.0 → an unwrapped int().
  • Crash on load with "Invalid get index" → a data[key] that should be data.get(key, default).

Next: Part 03 — when it breaks, which is about the save files already on players' disks when you ship an update.