Skip to content

ConfigFile / JSON Serialize

Try it first

You are about to write the save system. The player closes the game; the next time they open it, they expect to find their progress exactly where they left it.

What state do you save to disk?

Walk through every field in GameState you've built across M2–M6 and decide, for each, whether it goes in the save file or not. Some are obvious (the player's Light total — yes). Some are genuinely ambiguous (tick_multiplier? _income_rates? honor_multiplier?).

Spend 4–5 minutes. Make a list:

  • Save these fields.
  • Don't save these fields.
  • Not sure about these fields.

The chapter walks through a specific design — primary state goes in, derived state stays out — and explains the trade-off. Compare your list against it.

What you'll learn

  • The user:// path scheme — Godot's cross-platform writable directory — and why save data lives there instead of res://. The runtime cannot write to res:// in exported builds; user:// is the only durable per-machine location.
  • FileAccess.open(path, mode) — Godot's file primitive. Write/read modes (WRITE, READ), the returned FileAccess object's lifecycle, and what happens if open fails (returns null, sets a global error code).
  • JSON.stringify(value) and the corresponding JSON.parse_string(text) — the canonical Variant-to-text round-trip. What types serialize cleanly (Dictionary, Array, String, float, int, bool) and what doesn't (Object references, Resource handles, Callables).
  • The save schema as a contract: which fields are written, what their types are, what their default values mean, and the version field that lets future migrations recognize older saves. The schema is documented in code, not just implied.
  • Save triggers — when to call save_game(). The pragmatic set: on prestige, on building/upgrade purchase, on app quit (NOTIFICATION_WM_CLOSE_REQUEST), and periodically (a 30s autosave). M7.1 wires the triggers; the trigger choices are explicit design decisions.

How it applies

  • Players hate losing progress. A 40-hour idle save that vanishes because the autosave timer was 5 minutes and the player crashed at minute 4 is a community-incident-grade failure. Frequent saves (every state change, plus periodic) cost almost nothing on modern storage and prevent the worst class of player complaints. M7.1's "save on every meaningful mutation" approach trades a few extra disk writes for a save-loss surface area approaching zero.
  • Cross-platform path discipline. Windows stores user:// under %APPDATA%/Godot/app_userdata/<projectname>/. macOS uses ~/Library/Application Support/Godot/app_userdata/<projectname>/. Linux uses ~/.local/share/godot/app_userdata/<projectname>/. Hardcoding any of these breaks the others. user:// is the only path that compiles to "the right place per OS" — and it's the path the engine documents, the path Steam Cloud sync hooks pick up, and the path the project's own save backup tool would target.
  • JSON vs binary trade-off. JSON is human-readable, diff-friendly, modifiable in a text editor, and 10–100× larger than a packed binary equivalent. For an idle game with kilobyte-scale save state, the size penalty is invisible; the readability is invaluable for support ("paste your save file") and debugging ("did the migration drop a field?"). Binary saves only pay off at megabyte scale or when piracy concerns demand obfuscation — neither applies here.
  • Schema versioning is forward-compatibility insurance. A version: 1 field added today costs one line. Without it, a future schema change forces either: (a) brute-force "try parsing, fall back to defaults" (loses data when fields rename), or (b) a one-time migration script that ships before the schema change (deployment dependency). With it: the load path can branch on version and run targeted migrations. The version field is the cheapest piece of save infrastructure with the highest deferred payoff.
  • Quit-time save reliability. A web build closing the tab gives the runtime ~10 ms of cleanup time before the page unloads. A desktop build closing the window via the window manager fires NOTIFICATION_WM_CLOSE_REQUEST before the process is killed, giving you arbitrary time. Hooking quit-save into the close-request notification rather than _exit_tree is the difference between a save that lands and a save that's truncated. Web builds need a different strategy (browser beforeunload handler) — that's an M7+ exercise.

Concepts

user:// — Godot's writable scheme

Godot's path system has two virtual roots:

  • res:// — the project resources directory. Read-only at runtime in exported builds. Editor builds can write to it because the editor is running against the source tree, but the moment the project ships, every res:// write becomes a runtime error.
  • user:// — a per-user, per-project, per-OS writable directory. Created lazily on first write. Survives uninstalls only if the OS treats the directory as user data (it does on all three desktop platforms; mobile is more aggressive about wiping app data on uninstall).

