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:
| Feature | ConfigFile | JSON | Custom Resources (Recommended) |
|---|---|---|---|
| Primary Use | Settings files (volume, key config) | External API integration, generic data | All game save data |
| Godot-specific Types | Full support | Not supported (manual conversion required) | Full support |
| Code Volume | Low | High (conversion code is tedious) | Lowest |
| Performance | Fast | Somewhat slow (text parsing) | Fast (especially binary format) |
| Tamper Resistance | Low (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 Mistake | Best Practice |
|---|---|
Saving to absolute paths or res:// | Always use the user:// path. This avoids write permission issues across different operating systems. |
| Neglecting error handling | Always check return values of save() and load() and implement failure handling. |
| Passing Godot-specific types directly to JSON | When using JSON, prepare helper functions to convert Vector2, Color, etc. to arrays. |
| Freezing during save/load | Perform large data reads/writes asynchronously using Thread. |
| Not considering save data versioning | Add 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.
| Feature | ConfigFile | JSON | Custom Resources |
|---|---|---|---|
| Primary Use | Settings files | External integration, static data | Game save data |
| Godot-specific Types | Full support | Not supported (manual conversion needed) | Full support |
| Code Volume | Low | High (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.