doc 2 of 4

Part 01 — Deciding what to save

You will build: a written save schema for a project you actually have, plus the save() methods on your data objects. No file I/O yet — that's part 02, and it's the easy half.

The audit#

Open a project. List every fact the player would be angry to lose. Be specific — "the player" is not a fact; "the player's position, hp, and unlocked abilities" is three facts.

Then put each one in a column:

FactWhere does it live right now?Category
Player hpplayer.gd, var hpauthored state
Player positionthe node's positionnode state
Inventory contentsInventory resourceauthored state
Health bar widthHealthBar.valuederived
Which chests are open...a bool on each chest node?node state
Current level...the currently loaded scene?implicit

Three categories, three different amounts of trouble:

Authored state lives in a plain data object you own. Trivial to save. This is where you want everything.

Node state lives on a node — position, a bool on a chest. Saveable, but you must re-find the node on load, which means it needs a stable identity that survives a scene reload. Node names usually work; node paths usually don't.

Derived must not be saved at all. Recompute it. If the health bar's width can't be recomputed from hp, the bar is holding information the game doesn't have elsewhere — which is the bug, not the save.

Implicit is the dangerous one. "Which level am I in" often exists only as which scene happens to be loaded. Nothing holds it as a value, so there's nothing to write down. Every implicit fact must become an explicit one before it can be saved.

Do this audit honestly and you'll usually find two or three implicit facts. Finding them is the point of this part.

Designing the schema#

A save file is a Dictionary that becomes JSON. Design it before writing code:

{
  "version": 1,
  "saved_at": 1753000000,
  "player": { "hp": 40, "max_hp": 50, "position": [312.0, 88.5] },
  "level": "res://levels/forest.tscn",
  "inventory": { "slots": [ {"id": "wheat", "count": 12}, null, ... ] },
  "flags": { "chest_forest_01": true, "met_the_smith": true }
}

Four choices in there worth arguing about:

version first, always. Part 03 explains what it buys. It costs one line now and is impossible to add retroactively to saves already on players' disks.

Items are saved by id, not by resource path. "wheat" survives you reorganising res://items/ into res://data/items/. A stored path does not — and a save file full of dead paths is unrecoverable. This is why part 01 of the inventory shelf gave ItemData an explicit id field rather than relying on the filename.

position is a two-element array. JSON has no Vector2. You convert. Doing it explicitly in one place beats discovering it as a crash.

Flags are one flat dictionary keyed by string. Every "has this happened yet" question in the game — chests, conversations, doors — goes in one place. Tempting to scatter these as booleans on nodes; resist, because scattered flags are exactly the node state that's painful to re-find on load.

Each object describes itself#

Don't write one giant SaveManager that reaches into everything and knows every field. That function becomes the single most-edited, most-broken file in the project — every new feature touches it.

Instead each data object converts itself, and the manager just assembles:

# in inventory.gd
func to_dict() -> Dictionary:
	var out: Array = []
	for stack in slots:
		if stack.is_empty():
			out.append(null)
		else:
			out.append({ "id": String(stack.item.id), "count": stack.count })
	return { "slots": out }


func from_dict(data: Dictionary, catalog: ItemCatalog) -> void:
	slots.clear()
	for entry in data.get("slots", []):
		var stack := ItemStack.new()
		if entry != null:
			stack.item = catalog.by_id(StringName(entry["id"]))
			stack.count = int(entry["count"])
		slots.append(stack)
	emit_changed()

Two things there earn their keep.

from_dict needs a catalog — something that turns "wheat" back into the actual ItemData resource. That's a small class holding a dictionary from id to resource, built once at startup by scanning your items folder. Loading cannot work without it, and noticing that now is much better than noticing it halfway through part 02.

from_dict ends with emit_changed(). Loading goes through the same notification path as gameplay, so every UI refreshes itself exactly as it would after a pickup. No special "refresh everything after load" pass — which is a thing that always gets written, and always forgets one panel.

Checkpoint — definition of done#

  • You have a written table of every saveable fact in a real project, categorised
  • You found at least one implicit fact and made it explicit
  • A schema exists on paper, with version in it
  • to_dict() / from_dict() exist on at least one data object and round-trip in a print
  • You can explain: why are items stored by id rather than by resource path?

Stretch (no instructions)#

Write the ItemCatalog. It should build its id→resource map by scanning a directory at startup, and it should complain loudly if two items share an id — a duplicate id is a bug that otherwise shows up months later as an item mysteriously turning into a different item on load.

If you get stuck#

  • "What about node positions?" → save them as data ([x, y]) belonging to the entity's record, not by asking the node at save time. The node is presentation; the record is truth.
  • Round-trip loses data → print both dictionaries and diff them by eye. Almost always an int arriving back as a float: JSON has one number type, so int(entry["count"]) is not optional.
  • Stuck on how to save "which enemies are still alive" → don't save the enemies. Save the list of dead ones' ids, and let level loading skip them. Absence is usually cheaper than presence.

Next: Part 02 — writing it down.