user:// path

The save file's full path: user://savegame.json. The filename is conventional — Godot does not enforce one. Some teams use .save, .dat, or .cfg; the suffix has no semantic meaning to the engine.

Example

A player on Windows installs the exported build to C:\Games\BloodKnightGrove\. The executable is at res:// in runtime terms (mapped to the install dir) but not writable. When the game first calls FileAccess.open("user://savegame.json", FileAccess.WRITE), Godot resolves user:// to C:\Users\<them>\AppData\Roaming\Godot\app_userdata\Blood Knight Grove\, creates the directory if missing, and writes the file there. The player can uninstall the game from C:\Games\ without losing the save in %APPDATA%\Godot\.

FileAccess.open and the file lifecycle

FileAccess is Godot's file-handle class. Three things to know:

var file: FileAccess = FileAccess.open("user://savegame.json", FileAccess.WRITE)
if file == null:
    push_error("Save open failed: %s" % FileAccess.get_open_error())
    return
file.store_string(serialized)

FileAccess

First, open returns null on failure (path invalid, permissions denied, disk full). Always check. The companion FileAccess.get_open_error() returns an Error enum valueOK (0) on success, otherwise an error code suitable for logging.

Second, FileAccess is RefCounted. The handle auto-closes when its last reference drops. The pattern in the snippet above closes the file at the end of the function naturally; explicit file.close() is allowed but not required.

Third, WRITE mode truncates if the file exists. There is no append-or-overwrite ambiguity — opening in write mode resets the file to empty, then store_string writes from byte zero. For save files (which are always rewritten in full) this is exactly the desired behavior.

Example

A save in progress fails halfway because the disk fills up. FileAccess.open succeeded, truncating the previous good save to empty. store_string returns without writing the full payload. The result: an empty (or partial) save file replacing the previous good one. This is the atomic write problem. The defensive pattern — write to user://savegame.json.tmp first, then DirAccess.rename to user://savegame.json on success — is left as walkthrough exercise; M7.1 ships the simple version and notes the gap. Atomic-write upgrade is a known M7+ improvement.

JSON.stringify / JSON.parse_string

Godot's built-in JSON marshaller round-trips Variants:

var serialized: String = JSON.stringify(state_dict, "  ")

The second argument is the indent string — " " (two spaces) produces pretty-printed output; omitting it (or passing "") produces compact one-line output. Pretty-printed is the default choice for development saves: a support ticket attaching a save you can read in Notepad is worth the extra bytes.

JSON.stringify

What serializes cleanly: - Dictionary[String, *], Array[*], String, int, float, bool, null. - Nested structures of the above.

What does not: - Object and any subclass — including Node, Resource, RefCounted. Stringify silently emits {} (empty object) for these. The caller's responsibility is to convert objects to plain Dictionaries before passing to stringify. - Callable, Signal, RID — also blanked.

The implication: GameState._buildings (which is a Dictionary[String, BuildingData]) cannot be stringify'd directly because BuildingData is an Object. The serializer needs a converter — typically a method on BuildingData that returns a plain Dictionary, or (cheaper) a serialization layer that records just the count and id per building, since the static fields (base_cost, output, etc.) live in the .tres file and don't need to ride in the save.

Example

_buildings at save time: {"initiate": <BuildingData>, "aspirant": <BuildingData>}. Each BuildingData has the count (mutable runtime state) plus the data fields (immutable, defined in .tres). The save only needs the counts — the IDs identify which .tres to reload, the rest is reconstructed from the .tres on the next load. So the saved shape is {"initiate": 5, "aspirant": 2}, not the BuildingData instances. This is the project state vs reference data split: save what the player did (counts), let the project files carry what the designer authored (stats).

The save schema and your list

The save's structure makes a specific design call: save inputs, recompute outputs. Walk your list against the chapter's:

  • Saved. _resources (current values), _lifetime_light, _buildings (counts), _purchased_upgrades (ownership), tick_multiplier. These are primary state: the player's actions over time produced them, and there is no way to reconstruct them from anything else in the game.
  • Not saved. _income_rates, honor_multiplier, click_honor_multiplier. These are derived state: pure functions of the saved fields. On load, the chapter calls _recalculate_income() once and they reconstruct exactly. Saving them would be either redundant (the cache and source agree) or a class of bug (they disagree silently).

