【Godot】Implementing Save/Load Systems - A Complete Comparison of JSON, ConfigFile, and Custom Resources

Created: 2025-12-08Last updated: 2025-12-16

Compare the three main methods for persisting game data in Godot Engine (JSON, ConfigFile, Custom Resources) from error handling, performance, and security perspectives with concrete implementation examples.

In game development, a save/load system that persistently stores player progress and settings and restores them on next launch is a crucial element that forms the foundation of the gaming experience.

Godot Engine provides several powerful built-in features for persisting data. This article thoroughly compares the three most commonly used methods—JSON, ConfigFile, and Custom Resources—explaining their characteristics and concrete implementations.


Conclusion First: When to Use Each Method

Before diving into details, here's a summary of the optimal use cases for each method:

FeatureConfigFileJSONCustom Resources (Recommended)
Primary UseSettings files (volume, key config)External API integration, generic dataAll game save data
Godot-specific TypesFull supportNot supported (manual conversion required)Full support
Code VolumeLowHigh (conversion code is tedious)Lowest
PerformanceFastSomewhat slow (text parsing)Fast (especially binary format)
Tamper ResistanceLow (text)Low (text)Configurable (binary/encryption)

1. ConfigFile: The Standard for Simple Settings

ConfigFile is a simple format consisting of [section] and key = value pairs, like Windows INI files. It's primarily suited for saving user-modifiable game settings.

Implementation Example: Saving and Loading Graphics Settings

# SettingsManager.gd
extends Node

const SAVE_PATH = "user://settings.cfg"

# Default settings
var default_settings = {
    "video": { "fullscreen": false, "vsync": true },
    "audio": { "master_volume": 0.8 }
}

func save_settings(settings: Dictionary) -> void:
    var config = ConfigFile.new()
    for section in settings.keys():
        for key in settings[section].keys():
            config.set_value(section, key, settings[section][key])

    var error = config.save(SAVE_PATH)
    if error != OK:
        printerr("Failed to save settings: %s" % error_string(error))

func load_settings() -> Dictionary:
    var config = ConfigFile.new()

    if not FileAccess.file_exists(SAVE_PATH):
        return default_settings

    var error = config.load(SAVE_PATH)
    if error != OK:
        printerr("Failed to load settings: %s. Using defaults." % error_string(error))
        return default_settings

    var loaded_settings = default_settings.duplicate(true)
    for section in default_settings.keys():
        for key in default_settings[section].keys():
            var loaded_value = config.get_value(section, key, default_settings[section][key])
            loaded_settings[section][key] = loaded_value

    return loaded_settings

2. JSON: High Versatility and Web Integration

JSON is a powerful choice for communicating with Web APIs or exchanging data with tools outside Godot due to its high readability and versatility. However, since it can't directly handle Godot-specific types, extra work is needed during serialization/deserialization.

Implementation Example: Safe Data Conversion with Helper Functions

# SaveLoadJSON.gd
extends Node

const SAVE_PATH = "user://save_game.json"

# Convert Godot data to JSON-compatible format
func _data_to_json(data):
    if data is Vector2:
        return [data.x, data.y]
    if data is Color:
        return [data.r, data.g, data.b, data.a]
    return data

# Restore Godot data from JSON format
# Note: After JSON parsing, numbers become int or float, so check both
func _json_to_data(data):
    if data is Array and data.size() == 2 and (data[0] is float or data[0] is int):
        return Vector2(data[0], data[1])
    if data is Array and data.size() == 4 and (data[0] is float or data[0] is int):
        return Color(data[0], data[1], data[2], data[3])
    return data

func save_game(game_state: Dictionary) -> void:
    var file = FileAccess.open(SAVE_PATH, FileAccess.WRITE)
    if not file:
        printerr("Failed to open JSON file.")
        return

    var serializable_state = game_state.duplicate(true)
    serializable_state["player_position"] = _data_to_json(game_state["player_position"])

    var json_string = JSON.stringify(serializable_state, "  ")
    file.store_string(json_string)
    file.close()

