What you will build
In this godot save load tutorial you will build a small but complete godot save system that survives scene changes. The player position, coin count, current level path, and play time are stored as JSON in user://savegame.json using gdscript FileAccess, then loaded back when the game starts or when the player presses a Load button.
You will not write all the boilerplate by hand. You will ask an AI agent to scaffold each piece, then you will review and run it. This follows the same idea as asking it to unblock you instead of asking for a whole game at once. One prompt for the data resource, one prompt for the save logic, one test pass to prove the round trip works.
By the end you will have three working files:
res://save/save_data.gd, aResourcethat defines what gets savedres://save/save_system.gd, an autoload that reads and writes the fileres://main.gd, a demo that saves on demand and restores after a scene change
Before you prompt: set up two test scenes
Agents do better work when the project already has places to save and load. Create a minimal project first so the round trip test is meaningful.
- Create a folder named
saveat the root of your project. - Create
level_1.tscnandlevel_2.tscn. Each scene can be aNode2Dwith aCharacterBody2Dnamed Player, aLabelfor coins, and twoButtonnodes named SaveButton and LoadButton. - In Project Settings, pick Input Map and confirm you have
ui_left,ui_right,ui_up, andui_downfor movement. The default project already includes them. - Leave saving for later. Do not create the save scripts yourself, you will prompt for them next.
If you use an agent with file tools, such as Godot MCP Studio, keep the same workflow described here. Ask for one file at a time and review the created script before you run it.
Step 1: Have the agent scaffold the SaveData resource
Start with data, not file code. A Resource gives you typed fields in the editor and one place to change the save format later.
Paste a narrow prompt like this:
Create res://save/save_data.gd. It should extend Resource, use class_name SaveData, and export player_position as Vector2, coins as int, current_level as String, and play_time_seconds as float. Include to_dict and from_dict helpers that convert between SaveData and Dictionary with safe defaults.
Expect back a file close to this. Save it as res://save/save_data.gd if the agent has not already created it:
extends Resource
class_name SaveData
@export var player_position: Vector2 = Vector2.ZERO
@export var coins: int = 0
@export var current_level: String = "res://level_1.tscn"
@export var play_time_seconds: float = 0.0
func to_dict() -> Dictionary:
return {
"player_position": [player_position.x, player_position.y],
"coins": coins,
"current_level": current_level,
"play_time_seconds": play_time_seconds
}
static func from_dict(data: Dictionary) -> SaveData:
var save := SaveData.new()
var pos: Array = data.get("player_position", [0.0, 0.0])
save.player_position = Vector2(float(pos[0]), float(pos[1]))
save.coins = int(data.get("coins", 0))
save.current_level = String(data.get("current_level", "res://level_1.tscn"))
save.play_time_seconds = float(data.get("play_time_seconds", 0.0))
return saveCheck three things in review. The script should extend Resource, every field should have a default, and from_dict should never crash on missing keys. If the agent used nested resources or custom classes inside the save, ask it to flatten them to arrays, ints, floats, strings, and bools. JSON stays simple that way.
Step 2: Have the agent write the FileAccess load logic
Now ask for the autoload that owns the file. Keep the prompt specific about path, format, and error cases.
Create res://save/save_system.gd as an autoload named SaveSystem. Use FileAccess with user://savegame.json and JSON.stringify. Implement save_game(data: SaveData), load_game() returning SaveData, has_save(), and delete_save(). On missing or corrupt files, log an error and return default SaveData instead of null.
The result should look like this. Add it to Autoload in Project Settings as SaveSystem:
extends Node
const SAVE_PATH := "user://savegame.json"
func save_game(data: SaveData) -> Error:
var file := FileAccess.open(SAVE_PATH, FileAccess.WRITE)
if file == null:
push_error("Save failed: could not open %s: %s" % [SAVE_PATH, FileAccess.get_open_error()])
return FileAccess.get_open_error()
file.store_string(JSON.stringify(data.to_dict(), " "))
file.close()
return OK
func load_game() -> SaveData:
if not has_save():
return SaveData.new()
var file := FileAccess.open(SAVE_PATH, FileAccess.READ)
if file == null:
push_error("Load failed: could not open %s" % SAVE_PATH)
return SaveData.new()
var parsed: Variant = JSON.parse_string(file.get_as_text())
file.close()
if parsed is Dictionary:
return SaveData.from_dict(parsed)
push_error("Load failed: save file is not valid JSON")
return SaveData.new()
func has_save() -> bool:
return FileAccess.file_exists(SAVE_PATH)
func delete_save() -> void:
if has_save():
var err := DirAccess.remove_absolute(SAVE_PATH)
if err != OK:
push_error("Could not delete save file: %s" % err)This separation matters. SaveData can change without touching file code, and SaveSystem can change format without touching gameplay. It also works best when the agent has real project context, which is why tool access changes AI game development matters for this kind of scaffolding and testing loop.
Step 3: Connect save and load to gameplay
Create res://main.gd and attach it to the root Node2D of both test levels. The script moves the player, tracks coins and time, and calls the autoload from button signals.
extends Node2D
@onready var player: CharacterBody2D = $Player
@onready var coins_label: Label = $UI/CoinsLabel
var coins: int = 0
var play_time_seconds: float = 0.0
const MOVE_SPEED := 220.0
func _ready() -> void:
var data := SaveSystem.load_game()
coins = data.coins
play_time_seconds = data.play_time_seconds
if data.current_level == scene_file_path or data.current_level == "":
player.global_position = data.player_position
_update_label()
$UI/SaveButton.pressed.connect(_on_save_pressed)
$UI/LoadButton.pressed.connect(_on_load_pressed)
func _process(delta: float) -> void:
play_time_seconds += delta
var dir := Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")
player.velocity = dir * MOVE_SPEED
player.move_and_slide()
func _update_label() -> void:
coins_label.text = "Coins: %d" % coins
func add_coin() -> void:
coins += 1
_update_label()
func _on_save_pressed() -> void:
var data := SaveData.new()
data.player_position = player.global_position
data.coins = coins
data.current_level = scene_file_path
data.play_time_seconds = play_time_seconds
var err := SaveSystem.save_game(data)
if err == OK:
print("Saved to ", SaveSystem.SAVE_PATH)
func _on_load_pressed() -> void:
var data := SaveSystem.load_game()
if data.current_level != scene_file_path and data.current_level != "":
get_tree().change_scene_to_file(data.current_level)
else:
player.global_position = data.player_position
coins = data.coins
play_time_seconds = data.play_time_seconds
_update_label()Ask the agent to wire anything you missed: connect the pressed signals if you prefer the editor, add a coin pickup that calls add_coin, or store the level path before calling change_scene_to_file.
Step 4: Test the round trip across scenes
A save system is only done when a full loop works. Ask the agent to walk through this list, or do it yourself in under two minutes:
- Delete
user://savegame.jsonby callingSaveSystem.delete_saveonce, then runlevel_1.tscn. The game should start with zero coins and no errors. - Move the player, collect two coins, and press Save. Confirm the file appears in the user data folder.
- Change to
level_2.tscnwith the editor or withchange_scene_to_file, then press Load. The game should return to level 1 at the saved position. - Close the game completely, run it again, and confirm
_readyrestores coins and time without pressing anything. - Corrupt the test on purpose. Open the JSON file, delete a bracket, run again. The game should log an error and start with defaults instead of crashing.
If step 3 fails, the usual cause is saving the wrong scene path or restoring position before the new scene is ready. Save scene_file_path at save time and apply position in _ready of the loaded scene, not in the button callback that requested the change.
FileAccess versus ConfigFile: which should you keep?
The example above uses JSON because it is easy to inspect and version. A godot ConfigFile save is also valid and needs less parsing code for flat settings.
| Approach | Best for | Tradeoff |
|---|---|---|
| FileAccess plus JSON dictionary | Player progress, positions, inventories | You handle parsing and defaults |
| ConfigFile with sections and keys | Settings, volume, key binds, graphics | Less natural for arrays and nested data |
Keep both in larger projects. Use ConfigFile for options that players edit by hand, and keep the JSON save file for run state that must round trip exactly.
Troubleshooting checklist
- Nothing saves: confirm
SaveSystemis registered as an autoload and the path starts withuser://, notres://. - Load always returns defaults: print
SaveSystem.has_save()and check you are reading the same filename you wrote. - Position resets after scene change: you restored too early. Apply
player_positionin the new scene_ready, after the player node exists. - JSON parse error: open the file and validate it. Extra commas and NaN values are the usual suspects.
Once this loop passes, extend the same pattern. Add health, unlocked doors, or quest flags to SaveData, update to_dict and from_dict, and rerun the five step test. Small prompts and a repeatable test keep the system reliable as it grows.