If your list saved a derived value (_income_rates is the most common student answer), ask: what would happen if a future patch changed the formula but the save still held the old computed value? The answer is "the player loads into a game where their cached rate disagrees with the recomputed one." Saving inputs avoids the disagreement entirely.

If your list missed a primary value (_lifetime_light is the most common omission), ask: how would the prestige threshold be checked on a fresh load? Lifetime is what gates the Pledge Crusade button; missing it means the button is wrong on load.

The shape of the design is "primary in, derived out." It's the same shape M3.2's recompute discipline produced one module's-worth of state earlier, applied at the save boundary instead of the recompute boundary.

The save is a single Dictionary with a fixed set of keys. M7.1's schema is the application:

{
    "version": 1,
    "saved_at": <unix timestamp int>,
    "resources": {"light": <float>, "honor": <int>},
    "lifetime_light": <float>,
    "tick_multiplier": <float>,
    "buildings": {<id: String>: <count: int>},
    "purchased_upgrades": [<id: String>, ...]
}

save schema

Five design points.

First, version is an integer, monotonically increasing. M7.1 ships at version 1; any future schema-breaking change increments to 2 and adds a migration path in M7.2's load function. Version is the first field by convention so a partial-read could still see it.

Second, saved_at is included now even though M7.1 doesn't read it; M7.3 (offline earnings) needs it, and adding it later would require a v2 schema. Forward-think the obvious additions.

Third, derived values (honor_multiplier, click_honor_multiplier) are not saved. They reconstruct from resources.honor on load via _recalculate_income. Saving them would mean either accepting drift (saved value vs computed value diverge if the formula changes) or recomputing-on-load anyway (saving them is then dead bytes).

Fourth, tick_multiplier is saved despite being recomputable from purchased_upgrades. The reason: replaying every upgrade's effect on load works only if the effect resolution is deterministic and order-independent (M4.3 made it so), but saving the value short-circuits the replay. The trade-off is real — M7.2's load could go either way, and the discussion belongs to that chapter.

Fifth, the schema is documented in code — the _serialize_state() method's body is the schema definition. There is no separate schema file. This is intentional for a project this size; a 50-key schema would justify extracting a JSON Schema document.

Example

A first-time player completes M2.1 (their first click), Light is now 1. Save fires. The serialized dict: {"version": 1, "saved_at": 1734567890, "resources": {"light": 1.0, "honor": 0}, "lifetime_light": 1.0, "tick_multiplier": 1.0, "buildings": {}, "purchased_upgrades": []}. Empty dictionaries and arrays for the things they haven't done yet. The schema's defaults make a fresh-game save indistinguishable from a never-saved game, which is the right behavior — load-from-empty-save and start-from-defaults converge.

Save triggers

When does save_game() run? Four call sites:

  1. On prestige. pledge_crusade() ends with a save. Honor is the most expensive thing the player earns; losing it is unacceptable.
  2. On purchase. Each purchase_building() and purchase_upgrade() ends with a save. A player who buys their first Champion (M5) and then crashes should not relog and find no Champion.
  3. On quit. _notification(NOTIFICATION_WM_CLOSE_REQUEST) on the main scene calls save_game() before get_tree().quit(). The window manager respects this delay; the user's click on the X button does not vanish progress.
  4. Periodically. A Timer autoload ticks every 30 seconds and calls save_game(). Catches the crash-without-clean-quit case (power loss, OS kill).

save triggers

The four-trigger setup is overkill for a careful player and just-right for a careless one. Removing periodic saves means players who run for an hour without buying anything risk losing the hour to a crash. Removing quit saves means players who close mid-tick lose state since the last purchase. Each trigger covers a specific failure mode; the cumulative cost is one boolean per save (early-out if nothing changed since last save would cut idle disk writes — left as an optimization).

Walkthrough

You'll add a _serialize_state() method to GameState, a save_game() method that writes it to user://savegame.json, and the four call sites listed above.