func load_game() -> Dictionary:
    if not FileAccess.file_exists(SAVE_PATH):
        return {}

    var file = FileAccess.open(SAVE_PATH, FileAccess.READ)
    if not file:
        printerr("Failed to read JSON file.")
        return {}

    var parse_result = JSON.parse_string(file.get_as_text())
    file.close()

    if parse_result is Dictionary:
        var loaded_data = parse_result
        loaded_data["player_position"] = _json_to_data(loaded_data["player_position"])
        return loaded_data
    else:
        printerr("Failed to parse JSON.")
        return {}

3. Custom Resources: The Recommended Save/Load Method in Godot

Custom classes that extend Resource (Custom Resources) are the most recommended save method that best aligns with Godot's philosophy. Variables annotated with @export are automatically serialized and deserialized by the engine, dramatically reducing code volume and guaranteeing type safety.

Step 1: Create a Resource Class for Save Data

# SaveGame.gd
class_name SaveGame
extends Resource

# Basic player information
@export var player_name: String = "Hero"
@export var health: int = 100
@export var global_position: Vector2 = Vector2.ZERO

# Complex data structures
@export var inventory: Dictionary = {}
@export var unlocked_levels: Array[String] = []

Step 2: Implement Save/Load Manager Class

# SaveManager.gd
extends Node

const SAVE_PATH_TRES = "user://savegame.tres" # Text format (for debugging)
const SAVE_PATH_RES = "user://savegame.res"   # Binary format (fast, tamper-resistant)

var current_save: SaveGame

func save_game(is_binary: bool = true) -> void:
    if not current_save:
        printerr("No save data exists.")
        return

    var path = SAVE_PATH_RES if is_binary else SAVE_PATH_TRES
    var error = ResourceSaver.save(current_save, path)
    if error != OK:
        printerr("Failed to save: %s" % error_string(error))
    else:
        print("Game saved successfully: %s" % path)

func load_game() -> bool:
    var path = SAVE_PATH_RES if ResourceLoader.exists(SAVE_PATH_RES) else SAVE_PATH_TRES

    if not ResourceLoader.exists(path):
        print("No save file found. Starting new game.")
        current_save = SaveGame.new()
        return false

    # Specify CACHE_MODE_IGNORE to load directly from file, not cache
    var loaded_resource = ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_IGNORE)
    if loaded_resource is SaveGame:
        current_save = loaded_resource
        print("Game loaded successfully: %s" % path)
        return true
    else:
        printerr("Failed to load save file.")
        current_save = SaveGame.new()
        return false

# Update resource with game state
func update_save_data(player: Node2D, inventory_data: Dictionary):
    if not current_save:
        current_save = SaveGame.new()
    current_save.global_position = player.global_position
    current_save.inventory = inventory_data

Performance and Security: .tres vs .res

  • .tres (Text format): Human-readable making debugging easy, but larger file size and easy to tamper with.
  • .res (Binary format): Fast with smaller file size, but not human-readable. Harder to tamper with than .tres, but not complete protection without encryption. Recommended for release builds.

Common Mistakes and Best Practices

Common MistakeBest Practice
Saving to absolute paths or res://Always use the user:// path. This avoids write permission issues across different operating systems.
Neglecting error handlingAlways check return values of save() and load() and implement failure handling.
Passing Godot-specific types directly to JSONWhen using JSON, prepare helper functions to convert Vector2, Color, etc. to arrays.
Freezing during save/loadPerform large data reads/writes asynchronously using Thread.
Not considering save data versioningAdd a version variable to resources and implement conversion processing from older versions.

Summary

This article explained the three main save/load methods in Godot Engine from a practical perspective.

FeatureConfigFileJSONCustom Resources
Primary UseSettings filesExternal integration, static dataGame save data
Godot-specific TypesFull supportNot supported (manual conversion needed)Full support
Code VolumeLowHigh (conversion processing needed)Lowest

For most game development scenarios, Custom Resources are the optimal choice from type safety, code conciseness, and performance perspectives. Consider implementing with Custom Resources first, then use ConfigFile or JSON as needed—this is the shortcut to robust and efficient development.