Every game needs a save system. Whether you're saving player progress, settings, or entire world states, understanding how to persist data is essential. This guide covers everything from basic file I/O to building a complete SaveManager autoload.
Understanding FileAccess in Godot 4
Godot 4's FileAccess class handles all file read/write operations. It's the foundation of any save system and replaces Godot 3's File class.
# Writing to a file
var file = FileAccess.open("user://save.txt", FileAccess.WRITE)
if file:
file.store_string("Hello, World!")
file.close()
else:
print("Error: ", FileAccess.get_open_error())
# Reading from a file
if FileAccess.file_exists("user://save.txt"):
var file = FileAccess.open("user://save.txt", FileAccess.READ)
var content = file.get_as_text()
print(content)
file.close()JSON Serialization
JSON is the most common format for save files because it's human-readable and easy to debug. Godot has built-in JSON support.
func save_game():
var save_data = {
"player": {
"position": {"x": player.position.x, "y": player.position.y},
"health": player.health,
"coins": player.coins,
"inventory": player.inventory
},
"level": current_level,
"playtime": playtime_seconds,
"save_date": Time.get_datetime_string_from_system()
}
var file = FileAccess.open("user://savegame.json", FileAccess.WRITE)
file.store_string(JSON.stringify(save_data, "\t")) # Pretty print
file.close()
print("Game saved!")func load_game() -> bool:
if not FileAccess.file_exists("user://savegame.json"):
print("No save file found")
return false
var file = FileAccess.open("user://savegame.json", FileAccess.READ)
var json = JSON.new()
var error = json.parse(file.get_as_text())
file.close()
if error != OK:
print("JSON parse error: ", json.get_error_message())
return false
var data = json.data
# Restore player state
player.position = Vector2(data.player.position.x, data.player.position.y)
player.health = data.player.health
player.coins = data.player.coins
player.inventory = data.player.inventory
current_level = data.level
print("Game loaded!")
return truePlayerPrefs.SetInt("health", 100);
PlayerPrefs.SetFloat("posX", transform.position.x);
PlayerPrefs.Save();var data = {"health": 100, "pos_x": position.x}
var file = FileAccess.open("user://save.json", FileAccess.WRITE)
file.store_string(JSON.stringify(data))Building a SaveManager Autoload
For a real game, you'll want a centralized SaveManager that any script can access. Create this as an autoload (singleton).
# save_manager.gd - Add as Autoload in Project Settings
extends Node
const SAVE_PATH = "user://savegame.json"
signal game_saved
signal game_loaded
var game_data = {
"player": {},
"world": {},
"settings": {}
}
func save_game() -> bool:
emit_signal("game_saved") # Let nodes update their data
var file = FileAccess.open(SAVE_PATH, FileAccess.WRITE)
if not file:
push_error("Could not open save file")
return false
file.store_string(JSON.stringify(game_data, "\t"))
file.close()
return true
func load_game() -> bool:
if not FileAccess.file_exists(SAVE_PATH):
return false
var file = FileAccess.open(SAVE_PATH, FileAccess.READ)
var json = JSON.new()
var error = json.parse(file.get_as_text())
file.close()
if error != OK:
push_error("Failed to parse save file")
return false
game_data = json.data
emit_signal("game_loaded")
return true
func has_save() -> bool:
return FileAccess.file_exists(SAVE_PATH)
func delete_save() -> void:
if has_save():
DirAccess.remove_absolute(ProjectSettings.globalize_path(SAVE_PATH))Using the SaveManager
# In your Player script
extends CharacterBody2D
func _ready():
SaveManager.game_saved.connect(_on_save)
SaveManager.game_loaded.connect(_on_load)
func _on_save():
SaveManager.game_data.player = {
"position": {"x": position.x, "y": position.y},
"health": health,
"inventory": inventory
}
func _on_load():
var data = SaveManager.game_data.player
position = Vector2(data.position.x, data.position.y)
health = data.health
inventory = data.inventory
# Call from pause menu
func _on_save_button_pressed():
SaveManager.save_game()Advanced: Multiple Save Slots
const SAVE_DIR = "user://saves/"
const MAX_SLOTS = 3
func get_save_path(slot: int) -> String:
return SAVE_DIR + "slot_" + str(slot) + ".json"
func save_to_slot(slot: int) -> bool:
# Ensure directory exists
DirAccess.make_dir_recursive_absolute(
ProjectSettings.globalize_path(SAVE_DIR)
)
var file = FileAccess.open(get_save_path(slot), FileAccess.WRITE)
file.store_string(JSON.stringify(game_data, "\t"))
file.close()
return true
func get_all_saves() -> Array:
var saves = []
for i in range(MAX_SLOTS):
var path = get_save_path(i)
if FileAccess.file_exists(path):
var file = FileAccess.open(path, FileAccess.READ)
var data = JSON.parse_string(file.get_as_text())
saves.append({"slot": i, "data": data})
file.close()
return savesAuto-Save Implementation
var auto_save_timer: Timer
func _ready():
auto_save_timer = Timer.new()
auto_save_timer.wait_time = 60.0 # Save every 60 seconds
auto_save_timer.timeout.connect(_on_auto_save)
add_child(auto_save_timer)
auto_save_timer.start()
func _on_auto_save():
save_game()
print("Auto-saved at ", Time.get_time_string_from_system())
# Also save when the game is about to close
func _notification(what):
if what == NOTIFICATION_WM_CLOSE_REQUEST:
save_game()
get_tree().quit()Best Practices
- Always validate loaded data—never trust save files blindly
- Use version numbers to handle save format migrations
- Consider auto-save at key moments (level complete, checkpoint)
- Provide visual feedback when saving (icon, toast notification)
- For security-sensitive data, use FileAccess.open_encrypted_with_pass()
Next Steps
You now have everything you need to implement a professional save system. Start simple with JSON, add auto-save for convenience, and expand to multiple slots as your game grows. Check out our Save & Load Guide in the Learning Hub for interactive examples.