Step 1. Open scripts/game_state.gd. Find a sensible place near the bottom of the file for the save block (after the recompute method, before any helpers).

Step 2. Add the constants and the serializer:

const SAVE_PATH: String = "user://savegame.json"
const SAVE_VERSION: int = 1

func _serialize_state() -> Dictionary:
    return {
        "version": SAVE_VERSION,
        "saved_at": int(Time.get_unix_time_from_system()),
        "resources": _resources.duplicate(),
        "lifetime_light": _lifetime_light,
        "tick_multiplier": tick_multiplier,
        "buildings": _buildings.duplicate(),
        "purchased_upgrades": _purchased_upgrades.keys(),
    }

_buildings.duplicate() is a shallow copy of the int-valued Dictionary — _buildings is already Dictionary[String, int] (M5.3's schema: id → count), so the snapshot round-trips through JSON cleanly. _purchased_upgrades.keys() returns the owned-upgrade ids as an Array[String]; saving the values (UpgradeData references) would not survive JSON.stringify, which cannot serialize Resource references — exactly the failure mode the concept section flagged above. The save format declares this: "purchased_upgrades": [<id: String>, ...]. M7.2 rebuilds the typed Dictionary on load by looking up each saved id in a project-level UpgradeData registry.

Step 3. Add save_game():

func save_game() -> void:
    var state: Dictionary = _serialize_state()
    var text: String = JSON.stringify(state, "  ")
    var file: FileAccess = FileAccess.open(SAVE_PATH, FileAccess.WRITE)
    if file == null:
        push_error("save_game open failed: %s" % FileAccess.get_open_error())
        return
    file.store_string(text)

The early return on open failure is critical. A failed save that returns silently is the worst possible outcome — the player thinks they're saved, the save isn't there.

Step 4. Wire the prestige trigger. Open the existing pledge_crusade() method (M6.1). After the crusade_pledged.emit(honor_gain) line and the _recalculate_income() call, add save_game():

crusade_pledged.emit(honor_gain)
_recalculate_income()
save_game()
return honor_gain

Step 5. Wire the purchase triggers. Open purchase_building() and purchase_upgrade(). At the end of the success path (after _recalculate_income()), add save_game(). Two methods, one extra line each.

Step 6. Wire the quit trigger. Open scenes/main.gd (or whichever script attaches to the main scene root). Add _notification:

func _notification(what: int) -> void:
    if what == NOTIFICATION_WM_CLOSE_REQUEST:
        GameState.save_game()
        get_tree().quit()

NOTIFICATION_WM_CLOSE_REQUEST fires when the OS asks the window to close. Calling quit() from inside the handler completes the close cleanly; the save runs first.

Step 7. Wire the periodic trigger. In the Tick autoload (M3.1), add a parallel accumulator for saves:

var _save_accumulator: float = 0.0
const SAVE_INTERVAL: float = 30.0

func _process(delta: float) -> void:
    # ... existing tick logic ...
    _save_accumulator += delta
    if _save_accumulator >= SAVE_INTERVAL:
        _save_accumulator -= SAVE_INTERVAL
        GameState.save_game()

The same accumulator pattern as M3.1's tick — different cadence, same shape.

Step 8. Save all three files (Ctrl+S on each in the editor) and run (F5).

Step 9. Click a few times to earn Light. Buy an upgrade. Wait ~30 seconds. Quit by clicking the X.

Step 10. Open %APPDATA%\Godot\app_userdata\<your project name>\ in File Explorer. Confirm savegame.json exists. Open it in Notepad — pretty-printed JSON, version 1, your current state visible as a tree.

Step 11. Run the game again. The save still loads as a fresh game because M7.2 (deserialize) hasn't been written yet — that's the next chapter. M7.1 ships the write path; the read path is M7.2's job.

Self-check quiz

Quiz

Q1. Why save to user://savegame.json instead of res://savegame.json?

  • A) user:// is faster to write to.
  • B) res:// is read-only at runtime in exported builds; only user:// is writable.
  • C) res:// files are version-controlled; user:// files are not.
  • D) Godot does not allow JSON in res://.

Reveal

