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:
| Fact | Where does it live right now? | Category |
|---|---|---|
| Player hp | player.gd, var hp | authored state |
| Player position | the node's position | node state |
| Inventory contents | Inventory resource | authored state |
| Health bar width | HealthBar.value | derived |
| 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
versionin 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
idrather 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
intarriving back as afloat: JSON has one number type, soint(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.