Saving game state between scenes in Godot 4
A scene change should not reset the player's progress. Keep save data in one persistent contract, restore it after the destination scene is ready, and avoid the loading-order bug that makes a save look empty.
Do not put the player's progress in the scene you are about to unload. When a Godot 4 game changes from the village to the dungeon, that village scene and every node below it can disappear immediately. If the inventory, quest flags, and player position live only on those nodes, the next scene has nothing to restore and the reset looks like a save bug. The fix is to separate the data from the scene tree: collect state before the change, keep it behind a stable save contract, then restore it after the destination scene has entered the tree.
This is the useful boundary in Save/Load Lite. The addon does not own your scene transitions, and it should not. It gives saveable nodes a get_save_id(), save_state(), and load_state(data) contract, then stores their dictionaries in one envelope. Your scene manager decides when to call the save and when the new scene is ready. That division is what keeps a scene change from turning into a race between loading data and constructing the nodes that need it.
Keep the save contract outside the scene
Start with one node that survives every scene change. An autoload is the usual choice. It can own the current save dictionary in memory and write it to disk when you need a durable checkpoint. The important part is not the name of the autoload. It is that the object holding the data is not a child of the scene being replaced.
# GameState.gd, registered as an autoload
extends Node
var current := {
"version": 1,
"nodes": {},
}
func remember(id: String, data: Dictionary) -> void:
current["nodes"][id] = data.duplicate(true)
func recall(id: String) -> Dictionary:
return current["nodes"].get(id, {}).duplicate(true)The deep copy matters. A scene node should hand the state manager a snapshot, not a reference to a dictionary it can continue mutating after the transition starts. Likewise, recall returns a copy so a loader cannot change the central state by accident while it is still validating its input. Keep the contract deliberately plain. Dictionaries and stable ids are easier to migrate than scene paths or live node references.
Save before you change scenes
The scene change is the boundary, so put the handoff immediately before it. Let each current scene node publish its state, then call the change. Do not wait for the old scene's _exit_tree to do this. At that point the order of teardown is already part of your save system, and a node that has lost a child can produce a partial snapshot.
func go_to_dungeon() -> void:
_capture_current_scene()
get_tree().change_scene_to_file("res://scenes/dungeon.tscn")
func _capture_current_scene() -> void:
for node in get_tree().get_nodes_in_group("save_load_contract_lite"):
if not node.has_method("save_state") or not node.has_method("get_save_id"):
continue
var id := String(node.get_save_id())
GameState.remember(id, node.save_state())The group is more reliable than a hardcoded list of scene paths. A village inventory and a dungeon inventory can both implement the same contract without the scene manager knowing what either one contains. A global player record should have one stable id, such as player_state, rather than a path like Village/Player. Paths describe where a node is today. Save ids describe what the data means, and that meaning should survive a scene reorganization.
Restore after the destination enters the tree
Give the scene manager one place to apply saved state after a successful scene change. The simplest version is an autoload that listens for the tree's scene-changed signal, then waits one idle frame before collecting the contract nodes. The extra frame is not magic. It gives the destination scene's _ready callbacks a chance to finish creating its saveable children.
# SceneFlow.gd, another autoload
extends Node
func _ready() -> void:
get_tree().scene_changed.connect(_on_scene_changed)
func _on_scene_changed() -> void:
await get_tree().process_frame
for node in get_tree().get_nodes_in_group("save_load_contract_lite"):
if not node.has_method("load_state") or not node.has_method("get_save_id"):
continue
var id := String(node.get_save_id())
var data := GameState.recall(id)
if not data.is_empty():
node.load_state(data)There is one policy choice hidden in this small loop. If a node has no saved data, should its defaults remain, or should the load fail? For a first visit to a new scene, keeping defaults is usually right. For a checkpoint reload, you may want to show a clear error if a required id is missing. Make that decision at the contract boundary instead of letting every node invent a different fallback.
Persist the same envelope to disk
Memory gets you across a scene transition. It does not get the player across a restart. Once the in-memory dictionary is correct, the disk step is ordinary: write the envelope with a version and the node states, then read it before the first gameplay scene is loaded. Save/Load Lite already uses this shape, so the scene transition code can stay focused on timing rather than inventing a second format.
func save_checkpoint(path := "user://save.json") -> bool:
var f := FileAccess.open(path, FileAccess.WRITE)
if f == null:
return false
f.store_string(JSON.stringify(GameState.current, " "))
f.close()
return true
func load_checkpoint(path := "user://save.json") -> bool:
if not FileAccess.file_exists(path):
return false
var f := FileAccess.open(path, FileAccess.READ)
var parsed = JSON.parse_string(f.get_as_text())
f.close()
if not (parsed is Dictionary) or int(parsed.get("version", 0)) != 1:
return false
GameState.current = parsed
return trueFor a shipped game, use the atomic temp-file pattern rather than overwriting the only save in place. A crash during store_string can leave a truncated file, and a scene transition does not make that risk smaller. Write beside the real file, flush it, and rename it over the target only after the complete JSON is there. Keep one backup if losing the last checkpoint would be costly.
What should not be global
A persistent state manager is not a reason to make every gameplay node an autoload. Keep transient presentation in the scene: animation players, camera shake, open panels, and enemy instances should be recreated. Persist facts that represent player progress, such as inventory ids and counts, quest flags, unlocked recipes, and the player's intended position. Rebuilding a scene from those facts is safer than trying to keep half of the old scene alive across a transition.
That is the complete flow: capture before change_scene_to_file, store plain state behind stable ids, wait until the destination is in the tree, then dispatch each snapshot through load_state. The free Save/Load Lite addon supplies the contract and the versioned envelope. Your scene manager supplies the timing, which is exactly where that responsibility belongs. Once those two concerns are separate, scene changes stop erasing progress and start being ordinary data handoffs.