Skip to main content

Command Palette

Search for a command to run...

Godot 4 Save Systems: Resource, JSON, or Encrypted?

Updated
8 min read
Godot 4 Save Systems: Resource, JSON, or Encrypted?
Z
Developing Ziva AI - The AI for game development. https://ziva.sh/

Saves are the most boring feature you can ship until a player loses 12 hours of progress and leaves a one-star review. Every Godot 4 project needs one, but there are three reasonable ways to build it and the "right" choice depends on what you're saving and who's trying to cheat.

I've been writing a lot of save code lately for a 2D metroidvania I'm building on Godot 4.3. Here's how the three approaches actually compare when you sit down and use them.

The three options in Godot 4

Godot 4 gives you three reasonable paths for persisting game state:

Approach Format Type safety Tamper resistance Use when
FileAccess + JSON Human-readable text No None Prototypes, debug saves, cross-tool data
Custom Resource Binary .res or text .tres Yes (typed GDScript) Low Most games, especially single-player
FileAccess.open_encrypted_with_pass Encrypted binary Depends on wrapping High Competitive games, unlock data, achievements

All three use user:// as the write location, which resolves to %APPDATA% on Windows, ~/Library/Application Support/Godot/app_userdata/ on macOS, and ~/.local/share/godot/app_userdata/ on Linux. Never hardcode a path.

Approach 1: JSON via FileAccess

The simplest possible save. Dump a dictionary, serialize it, read it back.

# save_manager.gd
const SAVE_PATH = "user://save.json"

func save_game(state: Dictionary) -> void:
    var file = FileAccess.open(SAVE_PATH, FileAccess.WRITE)
    file.store_string(JSON.stringify(state))
    file.close()

func load_game() -> Dictionary:
    if not FileAccess.file_exists(SAVE_PATH):
        return {}
    var file = FileAccess.open(SAVE_PATH, FileAccess.READ)
    var text = file.get_as_text()
    file.close()
    var result = JSON.parse_string(text)
    return result if result is Dictionary else {}

Good for: prototypes, game jams, anything where a player opening the save file in a text editor and seeing "gold": 500 is fine or even desirable.

Bad for: anything with custom objects. JSON.stringify flattens Vector2 to "(10, 20)" and forgets it was ever a Vector2. You have to manually serialize and deserialize every typed value. For a real game this becomes hundreds of lines of boilerplate that breaks every time you add a field.

I used this approach for my first prototype. After about two weeks I had a 400-line save_manager.gd that was almost entirely if "position" in data: player.position = str_to_var(data["position"]) and I gave up.

Approach 2: Custom Resource saves

This is the approach the Godot official docs nudge you toward, and it's the one most Godot tutorials skip because it feels "enterprise-y". It's actually the easiest once you stop fighting it.

Define a Resource subclass with typed fields:

# save_data.gd
class_name SaveData
extends Resource

@export var version: int = 1
@export var player_name: String = ""
@export var player_position: Vector2 = Vector2.ZERO
@export var player_health: int = 100
@export var current_scene: String = ""
@export var inventory: Array[String] = []
@export var play_time_seconds: float = 0.0

Then save and load with ResourceSaver and ResourceLoader:

# save_manager.gd
const SAVE_PATH = "user://save.tres"

func save_game(data: SaveData) -> Error:
    return ResourceSaver.save(data, SAVE_PATH)

func load_game() -> SaveData:
    if not ResourceLoader.exists(SAVE_PATH):
        return SaveData.new()
    var data = ResourceLoader.load(SAVE_PATH)
    return data if data is SaveData else SaveData.new()

Why this is better:

  • Type safety. Vector2 stays Vector2. Arrays stay typed. The save file literally contains Vector2(100, 200) and loads back into a Vector2 with zero serialization code on your side.

  • Refactor-friendly. Rename a field in the @export, Godot handles the rest for existing saves (missing fields get default values).

  • Inspector-compatible. You can open a .tres save in the Godot editor and edit it like any other resource, which is a debugging superpower.

  • Nested Resources work. If your save has @export var weapon: WeaponData, the whole nested resource gets serialized. You can build entire save trees.

The gotcha: if your save data references scene nodes directly (@export var player_node: Node), you'll get a mess because nodes don't serialize well. Only store data, never references to live nodes.

Approach 3: Encrypted saves

