The short answer
Save game data under user://, the per-player folder Godot can always write to; res:// is read-only in an exported game. Use ConfigFile for settings, JSON for portable progress data you convert to and from basic types, and a custom Resource saved with ResourceSaver when you want typed, Inspector-friendly data your own game writes. Check every open and parse result, store a version number with each save, and test a missing, corrupted, and outdated file before you ship.
Where should a Godot game save data?
Always write player data to user://. Godot maps it to a per-user application data folder on each platform, and it is writable both in the editor and in exported builds. res:// points at your project files, which are packed and read-only once the game is exported, so a save that works in the editor would fail for players.
To find the files while testing, print OS.get_user_data_dir() or use Project → Open User Data Folder in the editor. Deleting the file there gives you a clean first-run state.
Godot data paths documentationShould I use ConfigFile, JSON, or a Resource?
Pick one format per kind of data and keep the code that reads and writes it in one place, such as a small save script or an autoload. Mixing formats for the same data makes migrations harder later.
| Format | Best for | Watch out for |
|---|---|---|
| ConfigFile | Settings such as volume, display mode, and key bindings | Sections and keys are untyped on read; supply defaults and convert |
| JSON | Progress data you may inspect, migrate, or share with other tools | Only basic types survive; numbers come back as floats and vectors need converting |
| Custom Resource | Typed progress data your own game writes and reads | A resource file can contain scripts; never load one from an untrusted source |
How do I save settings with ConfigFile?
ConfigFile writes an INI-style text file with sections and keys. Read every value with a default, so a missing key or a first run still produces valid settings.
extends Node
const SETTINGS_PATH := "user://settings.cfg"
var master_volume_db: float = 0.0
var fullscreen: bool = false
func _ready() -> void:
load_settings()
func save_settings() -> void:
var config := ConfigFile.new()
config.set_value("audio", "master_volume_db", master_volume_db)
config.set_value("display", "fullscreen", fullscreen)
var error := config.save(SETTINGS_PATH)
if error != OK:
push_error("Could not save settings: %s" % error_string(error))
func load_settings() -> void:
var config := ConfigFile.new()
if config.load(SETTINGS_PATH) != OK:
return
master_volume_db = float(config.get_value("audio", "master_volume_db", 0.0))
fullscreen = bool(config.get_value("display", "fullscreen", false))How do I save game progress as versioned JSON?
Wrap the data in an object that records a version number, so a future update can recognize and migrate older saves. JSON.parse_string() returns null for invalid text, so check the result type before using it.
Write to a temporary file first and replace the real save only after the write succeeds, so a failed or interrupted write never destroys the last good save. Checking the bool that store_string() returns requires Godot 4.4 or later.
Convert engine types yourself. Store a Vector2 as a two-number array and rebuild it on load, and convert counts back with int() because JSON numbers return as floats.
class_name SaveGame
extends RefCounted
const SAVE_PATH := "user://savegame.json"
const SAVE_VERSION := 1
static func write(data: Dictionary) -> bool:
var temp_path := SAVE_PATH + ".tmp"
var file := FileAccess.open(temp_path, FileAccess.WRITE)
if file == null:
push_error("Could not open save file: %s" % error_string(FileAccess.get_open_error()))
return false
var payload := {"version": SAVE_VERSION, "data": data}
var written := file.store_string(JSON.stringify(payload, "\t"))
var write_error := file.get_error()
file.close()
if not written or write_error != OK:
push_error("Could not write save file; the previous save is unchanged.")
DirAccess.remove_absolute(temp_path)
return false
var rename_error := DirAccess.rename_absolute(temp_path, SAVE_PATH)
if rename_error != OK:
push_error("Could not replace save file: %s" % error_string(rename_error))
return false
return true
static func read() -> Dictionary:
if not FileAccess.file_exists(SAVE_PATH):
return {}
var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(SAVE_PATH))
if not (parsed is Dictionary):
push_warning("Save file could not be read; starting a new game.")
return {}
var payload: Dictionary = parsed
if int(payload.get("version", 0)) != SAVE_VERSION:
push_warning("Save file version is not supported.")
return {}
var data: Variant = payload.get("data")
if data is Dictionary:
return data
return {}extends CharacterBody2D
var coins: int = 0
func to_save_data() -> Dictionary:
return {
"position": [global_position.x, global_position.y],
"coins": coins,
}
func apply_save_data(data: Dictionary) -> void:
var saved_position: Variant = data.get("position")
if saved_position is Array and saved_position.size() == 2:
global_position = Vector2(float(saved_position[0]), float(saved_position[1]))
coins = int(data.get("coins", 0))Keep in mind: Save at clear moments, such as a checkpoint or the pause menu, rather than every frame. Writing less often keeps the file consistent and makes bugs easier to reproduce.
How do I save progress as a custom Resource?
A custom Resource gives you typed fields that the Inspector understands, and ResourceSaver writes it to a .tres text file. Load it with CACHE_MODE_IGNORE so a second load in the same session reads the file again rather than returning the cached copy. The typed Dictionary[StringName, float] field below requires Godot 4.4 or later; on earlier versions, declare it as a plain Dictionary.
Only load resource files your own game wrote to user://. A .tres or .res file can carry embedded scripts that run when loaded, so never load one that a player downloaded or shared. JSON is the safer default whenever save files might be shared, downloaded, or modded.
class_name PlayerProgress
extends Resource
@export var current_level: StringName = &"level_1"
@export var coins: int = 0
@export var best_times: Dictionary[StringName, float] = {}class_name ProgressStore
extends RefCounted
const PROGRESS_PATH := "user://progress.tres"
static func save(progress: PlayerProgress) -> bool:
var error := ResourceSaver.save(progress, PROGRESS_PATH)
if error != OK:
push_error("Could not save progress: %s" % error_string(error))
return false
return true
static func load_or_new() -> PlayerProgress:
if not ResourceLoader.exists(PROGRESS_PATH):
return PlayerProgress.new()
var loaded := ResourceLoader.load(PROGRESS_PATH, "", ResourceLoader.CACHE_MODE_IGNORE) as PlayerProgress
if loaded == null:
push_warning("Progress file could not be read; starting fresh.")
return PlayerProgress.new()
return loadedHow do I test a save system before players rely on it?
Test the failure paths as deliberately as the happy path. A save system is only trustworthy when a missing or damaged file leads to a clear, recoverable state.
- A first run with no save file starts a new game without errors.
- Save, quit completely, relaunch, and load restores the same position, coins, and level.
- A save file with its content replaced by invalid text is reported and does not crash the game.
- A save with an older version number is migrated or rejected with a clear message.
- Settings changed in the options menu survive a restart.
- The exported build saves and loads correctly, not just the editor run.
How can GDSense help review a save system?
With your save script open, ask GDSense to check the paths, error handling, and type conversions before you build more of the game on top of it. The project map tells it which autoloads and scripts exist, so suggestions can fit your current layout.
Review this save script against these rules: user:// only, versioned data,
every open and parse result checked, and JSON numbers converted back to int.
List each issue with the line it affects and a test that would catch it.
Do not change the save format yet.