Settings only
- Use
- ConfigFile or small JSON
- Best for
- Volume, fullscreen, language, accessibility options, input preferences.
- Watch out
- Do not mix settings with game progress unless the game is very small.
Save & Load Guide. Separate durable save data from live nodes, scene paths, and temporary runtime state.
Choose a save approach, write files through FileAccess, serialize safe JSON,
version your data, and avoid the common mistakes that corrupt progress late in development.
The right save system depends on how much state you need to restore. Start with the smallest approach that can survive a real export and a failed load.
Use FileAccess plus JSON for what Unity projects often solve with
PlayerPrefs, JsonUtility, or custom file IO. Godot does not force a SaveGame
class; you own the format.
For most solo 2D and small 3D projects, use one JSON save file first. Add slots, autosaves, and encryption only after the basic load path is reliable.
FileAccess.open() returns a file object or null. Check it,
write to user://, and close the file when you are done.
const SAVE_PATH := "user://save.txt"
func write_save_text(text: String) -> bool:
var file := FileAccess.open(SAVE_PATH, FileAccess.WRITE)
if file == null:
push_error("Could not open save file. Error: %s" % FileAccess.get_open_error())
return false
file.store_string(text)
file.close()
return trueconst SAVE_PATH := "user://save.txt"
func read_save_text() -> String:
if not FileAccess.file_exists(SAVE_PATH):
return ""
var file := FileAccess.open(SAVE_PATH, FileAccess.READ)
if file == null:
push_error("Could not read save file. Error: %s" % FileAccess.get_open_error())
return ""
var text := file.get_as_text()
file.close()
return textstore_string() writes full text or JSON.get_as_text() reads a full text file.store_line() is useful for logs or line-based formats.store_var() can write Variants quickly.get_var() reads matching Variant data back.Godot 3 used File.new(). Godot 4 uses static FileAccess.open(). Old snippets from tutorials need this update.
JSON is a good default because you can open the file, inspect the shape, and recover from bad data. Convert engine types into plain values before writing.
const SAVE_PATH := "user://savegame.json"
const SAVE_VERSION := 1
func vector2_to_dict(value: Vector2) -> Dictionary:
return {
"x": value.x,
"y": value.y,
}
func save_game(player: Node2D) -> bool:
var data := {
"save_version": SAVE_VERSION,
"saved_at": Time.get_datetime_string_from_system(),
"player": {
"position": vector2_to_dict(player.position),
"health": player.health,
"coins": player.coins,
"inventory": player.inventory,
},
"world": {
"current_level": "forest_01",
"opened_chests": ["chest_01", "chest_07"],
},
}
var file := FileAccess.open(SAVE_PATH, FileAccess.WRITE)
if file == null:
push_error("Save failed. Error: %s" % FileAccess.get_open_error())
return false
file.store_string(JSON.stringify(data, "\t"))
file.close()
return trueconst SAVE_PATH := "user://savegame.json"
func dict_to_vector2(data: Dictionary, fallback := Vector2.ZERO) -> Vector2:
if not data.has("x") or not data.has("y"):
return fallback
return Vector2(float(data.x), float(data.y))
func load_game(player: Node2D) -> bool:
if not FileAccess.file_exists(SAVE_PATH):
return false
var file := FileAccess.open(SAVE_PATH, FileAccess.READ)
if file == null:
push_error("Load failed. Error: %s" % FileAccess.get_open_error())
return false
var json_text := file.get_as_text()
file.close()
var json := JSON.new()
var parse_error := json.parse(json_text)
if parse_error != OK:
push_error("Save JSON parse error: %s" % json.get_error_message())
return false
var data: Dictionary = json.data
var player_data: Dictionary = data.get("player", {})
player.position = dict_to_vector2(player_data.get("position", {}))
player.health = int(player_data.get("health", 100))
player.coins = int(player_data.get("coins", 0))
player.inventory = player_data.get("inventory", [])
return true{
"save_version": 1,
"saved_at": "2026-01-23T10:30:00",
"player": {
"position": { "x": 150.5, "y": 200.0 },
"health": 85,
"coins": 42,
"inventory": ["sword", "potion", "key"]
},
"world": {
"current_level": "forest_01",
"opened_chests": ["chest_01", "chest_07"]
}
}Save plain data, not live objects. Convert Vector2, Vector3, Color, resources, and node references into dictionaries, IDs, file paths, or names you can validate.
A SaveManager is useful once multiple scenes need to request saves, loads, or slot metadata. Keep it focused on file orchestration and data validation.
save_manager.gdPut file paths, versioning, and signals in one place.
Go to Project > Project Settings > Autoload and name it SaveManager.
Use signals or groups so the manager does not become a giant object-specific script.
extends Node
const SAVE_PATH := "user://savegame.json"
const SAVE_VERSION := 1
signal save_requested(data: Dictionary)
signal load_finished(data: Dictionary)
func save_game() -> bool:
var data := {
"save_version": SAVE_VERSION,
"saved_at": Time.get_datetime_string_from_system(),
"nodes": {},
}
save_requested.emit(data)
return _write_json(data)
func load_game() -> bool:
var data := _read_json()
if data.is_empty():
return false
data = _migrate_if_needed(data)
load_finished.emit(data)
return true
func _write_json(data: Dictionary) -> bool:
var file := FileAccess.open(SAVE_PATH, FileAccess.WRITE)
if file == null:
push_error("Could not write save file.")
return false
file.store_string(JSON.stringify(data, "\t"))
file.close()
return true
func _read_json() -> Dictionary:
if not FileAccess.file_exists(SAVE_PATH):
return {}
var file := FileAccess.open(SAVE_PATH, FileAccess.READ)
if file == null:
return {}
var json := JSON.new()
var error := json.parse(file.get_as_text())
file.close()
if error != OK or typeof(json.data) != TYPE_DICTIONARY:
return {}
return json.data
func _migrate_if_needed(data: Dictionary) -> Dictionary:
var version := int(data.get("save_version", 1))
if version < SAVE_VERSION:
data["save_version"] = SAVE_VERSION
return dataextends CharacterBody2D
@export var save_id := "player"
var health := 100
var inventory: Array[String] = []
func _ready() -> void:
SaveManager.save_requested.connect(_on_save_requested)
SaveManager.load_finished.connect(_on_load_finished)
func _on_save_requested(data: Dictionary) -> void:
data.nodes[save_id] = {
"position": { "x": position.x, "y": position.y },
"health": health,
"inventory": inventory,
}
func _on_load_finished(data: Dictionary) -> void:
var saved := data.get("nodes", {}).get(save_id, {})
if saved.is_empty():
return
var saved_position: Dictionary = saved.get("position", {})
position = Vector2(saved_position.get("x", position.x), saved_position.get("y", position.y))
health = int(saved.get("health", health))
inventory = saved.get("inventory", inventory)Production save systems are less about fancy formats and more about predictable recovery: versioning, backups, validation, and safe writes.
const SAVE_DIR := "user://saves/"
const MAX_SLOTS := 3
func get_save_path(slot: int) -> String:
return SAVE_DIR + "slot_%s.json" % slot
func save_to_slot(slot: int, data: Dictionary) -> bool:
if slot < 0 or slot >= MAX_SLOTS:
return false
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(SAVE_DIR))
data["slot"] = slot
data["saved_at"] = Time.get_datetime_string_from_system()
var file := FileAccess.open(get_save_path(slot), FileAccess.WRITE)
if file == null:
return false
file.store_string(JSON.stringify(data, "\t"))
file.close()
return true
func get_save_slots() -> Array[Dictionary]:
var slots: Array[Dictionary] = []
for slot in range(MAX_SLOTS):
var path := get_save_path(slot)
if not FileAccess.file_exists(path):
continue
var file := FileAccess.open(path, FileAccess.READ)
var parsed = JSON.parse_string(file.get_as_text())
file.close()
if typeof(parsed) == TYPE_DICTIONARY:
slots.append(parsed)
return slotsconst SAVE_VERSION := 3
func migrate_save(data: Dictionary) -> Dictionary:
var version := int(data.get("save_version", 1))
if version < 2:
data = migrate_v1_to_v2(data)
version = 2
if version < 3:
data = migrate_v2_to_v3(data)
version = 3
data["save_version"] = SAVE_VERSION
return data
func migrate_v1_to_v2(data: Dictionary) -> Dictionary:
var player: Dictionary = data.get("player", {})
if player.has("hp"):
player["health"] = player["hp"]
player.erase("hp")
data["player"] = player
return data
func migrate_v2_to_v3(data: Dictionary) -> Dictionary:
if not data.has("world"):
data["world"] = {}
data.world["opened_chests"] = data.world.get("opened_chests", [])
return dataLocal encryption can discourage casual editing, but it is not trustworthy for competitive or online games. Validate important state on a server when stakes matter.
Before jumping to the next page, turn the idea into one tiny scene or script. That is where the Godot habit sticks.
PlayerPrefs is not a full save architecture.
SaveGame-style data still needs stable IDs and migration habits.
Use user://, plain dictionaries or Resources, and validate loaded data.
Add a save_version field before the save file grows.