For games where players would benefit from cheating their own save file (ranked leaderboards, premium unlock state, achievement gating), Godot 4 ships with FileAccess.open_encrypted_with_pass:

const SAVE_PATH = "user://save.dat"
const PASSWORD = "not-literally-this-please"

func save_game(data: SaveData) -> void:
    var file = FileAccess.open_encrypted_with_pass(
        SAVE_PATH, FileAccess.WRITE, PASSWORD
    )
    file.store_var(inst_to_dict(data))
    file.close()

func load_game() -> SaveData:
    if not FileAccess.file_exists(SAVE_PATH):
        return SaveData.new()
    var file = FileAccess.open_encrypted_with_pass(
        SAVE_PATH, FileAccess.READ, PASSWORD
    )
    if file == null:
        return SaveData.new()  // Wrong password or corrupted
    var dict = file.get_var()
    file.close()
    return dict_to_inst(dict) if dict is Dictionary else SaveData.new()

This uses AES-256. The encryption key lives in your compiled binary, so a determined attacker can extract it with a disassembler. That's fine for 99% of indie games. If you need real security (anti-cheat for online play), server-side validation is the only answer, not client-side encryption.

One gotcha: inst_to_dict and dict_to_inst don't handle all Resource types gracefully. For complex save data, serialize to JSON first, then encrypt the JSON.

Which one should you actually use?

After building all three for the same project, my take:

  • Single-player games with complex state: custom Resource. The type safety pays for itself the first time you add a new field.

  • Games with meaningful unlock progression: custom Resource wrapped in encryption. Use a unique key per build, rotate it on major updates.

  • Game jams and prototypes: JSON. You'll throw the code away anyway.

  • Debug builds in any project: ship a JSON export alongside your real save format. Being able to eyeball save state saves hours during debugging.

The worst thing you can do is pick "simple JSON" early, ship with it, and then try to migrate to Resources when your save file has 80 fields. Been there. Do it right the first time.

What about save versioning?

This is the question that kills most indie save systems. You ship v1.0, add a feature in v1.1 that stores a new field, and suddenly everyone's v1.0 saves crash on load.

The Resource approach handles this gracefully: missing @export fields get their default values. You can also add a version field:

@export var version: int = 2

func migrate_from_v1() -> void:
    if version == 1:
        # v1 stored health as float, we want int
        player_health = int(player_health)
        version = 2

Call migrate_from_v1() right after load. If you keep the migration chain short (don't skip versions), this works forever.

A note on async I/O

Godot 4 made file I/O synchronous by default, which is usually fine for saves (they're small and fast). But if you're saving a 10MB save file on a Steam Deck with eMMC storage, a sync save can cause a frame hitch.

The fix: use WorkerThreadPool.add_task to run the save on a background thread. Don't try to build your own threading, Godot's thread pool handles the lifecycle for you.

func save_game_async(data: SaveData) -> void:
    WorkerThreadPool.add_task(func():
        ResourceSaver.save(data, "user://save.tres")
    )

You lose the ability to catch save errors directly, so log them inside the task and display a toast on the next frame if anything went wrong.

Building save code with AI help

Save systems are one of those areas where AI tooling actually earns its keep: the patterns are standard, the bugs are subtle (off-by-one version numbers, missing fields), and testing them properly takes discipline that most solo devs don't have.

I use Ziva for Godot-specific code generation. It's an AI agent that runs inside the Godot editor and understands the type system, so when I ask it to "add an inventory field to my SaveData resource", it actually updates the @export declaration, the migration function, and the tests, instead of writing me 40 lines of JSON parsing. The Godot context matters here. Generic ChatGPT will happily generate FileAccess.open_compressed code that doesn't exist in Godot 4, or mix Godot 3 and Godot 4 idioms in the same snippet.

For anyone evaluating tools in this space, I wrote a longer breakdown of AI tools for Godot that goes into what works and what doesn't.

TL;DR

  • Use custom Resource for most games. It's the least code and the most type-safe.

  • Add encryption only when cheating a save file would hurt other players or break monetization.

  • Don't use raw JSON beyond prototyping. The boilerplate tax compounds fast.

  • Always version your save data from day one. Migration chains are cheap; "sorry, your save won't load" is expensive.

  • Write to user://, never to res:// or absolute paths.

Saves aren't glamorous, but they're one of the few systems where the player notices the second they break. Spend the afternoon getting them right.