Correct: B. res:// maps to the project's source/asset directory and is sealed at export time. The runtime cannot write to it. user:// is the per-user writable scheme and the only durable runtime-write path on exported builds.

  • A is wrong: write speed is comparable; the difference is permission, not performance.
  • C is true as a practice but unrelated to runtime constraints — the issue is whether the engine permits the write at all.
  • D is wrong: JSON is just text; Godot doesn't restrict file formats by path scheme.

Q2. _serialize_state() returns a Dictionary that includes tick_multiplier but not honor_multiplier. Why the asymmetry?

  • A) tick_multiplier is an integer; honor_multiplier is a float, which can't be JSON-encoded.
  • B) tick_multiplier is mutated by purchases (cached state); honor_multiplier is derived from _resources["honor"] on every recompute (derived state). Saving derived values is dead bytes — they reconstruct on load.
  • C) honor_multiplier is a private field; tick_multiplier is public.
  • D) honor_multiplier is too large to serialize.

Reveal

Correct: B. M6.2's distinction: cached state is stored and saved; derived state is recomputed on demand and reconstructed from its inputs on load. honor_multiplier reconstructs from _resources["honor"] (which is saved). Saving it would mean either drift (if the formula changes) or redundancy (if it doesn't).

  • A is wrong: floats serialize fine in JSON.
  • C is wrong: visibility has no bearing on save policy.
  • D is wrong: it's a single float — bytes are not the issue.

Q3. A player crashes without quitting cleanly. Which save trigger covers them?

  • A) The NOTIFICATION_WM_CLOSE_REQUEST handler in main.gd.
  • B) The 30-second periodic save in the Tick autoload.
  • C) The _serialize_state() method.
  • D) None — crashes always lose progress.

Reveal

Correct: B. The periodic save is the crash-coverage trigger. The quit handler only fires on clean exit. The other triggers (prestige, purchase) cover specific actions but not idle play. Periodic catches the gap.

  • A is wrong: a crash skips the close-request notification entirely. The handler is for clean shutdown, not crashes.
  • C is wrong: _serialize_state is a helper called by save_game, not a trigger.
  • D is wrong and is exactly what the trigger overlap exists to prevent.

Q4. Parsons (ordering).

Below are eleven lines of GDScript, scrambled. Two are distractors (they look plausible but should not appear in the body of _serialize_state). Identify the distractors and arrange the remaining lines in correct order with correct indentation:

A. func _serialize_state() -> Dictionary:
B.     return {
C.         "version": SAVE_VERSION,
D.         "saved_at": int(Time.get_unix_time_from_system()),
E.         "resources": _resources.duplicate(),
F.         "lifetime_light": _lifetime_light,
G.         "tick_multiplier": tick_multiplier,
H.         "buildings": _buildings.duplicate(),
I.         "purchased_upgrades": _purchased_upgrades.keys(),
J.     }
K.         "income_rates": _income_rates.duplicate(),       # distractor candidate
L.         "purchased_upgrades": _purchased_upgrades.duplicate(),  # distractor candidate

Reveal

Distractors: K and L.

K saves _income_rates, which is derived state — recomputable from _buildings, tick_multiplier, and _resources["honor"] by _recalculate_income(). Saving it duplicates information that the load path's _recalculate_income call will overwrite anyway, and risks the cached value disagreeing with the recomputed one across a balance change. The chapter's design rule: save inputs, recompute outputs.

L is the bug the chapter's concept text warned against: _purchased_upgrades.duplicate() passes a Dictionary[String, UpgradeData] to JSON. JSON.stringify cannot serialize Resource references; the values silently become {}. The correct line is I: _purchased_upgrades.keys() returns an Array[String] of owned ids, which is the schema's declared shape and round-trips through JSON losslessly.

Expected order:

func _serialize_state() -> Dictionary:
    return {
        "version": SAVE_VERSION,
        "saved_at": int(Time.get_unix_time_from_system()),
        "resources": _resources.duplicate(),
        "lifetime_light": _lifetime_light,
        "tick_multiplier": tick_multiplier,
        "buildings": _buildings.duplicate(),
        "purchased_upgrades": _purchased_upgrades.keys(),
    }

