GDSense how-to guide

Save and Load Game Data in Godot 4: ConfigFile, JSON, and Resources

Store settings and game progress in user:// with ConfigFile, versioned JSON, or a custom Resource, and test the save system before players depend on it.

Published Updated

All documentation guides

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 documentation

Should 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.

Choosing a save format in Godot 4
FormatBest forWatch out for
ConfigFileSettings such as volume, display mode, and key bindingsSections and keys are untyped on read; supply defaults and convert
JSONProgress data you may inspect, migrate, or share with other toolsOnly basic types survive; numbers come back as floats and vectors need converting
Custom ResourceTyped progress data your own game writes and readsA resource file can contain scripts; never load one from an untrusted source
Godot saving games tutorial

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.

res://autoload/settings.gd — register as 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.

res://save/save_game.gd
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 {}
res://player/player.gd — saving and restoring player data
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.

res://save/player_progress.gd
class_name PlayerProgress
extends Resource

@export var current_level: StringName = &"level_1"
@export var coins: int = 0
@export var best_times: Dictionary[StringName, float] = {}
res://save/progress_store.gd
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 loaded
Godot ResourceSaver class reference

How 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.

Save-system review request
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.

Try this workflow inside Godot

Use GDSense to ask questions, attach the context you choose, and review proposed changes without leaving the editor.