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://usingFileAccessandJSON - 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
- Create
res://systems/save_game.gd(adjust folder to match Lesson 2). - Project > Project Settings > Autoload add
SaveGamepointing at that script. - 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:
- Find the
CharacterBody2Dplayer and readglobal_position,current_hp, or your damage receiver values. - Ask your inventory autoload (Lesson 8) for a simple
PackedStringArrayorArrayof item ids. - Ask your quest runner (Lesson 7) for completed step ids or a duplicate-safe flag map.
- Record
get_tree().current_scene.scene_file_pathif 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:
get_tree().change_scene_to_file(save["current_scene_path"])- After the new scene is ready, apply payload (HP, position) in a deferred call or via a signal your
Playerexposes (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
- Add an
Area2Dchild sceneCheckpoint.tscnwithCollisionShape2D(layer/mask matches player). - Export
checkpoint_id: StringName(for examplevillage_gate). - Add a child
Marker2Dfor the exact respawn position. - On
body_enteredifbody.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:
- Write to
user://slot_0.tmp - Delete old
slot_0.jsonif present - 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
- Beat your slice, touch a checkpoint, quit to OS, reopen, press Continue, confirm HP and position match.
- Delete
user://slot_0.jsonmanually 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
Area2Dsaves, 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).