Your 2D action game has levels, combat, and polish. Players expect to quit and come back without losing progress. In this lesson you will add a save system in Godot 4: decide what to save (progress, options, score), write it to a file with FileAccess and JSON (or ConfigFile), and load it when the game starts or when the player continues.
By the end you will have at least one save slot that stores level reached, key stats, and optionally settings, and a way to load that data when the game or a level starts.
1. What to Save
Typical data to persist:
- Progress: Current level or scene, completed levels, unlocked content.
- Player state: Health, score, currency, inventory (if applicable).
- Settings: Volume, fullscreen, keybindings (optional; can live in ProjectSettings).
Keep the saved structure small and stable: avoid saving node paths or engine-internal IDs that change between runs. Prefer simple types (strings, numbers, arrays, dictionaries) that you can serialize to JSON or ConfigFile.
2. Where Save Files Live
Godot gives you a user:// path that is writable and persistent (e.g. user://save_game.dat). Use OS.get_user_data_dir() to see the full path, or open files with FileAccess.open("user://save_game.dat", FileAccess.WRITE). Do not save to res:// in shipped games; that is read-only. Use user:// for all runtime saves.
3. Saving with FileAccess and JSON
Step 1: Build a dictionary
Collect whatever you need into a Dictionary (e.g. level_reached, score, player_health, options). Use only types that JSON supports (strings, numbers, bools, arrays, dictionaries).
Step 2: Serialize and write
func save_game() -> void:
var data := {
"level": current_level,
"score": player_score,
"health": player_health
}
var json_string := JSON.stringify(data)
var file := FileAccess.open("user://save_game.dat", FileAccess.WRITE)
if file:
file.store_string(json_string)
file.close()
Use JSON.stringify() to turn the dictionary into a string, then FileAccess.open("user://save_game.dat", FileAccess.WRITE) and store_string(). Always close the file when done.
Step 3: Error handling
If FileAccess.open fails (returns null), the save path may be invalid or the disk full. Show a short message or retry; avoid overwriting good data with nothing.
4. Loading with FileAccess and JSON
Step 1: Open and read
func load_game() -> bool:
if not FileAccess.file_exists("user://save_game.dat"):
return false
var file := FileAccess.open("user://save_game.dat", FileAccess.READ)
if not file:
return false
var json_string := file.get_as_text()
file.close()
Use FileAccess.file_exists("user://save_game.dat") before opening so you do not try to read a missing file. get_as_text() reads the whole file.
Step 2: Parse and apply
var json := JSON.new()
var err := json.parse(json_string)
if err != OK:
return false
var data: Dictionary = json.get_data()
current_level = data.get("level", 1)
player_score = data.get("score", 0)
player_health = data.get("health", 100)
return true
JSON.parse() parses the string; get_data() returns the root (here a Dictionary). Use .get("key", default) so missing keys do not break the game. Return true only if load and parse succeeded.
Step 3: When to load
Call load_game() when the main menu loads (for “Continue”) or when the first game scene starts. If it returns false, start a new game (default level and stats).
5. ConfigFile Alternative
For simple key-value data (e.g. options or a small save), ConfigFile is handy:
var config := ConfigFile.new()
config.set_value("player", "level", current_level)
config.set_value("player", "score", player_score)
config.save("user://save.cfg")
# Load
config.load("user://save.cfg")
current_level = config.get_value("player", "level", 1)
Use ConfigFile when you prefer sections and keys over a raw dictionary; use JSON when you have nested structures or want one blob of data.
6. Game State and Autoload
Many games use an Autoload (singleton) to hold current run state (level, score, health) and to call save_game() / load_game(). The main menu and game scenes then read from that singleton. When the player completes a level or dies, update the singleton and call save_game() so the next launch can load_game() and resume.
Pro tip: Save after big milestones (level complete, boss defeated) and optionally on pause or quit. Do not save every frame.
7. Multiple Save Slots
Use different file names: user://save_slot_1.dat, user://save_slot_2.dat, etc. When the player picks a slot, pass the slot index into save_game(slot) and load_game(slot) and build the path from that. You can store a short preview (e.g. level name, timestamp) in the same file or in a small user://slot_1_preview.dat so the menu can show “Level 3 – 12:34” without loading the full save.
8. Troubleshooting
- Save not persisting: Ensure you use user:// and that save_game() is actually called (e.g. no early return). On some platforms, user:// is cleared on uninstall.
- Load returns wrong data: Check that keys in load_game() match save_game() and that you use .get("key", default) for compatibility with older save formats.
- JSON parse error: Validate that the file is valid JSON (no trailing commas, correct quotes). If you ever wrote non-JSON by mistake, the file may be corrupted; handle parse errors and fall back to “new game.”
Mini-Challenge
Add one save slot: save level index (or scene name), score, and player health when the player completes a level or from a pause menu. On game start, if a save exists, offer “Continue” and load it; otherwise start a new game. Test by saving, closing the game, and continuing.
Recap and Next Step
You used user://, FileAccess, and JSON (or ConfigFile) to save and load progress and optional settings. Your game now persists between sessions so players can quit and continue later.
In Lesson 12 you will focus on Performance Optimization: hitting a stable frame rate, reducing draw calls and script cost, and profiling so the game runs smoothly on your target platforms.
For more on persistence, see our Game Save Systems post and the Godot docs on FileAccess. Found this useful? Bookmark the course and share your build with the community.