Skip to content
Blog

Build a Godot Save System Step by Step With an AI Agent

7 min read
Robot knight standing between two game level portals with a glowing save icon overhead

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, a Resource that defines what gets saved
  • res://save/save_system.gd, an autoload that reads and writes the file
  • res://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.

  1. Create a folder named save at the root of your project.
  2. Create level_1.tscn and level_2.tscn. Each scene can be a Node2D with a CharacterBody2D named Player, a Label for coins, and two Button nodes named SaveButton and LoadButton.
  3. In Project Settings, pick Input Map and confirm you have ui_left, ui_right, ui_up, and ui_down for movement. The default project already includes them.
  4. 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 save

Check 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:

  1. Delete user://savegame.json by calling SaveSystem.delete_save once, then run level_1.tscn. The game should start with zero coins and no errors.
  2. Move the player, collect two coins, and press Save. Confirm the file appears in the user data folder.
  3. Change to level_2.tscn with the editor or with change_scene_to_file, then press Load. The game should return to level 1 at the saved position.
  4. Close the game completely, run it again, and confirm _ready restores coins and time without pressing anything.
  5. 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.

ApproachBest forTradeoff
FileAccess plus JSON dictionaryPlayer progress, positions, inventoriesYou handle parsing and defaults
ConfigFile with sections and keysSettings, volume, key binds, graphicsLess 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 SaveSystem is registered as an autoload and the path starts with user://, not res://.
  • 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_position in 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.

Was this helpful?

Comments