---
title: "How to Save and Load a Game in Godot 4"
description: "Save and load game data in Godot 4 with FileAccess. Compare JSON, custom Resources, store_var and ConfigFile, with working code, versioning and safety tips."
author: "Tajammal Maqbool"
last_updated: "2026-09-19"
---

# How to Save and Load a Game in Godot 4

> Save and load game data in Godot 4 with FileAccess. Compare JSON, custom Resources, store_var and ConfigFile, with working code, versioning and safety tips.

**Author:** Tajammal Maqbool  
**Published:** September 19, 2026  
**Tags:** godot, game development

Sooner or later every game needs to remember something: the player's level, their inventory, which doors are open, the volume slider. Search for **godot save game** and you mostly get videos, or documentation written for Godot 2 and 3, where `File` and `Directory` classes no longer exist in Godot 4. Written, up-to-date guides that compare the actual options are surprisingly rare.

This guide fixes that. You will learn where Godot stores save files, how the Godot 4 `FileAccess` API works, and four different ways to save and load data:

1. **JSON**: human-readable text, great for debugging.
2. **Custom Resources**: the most "Godot-native" option, almost no code.
3. **`store_var()` / `get_var()`**: compact binary, supports Godot types directly.
4. **`ConfigFile`**: the right tool for settings.

Then we put them side by side so you can choose, and finish with the parts most tutorials skip: saving many objects, versioning, safe writes and security.

## 1. Where Godot Saves Files: `res://` vs `user://`
Godot has two virtual file systems, and mixing them up is the most common beginner mistake.

* **`res://`** is your project folder. It is **read-only** in an exported game. Never save here.
* **`user://`** is a per-user, writable folder that Godot creates for your game. **Always save here.**

Where `user://` lives on disk depends on the platform:

| Platform | Default `user://` location |
|----------|----------------------------|
| Windows | `%APPDATA%\Godot\app_userdata\[Project Name]` |
| macOS | `~/Library/Application Support/Godot/app_userdata/[Project Name]` |
| Linux | `~/.local/share/godot/app_userdata/[Project Name]` |
| Web | Browser storage (IndexedDB) |
| Android and iOS | The app's private data folder |

To open it quickly while developing, use **Project > Open User Data Folder** in the editor. If you enable **Use Custom User Dir** in Project Settings (under Application > Config), Godot drops the `Godot/app_userdata` part and uses your own folder name, which looks more professional in a shipped game.

## 2. The FileAccess Basics (Godot FileAccess)
Godot 4 replaced Godot 3's `File` class with **`FileAccess`**. You no longer create an instance and call `open()`. You call the static `FileAccess.open()` and it returns an open file, or `null` on failure.

```gdscript
const SAVE_PATH := "user://savegame.txt"

func write_text(text: String) -> void:
    var file := FileAccess.open(SAVE_PATH, FileAccess.WRITE)
    if file == null:
        push_error("Could not open save file: %s" % FileAccess.get_open_error())
        return
    file.store_string(text)

func read_text() -> String:
    if not FileAccess.file_exists(SAVE_PATH):
        return ""
    var file := FileAccess.open(SAVE_PATH, FileAccess.READ)
    return file.get_as_text()
```

Things to know:

* **Modes**: `READ`, `WRITE` (creates or truncates the file), `READ_WRITE` (file must exist), and `WRITE_READ` (creates or truncates, then allows reading).
* **Closing**: the file closes automatically when the `file` variable goes out of scope. Call `file.close()` yourself if you need to reopen the same path in the same function.
* **Errors**: when `open()` returns `null`, `FileAccess.get_open_error()` tells you why.
* **Existence**: `FileAccess.file_exists(path)` checks before you read.

Every approach below is built on these few calls, so it is worth getting comfortable with them.

## 3. Deciding What to Save
Before writing a single line of save code, separate **state** from **content**. You save what the player changed, not what ships with the game.

