DEV Community

Cover image for Godot 4 Save Systems: 5 Patterns from Real Shipped Games
Ziva
Ziva

Posted on

Godot 4 Save Systems: 5 Patterns from Real Shipped Games

Every Godot tutorial pretends save systems are easy. They are not. The choice you make on day one quietly decides whether your save format survives a refactor, whether modders can edit a config, and whether your players lose their progress when you ship a patch.

I have shipped two Godot games and dug through the docs and source of a few more. Here are the five save patterns that actually show up in production Godot games, ranked by where they make sense and where they bite you.

1. ConfigFile for settings, not state

ConfigFile writes INI-style files: [section] blocks with key = value pairs. The official docs describe it as "creating simple configuration files."

It is good at one thing: settings. Audio volumes, resolution, keybinds, accessibility toggles. The file is human-readable, easy to ship as a user://settings.cfg, and trivially editable by tech-savvy players.

var cfg = ConfigFile.new()
cfg.set_value("audio", "master", 0.8)
cfg.set_value("controls", "jump", "space")
cfg.save("user://settings.cfg")
Enter fullscreen mode Exit fullscreen mode

Where it bites: it does not handle nested data well. Save your game progress in ConfigFile and you end up flattening dictionaries by hand. GDQuest's save cheatsheet explicitly recommends ConfigFile only for "small data like settings."

2. JSON with FileAccess for hand-edited data

JSON is the format every web developer knows, and it works in Godot via JSON.stringify / JSON.parse_string with FileAccess. Godot Learning's January 2026 tutorial walks through a full implementation with auto-save and multiple slots.

var data = {"hp": 80, "pos": [10, 20], "inventory": ["sword", "potion"]}
var file = FileAccess.open("user://save_01.json", FileAccess.WRITE)
file.store_string(JSON.stringify(data))
Enter fullscreen mode Exit fullscreen mode

Where it bites: JSON does not natively support Godot types. Vector2(10, 20) becomes [10, 20] and you write conversion code by hand both ways. Miss one type and the load silently produces an Array, not a Vector2, and your character spawns at null.x.

Use JSON when:

  • You need humans (modders, QA, designers) to read the save file
  • Save data is mostly primitives (strings, numbers, arrays)
  • You are syncing with a web backend that speaks JSON anyway

3. Binary serialization with store_var / get_var

Most overlooked native option. FileAccess.store_var() and FileAccess.get_var() write Variant types directly to a binary file. Vector2 stays Vector2. Dictionary stays Dictionary. No conversion code.

var file = FileAccess.open("user://save_01.dat", FileAccess.WRITE)
file.store_var({"hp": 80, "pos": Vector2(10, 20)})
Enter fullscreen mode Exit fullscreen mode

The docs note this format is "secure by default because it prevents saving and loading objects, which are what enable code execution in Godot." That security comes from refusing to deserialize objects, which is also its limit: you cannot save scene references or class instances directly.

Where it bites: binary files cannot be diffed in version control. If your save data is config-like and you need PR review, this is the wrong tool. Use it for hot-path saves where speed matters and you do not need humans reading the file.

4. Custom Resources + ResourceSaver

The pattern Godot itself wants you to use. Define a Resource subclass with @export properties for every saved field, populate an instance, and call ResourceSaver.save(). GDQuest's resource save guide calls this "the most concise method with full type safety."

class_name SaveData extends Resource
@export var hp: int = 100
@export var pos: Vector2 = Vector2.ZERO
@export var inventory: Array[String] = []
Enter fullscreen mode Exit fullscreen mode
var save = SaveData.new()
save.hp = 80
save.pos = player.position
ResourceSaver.save(save, "user://save_01.tres")

var loaded: SaveData = load("user://save_01.tres")
Enter fullscreen mode Exit fullscreen mode

Static typing. Code completion. Automatic conversion. Most importantly, you can save the same Resource class to either .tres (text, diffable) or .res (binary, smaller) by changing the extension.

Where it bites: schema migration. Adding a field is fine, but renaming or removing fields breaks existing saves. You either keep the old field around forever or write migration code. Godot's official saving docs flag this as the trade-off you accept in exchange for type safety.

5. The hybrid pattern: Resource for state, ConfigFile for settings

This is what shipping games actually do. I have not seen a single non-trivial Godot game use just one of the four patterns above. The pattern:

  • user://settings.cfg (ConfigFile) for audio, controls, display
  • user://save_01.tres (custom Resource) for game state
  • Optional: user://stats.json (JSON) for analytics or stuff you want to inspect manually

Slay the Spire 2 saves to the standard Godot user folder, with run state and persistent unlocks in separate files so a run crash does not nuke your meta-progress. Splitting state by lifecycle (per-run, per-profile, per-install) is the actual lesson, not the format choice.

What AI assistants get wrong here

This is a 2026 problem: ask ChatGPT or Claude for a Godot 4 save system and you will almost always get one of three patterns:

  1. func _save(): with var file = File.new(). That is Godot 3 syntax. File was removed in 4.0. The replacement is FileAccess.open() as a static call.
  2. JSON with manual Vector serialization but no @export annotation suggestion. Generic models do not know that @export on a Resource subclass is the modern path.
  3. store_line() for save data. That works for plain text but loses every Godot type. It also encourages the JSON-with-manual-conversion antipattern.

This is the same drift I covered in my earlier post on Godot 4 API calls AI assistants still get wrong. The fix is the same: use a Godot-aware tool that reads your project's project.godot and knows which Godot version you are actually on. Tools like Ziva inject the current Godot docs into the model at inference time, which is how you avoid the File.new() rabbit hole on a 4.7 project.

Quick decision table

Pattern Use for Avoid for
ConfigFile Settings, keybinds Game state
JSON Modder-editable saves, web sync Type-heavy state
Binary store_var Fast saves, big state Files you need to diff
Custom Resource Type-safe game state Schema-volatile data
Hybrid Real shipped games Tiny prototypes

If you are starting a new Godot 4 project today, default to Custom Resources for state and ConfigFile for settings. Migrate the analytics/debug stuff to JSON only if you actually need to read it by hand.

The cost of getting this wrong is not "your save file is ugly." It is "you ship a patch in month six and 5% of your players post one-star Steam reviews because their save vanished." Pick the pattern that survives that month-six rewrite.

Top comments (0)