Building a Save System in Godot 4: Complete Guide

Learn how to build a robust save system in Godot 4 using FileAccess and JSON. Covers auto-save, multiple slots, and best practices for game persistence.

Godot 4 save system tutorial showing JSON file and code

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.

gdscript
# 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.

gdscript
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!")
gdscript
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 true
Unity (PlayerPrefs)
PlayerPrefs.SetInt("health", 100);
PlayerPrefs.SetFloat("posX", transform.position.x);
PlayerPrefs.Save();
Godot (FileAccess)
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).

gdscript
# 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

gdscript
# 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

gdscript
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 saves

Auto-Save Implementation

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

👤
Godot Learning Team Helping developers transition to Godot with practical tutorials and comparisons.