* Save: current level, player position, health, inventory item IDs, unlocked abilities, quest progress, settings.
* Do not save: textures, scenes, enemy stats that come from your data files, anything that can be rebuilt from `res://`.

For items, save an ID like `"iron_sword"` and look up the full item data at load time. If you save the whole item, a balance patch that changes the sword's damage will never reach existing save files.

## 4. Method 1: Saving and Loading with JSON
JSON is the most popular choice for good reasons: you can open the file in any text editor, see exactly what was saved, and fix it by hand while debugging.

### Save

```gdscript
# save_manager.gd, added as an Autoload named "SaveManager"
extends Node

const SAVE_PATH := "user://savegame.json"
const SAVE_VERSION := 1

func save_game(player: Player) -> void:
    var data := {
        "version": SAVE_VERSION,
        "level": player.level,
        "health": player.health,
        "gold": player.gold,
        "position": {"x": player.global_position.x, "y": player.global_position.y},
        "inventory": player.inventory,   # Array of item ID strings
        "saved_at": Time.get_datetime_string_from_system(),
    }
    var file := FileAccess.open(SAVE_PATH, FileAccess.WRITE)
    if file == null:
        push_error("Save failed: %s" % FileAccess.get_open_error())
        return
    file.store_string(JSON.stringify(data, "\t"))
```

The `"\t"` argument pretty-prints the file with tabs, which makes it much easier to read. Drop it in release builds if you want a smaller file.

### Load

```gdscript
func load_game(player: Player) -> bool:
    if not FileAccess.file_exists(SAVE_PATH):
        return false
    var file := FileAccess.open(SAVE_PATH, FileAccess.READ)
    var data = JSON.parse_string(file.get_as_text())
    if typeof(data) != TYPE_DICTIONARY:
        push_error("Save file is corrupted")
        return false

    player.level = int(data.get("level", 1))
    player.health = int(data.get("health", 100))
    player.gold = int(data.get("gold", 0))
    var pos: Dictionary = data.get("position", {})
    player.global_position = Vector2(pos.get("x", 0.0), pos.get("y", 0.0))
    player.inventory.assign(data.get("inventory", []))
    return true
```

### Two JSON Gotchas You Will Hit
**Every number comes back as a float.** JSON has one number type, so `"level": 3` loads as `3.0`. That is why the load code wraps values in `int()`. Skip it and a typed `var level: int` assignment, a `match` or an array index will misbehave.

**Godot types do not survive the round trip.** `JSON.stringify()` turns a `Vector2` into the string `"(12, 34)"`, and it does not parse back into a `Vector2`. Either store components yourself, as with `position` above, or on Godot 4.4 and newer use `JSON.from_native()` when saving and `JSON.to_native()` when loading, which encode engine types in a form that converts back.

