~2 min read

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: IndexedDB via a virtual filesystem

The main rule: never write to res:// (it’s read-only after the build). All user data goes into user://.

Web

localStorage — for primitives (like Godot’s ConfigFile). IndexedDB — for large structured data (like Godot’s FileAccess + JSON).

Unity

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)
Vector3 → Array manually

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

  1. res:// is read-only in the build. Only user://.
  2. Vector3 and other Godot types are NOT JSON. Convert to arrays or use a Resource.
  3. JSON.parse_string returns null on error. Always check.
  4. Test migration — added a field → check the old save.
  5. WebGL — IndexedDB limits. The browser may limit your storage (~50MB+). Split large saves into pieces.