The shape: dictionary literal, primary state only, version first (so a partial-read could still route), saved_at next (M7.3 needs it for offline calc), then the per-resource fields in stable order. Indentation matters — the dictionary entries are all at the same depth, inside the return { block.

Integration question

M7.1 saves tick_multiplier even though it is technically derivable from purchased_upgrades (replay every owned upgrade's effect on load to reconstruct the multiplier). What goes wrong if M7.2 instead chooses to not read tick_multiplier from the save and replays the upgrades on load? What goes right? When does each approach win?

Reveal

Replay wins when: - An upgrade's effect formula is rebalanced between game versions. Old saves loaded under new formulas pick up the new effect automatically (replay applies the current effect of each owned upgrade). Saving the cached value would freeze the old formula's contribution into the multiplier. - An upgrade is removed from the project. Replay sees the unknown id, can skip it cleanly. The cached multiplier would still include the removed upgrade's contribution but with no way to back it out. - Upgrade order matters and the order changes. Replay re-runs in current order; cached value reflects historical order.

Saving the cached value wins when: - Effects are non-deterministic or have side effects (e.g., RNG roll at purchase time). Replay would re-roll, producing a different multiplier than the player earned. - Effects are expensive (a database lookup, a complex calculation). Replay does the work every load; cached read is one assignment. - The schema must round-trip exactly (regulatory or competitive integrity reasons — preventing rebalances from changing existing player power).

For Blood Knight Grove, replay would win in principle (effects are deterministic and cheap, rebalances likely), but M7.1 saves the cached value as a defensive default — if a future bug breaks effect replay, the cached value still loads correctly. The choice is a hedge, not an optimization. M7.2 can revisit it.

Glossary

Glossary

user:// path
Godot's virtual path scheme for the per-user, per-project writable directory. Resolves to %APPDATA%/Godot/app_userdata/<project>/ on Windows, ~/Library/Application Support/Godot/app_userdata/<project>/ on macOS, ~/.local/share/godot/app_userdata/<project>/ on Linux. The only path scheme that is writable at runtime in exported builds (res:// is read-only). Survives uninstalls on desktop; not guaranteed on mobile.
FileAccess
Godot's file-handle class. FileAccess.open(path, mode) returns a FileAccess instance on success or null on failure (check FileAccess.get_open_error() for the error code). Common modes: FileAccess.READ (open for reading, fails if missing), FileAccess.WRITE (truncate or create, write-only), FileAccess.READ_WRITE, FileAccess.WRITE_READ. The handle auto-closes when it goes out of scope (RefCounted) — explicit close() is rarely needed but allowed.
JSON.stringify
Godot's JSON serializer. JSON.stringify(value, indent='', sort_keys=false, full_precision=false) takes any Variant tree of supported types (Dictionary, Array, String, int, float, bool, null) and returns a JSON string. Object/Resource/Callable references do not serialize; pass primitive Dictionaries instead. Pair with JSON.parse_string for round-trip.
save schema
The fixed Dictionary structure written to disk by save_game(). M7.1's schema includes: version (int, for migration), saved_at (unix timestamp, for offline-earnings calc in M7.3), resources (Dictionary of resource counts), lifetime_light (lifetime tracker for prestige), tick_multiplier (cached derived value, optional), buildings (Dictionary mapping id to count), purchased_upgrades (Array of upgrade ids). Honor multiplier and click-side multiplier are derived (M6.2) and not saved — recomputed from honor on load.
save triggers
The set of game events that call save_game(). M7.1 wires four: on prestige (pledge_crusade), on purchase (buildings + upgrades), on quit (NOTIFICATION_WM_CLOSE_REQUEST), and periodically (30s timer). Multiple triggers are belt-and-suspenders — any single trigger could fail (crash before timer fires, quit-handler missed in web build) but overlap minimizes save-loss surface.
### Ask Claude (side-channel) Try one of these in a Claude Code session if a piece did not land: - `Show me the difference between FileAccess.WRITE and FileAccess.READ_WRITE — when should I use each, and what happens if I use the wrong one?` - `What's the atomic-write pattern for save files? I want to know what M7.1 left out — write to .tmp, rename to final.` - `If I want to add a new field to the save schema in version 2, what's the migration story? Walk me through what M7.2 would need to read a v1 save into a v2 game.`