All Scrolls

How to Build a Save System in Godot 4

Coding QuestsFebruary 12, 2026
godottutorialsave-system

How to Build a Save System in Godot 4

Every game needs to save and load data. Whether it's player progress, settings, or a full open-world state, you need a reliable system to persist data to disk. Godot 4 makes this straightforward — but there are a few decisions to make early that will save you from rewriting everything later.

This guide covers the core concepts and patterns. By the end, you'll understand how Godot handles file I/O, when to use each approach, and how to architect a save system that scales.

The Two Approaches

There are two fundamental ways to save data in Godot:

  1. ConfigFile — Key-value storage. Good for settings and simple data.
  2. FileAccess + JSON/binary — Full control. Good for complex game state.

Most games need both.

Approach 1: ConfigFile for Settings

ConfigFile is Godot's built-in INI-style storage. It handles sections, keys, and typed values automatically.

# Save settings
func save_settings() -> void:
    var config := ConfigFile.new()
    config.set_value("audio", "master_volume", 0.8)
    config.set_value("audio", "music_volume", 0.6)
    config.set_value("video", "fullscreen", true)
    config.set_value("video", "vsync", true)
    config.save("user://settings.cfg")

# Load settings
func load_settings() -> void:
    var config := ConfigFile.new()
    if config.load("user://settings.cfg") != OK:
        return  # No settings file yet, use defaults

    master_volume = config.get_value("audio", "master_volume", 0.8)
    music_volume = config.get_value("audio", "music_volume", 0.6)
    fullscreen = config.get_value("video", "fullscreen", false)

The third argument in get_value is the default — used when the key doesn't exist. This makes your code forward-compatible: you can add new settings without breaking old save files.

Important: Always use user:// for save paths, not res://. The user:// directory is writable at runtime and maps to the user's app data folder. res:// is your project directory and is read-only in exported builds.

Approach 2: JSON for Game State

For more complex data — inventories, quest progress, character stats — you need structured serialization. JSON is the simplest option.

func save_game() -> void:
    var data := {
        "player": {
            "position": {"x": player.position.x, "y": player.position.y, "z": player.position.z},
            "health": player.health,
            "level": player.level,
        },
        "inventory": player.inventory.serialize(),
        "quests": quest_manager.serialize(),
        "timestamp": Time.get_datetime_string_from_system(),
    }

    var file := FileAccess.open("user://savegame.json", FileAccess.WRITE)
    file.store_string(JSON.stringify(data, "\t"))
func load_game() -> void:
    if not FileAccess.file_exists("user://savegame.json"):
        return

    var file := FileAccess.open("user://savegame.json", FileAccess.READ)
    var json := JSON.new()
    if json.parse(file.get_as_text()) != OK:
        push_error("Failed to parse save file")
        return

    var data: Dictionary = json.data
    player.position = Vector3(
        data.player.position.x,
        data.player.position.y,
        data.player.position.z
    )
    player.health = data.player.health
    player.level = data.player.level
    player.inventory.deserialize(data.inventory)
    quest_manager.deserialize(data.quests)

The Serialize/Deserialize Pattern

The key to a clean save system is giving each object responsibility for its own serialization. Don't let one giant save_game() function reach into every object's internals.

# InventoryComponent.gd
func serialize() -> Array:
    var items := []
    for item in inventory_items:
        items.append({
            "id": item.id,
            "quantity": item.quantity,
            "durability": item.durability,
        })
    return items

func deserialize(data: Array) -> void:
    inventory_items.clear()
    for item_data in data:
        var item := Item.new()
        item.id = item_data.id
        item.quantity = item_data.quantity
        item.durability = item_data.get("durability", 100)  # Default for old saves
        inventory_items.append(item)

Notice the data.get("durability", 100) — this handles saves from before you added durability. Always provide defaults for new fields so old save files don't crash your game.

Handling Versioning

As your game evolves, your save format changes. Add a version number to every save file:

const SAVE_VERSION := 2

func save_game() -> void:
    var data := {
        "version": SAVE_VERSION,
        "player": player.serialize(),
        # ...
    }

func load_game() -> void:
    # ... parse file ...
    var version: int = data.get("version", 1)

    if version < 2:
        # Migrate old save format
        data = migrate_v1_to_v2(data)

This lets you ship updates without breaking players' saves. Version 1 saves get migrated up, version 2 saves load directly.

Multiple Save Slots

Most games need more than one save file. Use a simple naming convention:

func get_save_path(slot: int) -> String:
    return "user://save_slot_%d.json" % slot

func get_all_saves() -> Array[Dictionary]:
    var saves: Array[Dictionary] = []
    for i in range(1, 4):  # 3 slots
        var path := get_save_path(i)
        if FileAccess.file_exists(path):
            var file := FileAccess.open(path, FileAccess.READ)
            var json := JSON.new()
            if json.parse(file.get_as_text()) == OK:
                saves.append({"slot": i, "data": json.data})
    return saves

Common Mistakes

Saving Vector3 directly to JSON — JSON doesn't understand Godot types. Convert to plain numbers:

# Wrong — won't serialize
data["position"] = player.position

# Right — breaks into primitives
data["position"] = {
    "x": player.position.x,
    "y": player.position.y,
    "z": player.position.z,
}

Not handling missing files — Always check FileAccess.file_exists() before loading. First-time players won't have a save file.

Saving every frame — Save on specific events (entering a new area, completing a quest, manual save). Autosave on a timer (every 5 minutes) is fine, but not every frame.

No error handling — Corrupted save files happen. Wrap your parse logic in error checks and consider keeping a backup of the previous save.

Going Deeper

This covers the foundations, but a production save system has more layers: encrypted saves for anti-cheat, cloud saves for cross-device play, save file thumbnails, and complex world state serialization.

If you want to build a complete, production-ready save and load system step by step, we have a full 14-lesson course that takes you from basics to a polished system with multiple slots, migration, and error recovery.