Save / Load — persisting progress
FileAccess, ConfigFile, ResourceSaver, JSON, encryption, user:// paths.
Godot has several built-in ways to save progress. Each fits its own task.
Paths and the user:// schema
In Godot there is a special path for all user data — user://. On different
platforms it points to the right place:
- Windows:
%APPDATA%\Godot\app_userdata\[name]\ - macOS:
~/Library/Application Support/Godot/app_userdata/[name]/ - Linux:
~/.local/share/godot/app_userdata/[name]/ - Android:
/data/data/com.example.app/files/ - iOS:
Documents/ - Web:
IndexedDBvia a virtual filesystem
The main rule: never write to res:// (it’s read-only after the build). All user data
goes into user://.
localStorage — for primitives (like Godot’s ConfigFile). IndexedDB — for large
structured data (like Godot’s FileAccess + JSON).
ConfigFile — for settings
ConfigFile saves in INI format (like Windows .ini files). Ideal for a player config:
volume, resolution, key bindings.
extends Node
const SETTINGS_PATH := "user://settings.cfg"
func save_settings(master_volume: float, fullscreen: bool, player_name: String) -> void:
var config := ConfigFile.new()
config.set_value("audio", "master_volume", master_volume)
config.set_value("video", "fullscreen", fullscreen)
config.set_value("player", "name", player_name)
config.save(SETTINGS_PATH)
func load_settings() -> Dictionary:
var config := ConfigFile.new()
var err := config.load(SETTINGS_PATH)
if err != OK:
return {} # the file does not exist yet
return {
"master_volume": config.get_value("audio", "master_volume", 1.0), # default 1.0
"fullscreen": config.get_value("video", "fullscreen", false),
"player_name": config.get_value("player", "name", "Player"),
}
On disk you get:
[audio]
master_volume=0.8
[video]
fullscreen=true
[player]
name="Konstantin"
This is readable, version-controllable, easy to edit by hand — ideal for settings where security doesn’t matter.
FileAccess — for arbitrary files
For structured saves (with arrays, dictionaries, custom data) — FileAccess + JSON:
extends Node
const SAVE_PATH := "user://save.json"
func save_game(state: Dictionary) -> Error:
var file := FileAccess.open(SAVE_PATH, FileAccess.WRITE)
if file == null:
return FileAccess.get_open_error()
file.store_string(JSON.stringify(state, "\t")) # \t — pretty-print indent
return OK
func load_game() -> Dictionary:
if not FileAccess.file_exists(SAVE_PATH):
return {}
var file := FileAccess.open(SAVE_PATH, FileAccess.READ)
if file == null:
push_error("Failed to open save file: %s" % FileAccess.get_open_error())
return {}
var content := file.get_as_text()
var parsed = JSON.parse_string(content)
return parsed if parsed != null else {}
Usage:
var state := {
"version": 1,
"player": {
"name": "Konstantin",
"level": 5,
"hp": 80,
"position": [10.5, 2.0, -3.2], # Vector3 is not directly JSON-serializable
},
"inventory": ["sword", "potion", "potion"],
"play_time_seconds": 3600.5,
"active_quests": ["q_main_01", "q_side_03"],
}
save_game(state)
# Later:
var loaded := load_game()
print(loaded.player.name)
JSON.stringify does not serialize Godot types (Vector3, Color, Transform3D). Convert
them to arrays:
var pos := player.global_position
state.player.position = [pos.x, pos.y, pos.z]
# On load:
player.global_position = Vector3(loaded.player.position[0],
loaded.player.position[1],
loaded.player.position[2]) ResourceSaver / ResourceLoader — natively for a Godot Resource
If your state can be represented as a Resource subclass, you can use the built-in serialization:
# save_data.gd
class_name SaveData extends Resource
@export var player_name: String = "Player"
@export var level: int = 1
@export var hp: int = 100
@export var position: Vector3 = Vector3.ZERO
@export var inventory: Array[String] = []
# save_system.gd
const SAVE_PATH := "user://save.tres"
func save_game(data: SaveData) -> void:
ResourceSaver.save(data, SAVE_PATH)
func load_game() -> SaveData:
if not ResourceLoader.exists(SAVE_PATH):
return SaveData.new()
return load(SAVE_PATH) as SaveData
Pros:
- Vector3, Color, and other Godot types are serialized automatically.
- The file is human-readable (
.tres). - It can be opened in the Godot Editor for debugging.
Cons:
- A hard binding to the structure — migrating between versions requires care.
- Zero security (the file is easily edited).
Binary format — .res
If you want a compact and fast format:
ResourceSaver.save(data, "user://save.res", ResourceSaver.FLAG_COMPRESS)
.res is binary (parses faster), plus FLAG_COMPRESS is zstd compression. This is tens of
times more compact than JSON for large saves.
Encryption
Simple XOR
const KEY := 0x5A
func encrypt_xor(text: String) -> PackedByteArray:
var bytes := text.to_utf8_buffer()
for i in bytes.size():
bytes[i] ^= KEY
return bytes
func decrypt_xor(bytes: PackedByteArray) -> String:
for i in bytes.size():
bytes[i] ^= KEY
return bytes.get_string_from_utf8()
AES via FileAccess (built into Godot)
Godot has a built-in FileAccess.open_encrypted_with_pass:
func save_encrypted(state: Dictionary, password: String) -> void:
var file := FileAccess.open_encrypted_with_pass(
"user://save.dat", FileAccess.WRITE, password)
file.store_string(JSON.stringify(state))
func load_encrypted(password: String) -> Dictionary:
if not FileAccess.file_exists("user://save.dat"):
return {}
var file := FileAccess.open_encrypted_with_pass(
"user://save.dat", FileAccess.READ, password)
if file == null:
push_warning("Wrong password or corrupted file")
return {}
var content := file.get_as_text()
return JSON.parse_string(content)
⚠️ As in Unity: a password in the code → reverse-engineering will find it. This is protection against casual cheaters, nothing more.
Versioning saves
const CURRENT_VERSION := 3
func load_game() -> Dictionary:
var data := _read_save()
if data.is_empty():
return _default_state()
var version: int = data.get("version", 1)
if version < CURRENT_VERSION:
data = _migrate(data, version)
return data
func _migrate(data: Dictionary, from_version: int) -> Dictionary:
if from_version == 1:
# v1 → v2: added inventory
data.inventory = []
from_version = 2
if from_version == 2:
# v2 → v3: renamed a field
data.player_level = data.get("level", 1)
data.erase("level")
from_version = 3
data.version = CURRENT_VERSION
return data
Version your saves from the very start. Adding a version field after release is painful.
Async saving via WorkerThreadPool
If the save is large and stalls the main thread:
extends Node
var _save_task_id: int = -1
func save_async(state: Dictionary) -> void:
var json := JSON.stringify(state)
_save_task_id = WorkerThreadPool.add_task(_write_to_disk.bind(json))
func _write_to_disk(json: String) -> void:
var file := FileAccess.open("user://save.json", FileAccess.WRITE)
file.store_string(json)
func _process(_delta: float) -> void:
if _save_task_id != -1 and WorkerThreadPool.is_task_completed(_save_task_id):
WorkerThreadPool.wait_for_task_completion(_save_task_id)
_save_task_id = -1
print("Save complete")
See the Threading chapter for details.
Auto-save pattern
extends Node
@export var interval_seconds: float = 60.0
@onready var _timer := Timer.new()
func _ready() -> void:
_timer.wait_time = interval_seconds
_timer.timeout.connect(_on_autosave)
add_child(_timer)
_timer.start()
func _on_autosave() -> void:
var state := GameState.capture_state()
SaveSystem.save_game(state)
# show toast "💾 Saved"
func _notification(what: int) -> void:
if what == NOTIFICATION_WM_CLOSE_REQUEST or what == NOTIFICATION_APPLICATION_PAUSED:
_on_autosave()
get_tree().quit()
NOTIFICATION_APPLICATION_PAUSED fires on mobile when minimizing — we are guaranteed
time to save.
Cloud Saves
The Godot core has no built-in Cloud Save. Solutions:
- Steam Cloud — via the GodotSteam module (community).
- Google Play Games Services — via an AssetLib plugin.
- Custom backend — you write an API → an HTTPRequest node talks to the server.
Pitfalls
res://is read-only in the build. Onlyuser://.Vector3and other Godot types are NOT JSON. Convert to arrays or use a Resource.- JSON.parse_string returns null on error. Always check.
- Test migration — added a field → check the old save.
- WebGL — IndexedDB limits. The browser may limit your storage (~50MB+). Split large saves into pieces.