Lesson Goal

Players expect Continue to mean something. Checkpoints should not be a second, shadow inventory written with copy-paste JSON keys.

By the end of this lesson, you will:

  • write a versioned save file under user:// using FileAccess and JSON
  • centralize save/load in one autoload so UI, checkpoints, and quit flow call the same code
  • spawn the player at a checkpoint position after load
  • reflect save presence in your main menu from Lesson 10: UI and Onboarding Flow

Design the save contract first

Decide the smallest dictionary that still reproduces your slice:

Field Why it exists
version Lets you migrate saves when you rename keys
timestamp_unix Debug and “last played” UI
current_scene_path res:// path to the level PackedScene
spawn_marker_id String id of the Marker2D or checkpoint name
player hp, pos_x, pos_y (JSON has no Vector2)
inventory Array of item ids (matches your Lesson 8 model)
quests Flat flags ({"intro_defeat_slimes": true}) aligned with Lesson 7

Pro tip: Never serialize live node references. Serialize ids and let factories rebuild objects on load.


Step 1: Add a SaveGame autoload

  1. Create res://systems/save_game.gd (adjust folder to match Lesson 2).
  2. Project > Project Settings > Autoload add SaveGame pointing at that script.
  3. Use one constant path for the slot you are building (user://slot_0.json).

Skeleton:

extends Node

const SAVE_PATH := "user://slot_0.json"
const SAVE_VERSION := 1

func has_save() -> bool:
    return FileAccess.file_exists(SAVE_PATH)

func save_game(state: Dictionary) -> bool:
    state["version"] = SAVE_VERSION
    state["timestamp_unix"] = Time.get_unix_time_from_system()
    var json_text := JSON.stringify(state)
    var file := FileAccess.open(SAVE_PATH, FileAccess.WRITE)
    if file == null:
        push_error("Cannot write save: " + str(FileAccess.get_open_error()))
        return false
    file.store_string(json_text)
    file.close()
    return true

func load_game() -> Dictionary:
    if not has_save():
        return {}
    var file := FileAccess.open(SAVE_PATH, FileAccess.READ)
    if file == null:
        return {}
    var json_text := file.get_as_text()
    file.close()
    var parsed: Variant = JSON.parse_string(json_text)
    if typeof(parsed) != TYPE_DICTIONARY:
        push_warning("Save corrupt or empty")
        return {}
    var data: Dictionary = parsed
    if int(data.get("version", 0)) > SAVE_VERSION:
        push_warning("Save from newer build")
    return data

Common mistake: Calling get_as_text() twice. Read once, then parse.


Step 2: Build the snapshot from the running level

Add a method on SaveGame (or a small helper your Game root calls) that gathers live state:

  1. Find the CharacterBody2D player and read global_position, current_hp, or your damage receiver values.
  2. Ask your inventory autoload (Lesson 8) for a simple PackedStringArray or Array of item ids.
  3. Ask your quest runner (Lesson 7) for completed step ids or a duplicate-safe flag map.
  4. Record get_tree().current_scene.scene_file_path if you launched from a saved PackedScene path.

Store positions as floats:

"player": {
    "hp": player.hp,
    "pos_x": player.global_position.x,
    "pos_y": player.global_position.y
}

Step 3: Reload without double-running _ready traps

Loading usually means:

  1. get_tree().change_scene_to_file(save["current_scene_path"])
  2. After the new scene is ready, apply payload (HP, position) in a deferred call or via a signal your Player exposes (apply_save_payload(dict)).

Pro tip: If your level spawns enemies in _ready, gate spawning on a bool skip_initial_spawn_from_save that SaveGame sets for one frame.


Step 4: Checkpoint nodes

  1. Add an Area2D child scene Checkpoint.tscn with CollisionShape2D (layer/mask matches player).
  2. Export checkpoint_id: StringName (for example village_gate).
  3. Add a child Marker2D for the exact respawn position.
  4. On body_entered if body.is_in_group("player"):
func _on_body_entered(body: Node2D) -> void:
    SaveGame.save_game(SaveGame.build_snapshot(checkpoint_id))

Where build_snapshot wraps your gather logic and injects spawn_marker_id = checkpoint_id (or the marker’s path string).

Common mistake: Saving every physics frame inside the checkpoint overlap. Save once on enter, or debounce with a has_saved flag on the checkpoint.


Step 5: Wire Continue in the main menu

In your menu scene from Lesson 10:

func _ready() -> void:
    $VBox/Continue.disabled = not SaveGame.has_save()

func _on_continue_pressed() -> void:
    var data := SaveGame.load_game()
    if data.is_empty():
        return
    await get_tree().process_frame
    get_tree().change_scene_to_file(data["current_scene_path"])
    await get_tree().process_frame
    SaveGame.apply_to_current_scene(data)

apply_to_current_scene is the place you fetch Player by group and restore HP and global_position.


Step 6: Atomic writes (optional but professional)

On desktop, a crash mid-write can truncate JSON. A simple pattern:

  1. Write to user://slot_0.tmp
  2. Delete old slot_0.json if present
  3. Rename tmp to final (Godot has DirAccess.rename)

For this course slice, honest WRITE is acceptable until you ship publicly.


Troubleshooting

Continue loads the map but player spawns at default
You never ran apply_to_current_scene, or your Marker2D name does not match spawn_marker_id.

Inventory duplicates after load
You appended defaults in _ready and restored saves. Gate initialization with if SaveGame.has_save(): return.

Web export path differences
user:// maps to IndexedDB; keep saves small. Start HTML5 smoke tests as soon as you target the web; export presets and packaging are covered later in this course when that lesson is published.


FAQ

Should I use Resources instead of JSON?
ResourceSaver is great for editor tooling. For cross-version patches and debugging, JSON in user:// is easier to inspect.

How many slots should I ship in a jam?
One slot plus “New Game clears file after confirm” keeps UX honest.

Where does encryption go?
Not in this lesson. Ship plaintext for learning; add OS-specific secret storage only when you have an online economy.


Mini challenge

  1. Beat your slice, touch a checkpoint, quit to OS, reopen, press Continue, confirm HP and position match.
  2. Delete user://slot_0.json manually while the game is closed, reopen, confirm Continue greys out.

Summary

  • Use one autoload, one schema, one file path for your first slot.
  • Version every file, store positions as scalars, and deserialize into systems you already built in Lessons 7 and 8.
  • Checkpoints are Area2D saves, not mysterious duplicate state machines.

Next you will stress-test performance and memory before export builds. Continue to Lesson 12: Performance and Memory Checks.

For deeper persistence patterns, read ScriptableObjects vs JSON vs SQLite in Unity for parallels (concepts translate even when the API differs).