Using `.get()` with a default for every key is not paranoia. It is what keeps old save files loading after you add new fields. My [Godot Dictionary guide](https://tajammalmaqbool.com/pages/blogs/godot-dictionary-a-comprehensive-guide.md) covers this pattern in depth.

## 5. Method 2: Custom Resources
Resources are Godot's native data containers. Define a class with `@export` properties, and `ResourceSaver` and `ResourceLoader` handle the rest. `Vector2`, `Color`, typed arrays, even nested resources are all saved correctly with zero conversion code.

### Define the Save Data

```gdscript
# save_game.gd
class_name SaveGame
extends Resource

@export var version: int = 1
@export var level: int = 1
@export var health: int = 100
@export var gold: int = 0
@export var position: Vector2 = Vector2.ZERO
@export var inventory: Array[String] = []
@export var unlocked_doors: Dictionary = {}
```

### Save and Load

```gdscript
const SAVE_PATH := "user://savegame.tres"

func save_game(player: Player) -> void:
    var save := SaveGame.new()
    save.level = player.level
    save.health = player.health
    save.gold = player.gold
    save.position = player.global_position
    save.inventory = player.inventory.duplicate()
    var err := ResourceSaver.save(save, SAVE_PATH)
    if err != OK:
        push_error("Save failed with error %s" % err)

func load_game(player: Player) -> bool:
    if not ResourceLoader.exists(SAVE_PATH):
        return false
    var save := ResourceLoader.load(SAVE_PATH, "", ResourceLoader.CACHE_MODE_IGNORE) as SaveGame
    if save == null:
        return false
    player.level = save.level
    player.health = save.health
    player.gold = save.gold
    player.global_position = save.position
    player.inventory = save.inventory.duplicate()
    return true
```

Note `CACHE_MODE_IGNORE`. Godot caches loaded resources by path, so without it, loading the same save twice in one session can hand you the stale copy from the first load instead of what is on disk.

Use the `.tres` extension for a readable text file while developing, and `.res` for a compact binary file.

### The Security Catch
A `.tres` or `.res` file can contain embedded scripts, and loading it will run them. For save files that stay on the player's own machine, that is usually acceptable. **Never load Resource files from untrusted sources**, such as saves shared online, downloaded mods or cloud files from other players. For anything shareable, use JSON or `store_var()` instead.

## 6. Method 3: store_var and get_var (Binary)
`store_var()` writes any Godot `Variant` in Godot's binary format, and `get_var()` reads it back with its type intact. It is compact, fast, and understands `Vector2`, `Color`, `Dictionary` and friends natively.

```gdscript
const SAVE_PATH := "user://savegame.dat"

func save_game(data: Dictionary) -> void:
    var file := FileAccess.open(SAVE_PATH, FileAccess.WRITE)
    if file == null:
        push_error("Save failed: %s" % FileAccess.get_open_error())
        return
    file.store_var(data)

func load_game() -> Dictionary:
    if not FileAccess.file_exists(SAVE_PATH):
        return {}
    var file := FileAccess.open(SAVE_PATH, FileAccess.READ)
    var data = file.get_var()
    return data if data is Dictionary else {}
```

```gdscript
SaveManager.save_game({
    "version": 1,
    "position": player.global_position,   # stays a real Vector2
    "tint": player.modulate,              # stays a real Color
    "inventory": player.inventory,
})
```

Leave the optional second argument at its default of `false` (`full_objects` on `store_var()`, `allow_objects` on `get_var()`). Turning it on lets the file contain whole Objects, and with them code, which brings back the same security risk as Resources.

### Encrypting Save Files
Any `FileAccess` method can work on an encrypted file. Swap `open()` for `open_encrypted_with_pass()`:

```gdscript
var file := FileAccess.open_encrypted_with_pass(SAVE_PATH, FileAccess.WRITE, "your-secret-key")
```

Be realistic about what this gives you. The key is inside your game, so a determined player can extract it. Encryption stops casual editing of gold and health, not a motivated cheater. For single-player games that is usually all you want.

## 7. Method 4: ConfigFile for Settings
Settings (volume, resolution, key bindings, language) are different from game progress: they are per machine, not per save slot, and players sometimes want to edit them by hand. `ConfigFile` writes a simple INI-style file for exactly this.

```gdscript
const SETTINGS_PATH := "user://settings.cfg"

func save_settings() -> void:
    var config := ConfigFile.new()
    config.set_value("audio", "master_volume", master_volume)
    config.set_value("audio", "music_volume", music_volume)
    config.set_value("video", "fullscreen", fullscreen)
    config.save(SETTINGS_PATH)

func load_settings() -> void:
    var config := ConfigFile.new()
    if config.load(SETTINGS_PATH) != OK:
        return
    master_volume = config.get_value("audio", "master_volume", 1.0)
    music_volume = config.get_value("audio", "music_volume", 0.8)
    fullscreen = config.get_value("video", "fullscreen", false)
```

`get_value()` takes a default, so new settings added in an update just fall back cleanly.

## 8. JSON vs Resources vs store_var vs ConfigFile

| | JSON | Custom Resource | store_var | ConfigFile |
|---|------|-----------------|-----------|------------|
| Human readable | Yes | Yes (`.tres`) | No | Yes |
| Godot types (`Vector2`, `Color`) | Manual, or `from_native` in 4.4+ | Automatic | Automatic | Automatic |
| Numbers keep int type | No, ints become floats | Yes | Yes | Yes |
| Code required | Medium | Very little | Little | Little |
| Safe with untrusted files | Yes | **No** | Yes (default settings) | Yes |
| Easy to read outside Godot | Yes | No | No | Mostly |
| Best for | Most save games, debugging, cloud saves | Single-player, fast iteration | Compact binary saves | Settings |

My recommendation:

* **Starting out or unsure?** JSON. You can see what went wrong by opening the file.
* **Solo project, lots of Godot types, saves never leave the player's machine?** Resources. The least code by far.
* **Want compact binary with native types and no script risk?** `store_var()`.
* **Settings?** `ConfigFile`, always, and in a separate file from the save game.

## 9. Saving Every Object in a Level
A real level has many things to remember: collected coins, opened chests, defeated enemies. Rather than hard-coding each one, add the relevant nodes to a group named `persist` and give each a `save()` method.

```gdscript
# chest.gd
extends Node2D

var is_open := false

func save() -> Dictionary:
    return {
        "scene": scene_file_path,
        "parent": get_parent().get_path(),
        "x": position.x,
        "y": position.y,
        "is_open": is_open,
    }
```

```gdscript
# in SaveManager
func save_level() -> void:
    var nodes: Array = []
    for node in get_tree().get_nodes_in_group("persist"):
        if node.has_method("save"):
            nodes.append(node.save())
    var file := FileAccess.open("user://level.json", FileAccess.WRITE)
    file.store_string(JSON.stringify({"version": 1, "nodes": nodes}))

func load_level() -> void:
    if not FileAccess.file_exists("user://level.json"):
        return
    for node in get_tree().get_nodes_in_group("persist"):
        node.queue_free()

    var file := FileAccess.open("user://level.json", FileAccess.READ)
    var data = JSON.parse_string(file.get_as_text())
    if typeof(data) != TYPE_DICTIONARY:
        return
    for entry in data.get("nodes", []):
        var node: Node2D = load(entry["scene"]).instantiate()
        node.position = Vector2(entry["x"], entry["y"])
        node.is_open = entry.get("is_open", false)
        get_node(entry["parent"]).add_child(node)
```

The loader removes existing persistent nodes and recreates them from the file, so the level matches the save exactly. Only put objects in the group that the player can actually change.

## 10. Save Versioning and Migration
You will add fields after launch. Plan for it on day one by storing a `version` number (all the examples above do), then upgrade old data step by step when loading:

```gdscript
const SAVE_VERSION := 3

func migrate(data: Dictionary) -> Dictionary:
    var version: int = int(data.get("version", 1))
    if version < 2:
        data["gold"] = data.get("coins", 0)   # renamed in v2
        data.erase("coins")
    if version < 3:
        data["unlocked_doors"] = {}           # added in v3
    data["version"] = SAVE_VERSION
    return data
```

Call `migrate()` right after parsing, before reading any field. Each `if` handles one upgrade, and old saves move through them in order until they are current. Players never lose progress because you renamed a variable.

## 11. Safe Writes: Do Not Corrupt the Save
If the game crashes or the power fails halfway through writing, the player can lose the only copy of their save. The fix is cheap: write to a temporary file, then swap it in.

```gdscript
func write_safely(path: String, text: String) -> Error:
    var temp_path := path + ".tmp"
    var file := FileAccess.open(temp_path, FileAccess.WRITE)
    if file == null:
        return FileAccess.get_open_error()
    file.store_string(text)
    file.close()

    if FileAccess.file_exists(path):
        DirAccess.rename_absolute(path, path + ".bak")
    return DirAccess.rename_absolute(temp_path, path)
```

As a bonus, you keep the previous save as `.bak`. If loading the main file fails, try the backup before telling the player their progress is gone.

## 12. Save Slots
Multiple slots are just multiple file names:

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

func list_slots() -> Array[int]:
    var slots: Array[int] = []
    for i in range(1, 4):
        if FileAccess.file_exists(slot_path(i)):
            slots.append(i)
    return slots
```

Store a small summary in each file (level name, play time, `saved_at`) so the load menu can show details without loading the whole game.

## 13. Common Mistakes

| Mistake | Symptom | Fix |
|---------|---------|-----|
| Saving to `res://` | Works in the editor, fails in exports | Always use `user://` |
| Using Godot 3's `File` class | Parse error: identifier not declared | Use `FileAccess.open()` |
| Not converting JSON numbers | Ints become floats, typed code breaks | Wrap with `int()` on load |
| Saving `Vector2` straight to JSON | Loads back as a String | Save components, or `from_native`/`to_native` |
| Loading `.tres` from other players | Arbitrary code can run | Use JSON or `store_var` for shared saves |
| Loading a Resource save twice | Second load returns the cached copy | `CACHE_MODE_IGNORE` |
| No version field | Old saves break after an update | Save `version`, migrate on load |
| Settings mixed into save slots | Volume resets when switching slots | Separate `settings.cfg` |

## 14. Frequently Asked Questions

### Where are Godot save files stored?
In the `user://` folder. On Windows that is `%APPDATA%\Godot\app_userdata\[Project Name]` by default. Use **Project > Open User Data Folder** to jump there from the editor.

### What happened to the File class in Godot 4?
It was replaced by `FileAccess`, and `Directory` was replaced by `DirAccess`. Both use static constructors such as `FileAccess.open()` and `DirAccess.open()`.

### Does saving work in web exports?
Yes. `user://` is backed by the browser's IndexedDB. Data can be lost if the player clears site data, so offer an export or cloud option for long games.

### When should I save?
Autosave at natural checkpoints (level complete, entering a new area) plus a manual save in the pause menu. Avoid saving every frame or during combat, and never save in the middle of a scene change.

### Can I save a whole scene with PackedScene?
You can, with `PackedScene.pack()` and `ResourceSaver.save()`, but it saves far more than you need and ties your save format to your scene structure. Saving plain data and rebuilding the scene, as in section 9, is more robust.

## Conclusion
A reliable **Godot save game** system comes down to a few decisions. Save to `user://`, use `FileAccess` for the file itself, and choose a format that fits: **JSON** for readability and safety, **Resources** for the least code on a single-player project, **`store_var()`** for compact binary with native types, and **`ConfigFile`** for settings. Add a version number from the start, write through a temporary file, and never load Resource files you did not create.

Get those right and your players will never lose progress, even after a crash or your tenth patch.

> Follow and Support me on [Medium](https://medium.com/@tajammalmaqbool11) and [Patreon](https://www.patreon.com/TajammalMaqbool). Clap and Comment on Medium Posts if you find this helpful for you. Thanks for reading it!!!

---

## Related Articles

- [GDScript vs C# in Godot - Which Should You Use?](https://tajammalmaqbool.com/pages/blogs/gdscript-vs-csharp-in-godot.md)
- [Godot Match Statement - Switch Case in GDScript](https://tajammalmaqbool.com/pages/blogs/godot-match-statement-switch-case-in-gdscript.md)
- [Godot Signals - A Comprehensive Guide (Godot 4)](https://tajammalmaqbool.com/pages/blogs/godot-signals-a-comprehensive-guide.md)
- [Godot State Machine Tutorial - Enum vs Node Approach](https://tajammalmaqbool.com/pages/blogs/godot-state-machine-tutorial.md)
- [Godot Array - A Comprehensive Guide (GDScript 4.x)](https://tajammalmaqbool.com/pages/blogs/godot-array-a-comprehensive-guide.md)

## Sitemap

See the full [sitemap](https://tajammalmaqbool.com/sitemap.md) for all pages.
