Basic Game Save and loading in Godot 4

Basic Game Save and loading in Godot 4 cover image

When developing a game, one of the key features players expect is the ability to save and load their progress. A well-designed save system ensures that players can pick up right where they left off, enhances replayability, and helps maintain player satisfaction. In Godot 4, handling game saves is straightforward and flexible, leveraging Godot’s built-in file APIs and the rich GDScript language. In this post, we’ll walk through the fundamental concepts of creating, writing, and loading save files, as well as some best practices to keep your save system robust.

GitHub Project Repository

Why Implement a Custom Save System?

While Godot doesn’t provide a built-in save system out-of-the-box, its file handling tools make it simple to roll your own solution. By creating a custom system, you can:

  • Define your own data formats:

    Decide exactly what game data should be persisted (player position, inventory items, level progress, etc.).

  • Maintain flexibility

    Expand and update your save data schema easily as your game grows.

  • Control versioning

    Handle changes to your save data between game updates with custom logic.

What to Save?

Before you start coding, it’s crucial to determine what needs saving. Typical information might include:

  • Player state

    Current position, health, experience points, inventory.

  • World State

    NPC positions, quest progression, environment, items

  • Settings

    Options like sound volume, graphics quality, or control bindings.

Keep your save data focused and organized. Overloading your save file with unnecessary details will increase complexity and risk bugs. Start small and expand as needed.

Choosing a Data Format

Godot’s File API lets you read and write data in several formats, such as binary, JSON, or Godot’s built-in var serialization. Two common approaches are:

  • JSON Files

    Human-readable and easy to debug. Perfect for small-to-medium projects or when you need human readability.

  • Binary/Custom Format

    More compact and potentially faster, but less transparent and harder to debug.

Setting Up a Binary Save System

For my game I chose Binary Save System. To get started We’ll create a singleton (autoload) to manage all saving and loading. This centralizes code, making it easier to maintain and call from anywhere in your project.

Creating the Singleton

Create the Script: Create a SaveSystem.gd script in your res:// directory.

Autoload It: In Project > Project Settings > Globals, add SaveSystem.gd, give it a name like SaveSystem, and click “Add”. Now you can access it from all scripts via SaveSystem.

Example Save System Script

Here’s an example using binary operations. We’ll maintain a current_data dictionary as the in-memory representation of our game state. Our binary file will store this dictionary using store_var() and get_var():

# SaveSystem.gd
extends Node

var save_path = "user://save_game.bin"
var current_data = {}

func _ready():
    # Load the save if it exists, otherwise initialize defaults
    if FileAccess.file_exists(save_path):
        load_game()
    else:
        current_data = {
            "player": {
                "position": Vector2(0, 0),
                "health": 100,
                "inventory": []
            },
            "world": {
                "level": 1,
                "doors_opened": []
            }
        }

func save_game():
    var file = FileAccess.open(save_path, FileAccess.WRITE)
    if file:
        # store_var() serializes any Variant (dictionary, array, primitive, etc.)
        file.store_var(current_data)
        file.close()

func load_game():
    if not FileAccess.file_exists(save_path):
        return # No file, use defaults or do nothing

    var file = FileAccess.open(save_path, FileAccess.READ)
    if file:
        # get_var() reconstructs the Variant from the binary data
        var data = file.get_var()
        file.close()

        if typeof(data) == TYPE_DICTIONARY:
            current_data = data
        else:
            push_warning("Loaded data is not a dictionary! Resetting to defaults.")
            _reset_defaults()

func _reset_defaults():
    current_data = {
        "player": {
            "position": Vector2(0, 0),
            "health": 100,
            "inventory": []
        },
        "world": {
            "level": 1,
            "doors_opened": []
        }
    }

In this setup:

  • current_data: Holds the current in-memory game state.
  • save_game(): Writes current_data directly as binary data using store_var().
  • load_game(): Reads the binary data using get_var() and updates current_data.

Updating and Saving Player Data

As the player moves through the world or changes state, you’ll need to update current_data accordingly. For example, when the player reaches a checkpoint, you might update their position and inventory, then save:

#### In Player.gd or another relevant script
func save_player_state():
    if SaveSystem.current_data.has("player"):
        SaveSystem.current_data["player"].position = global_position
        SaveSystem.current_data["player"].health = health
        SaveSystem.current_data["player"].inventory = inventory
        SaveSystem.save_game()

This ensures that the file on disk always reflects the current state of the player’s progress.

Loading Saved Data Back Into the Game

When starting the game or entering a scene, you need to apply the saved data to your game objects

func load_player_state():
    var data = SaveSystem.current_data.get("player", null)
    if data:
        global_position = data.position
        health = data.health
        inventory = data.inventory

Best Practices and Tips

  • Centralize Logic:

    Keep saving and loading logic in a single place (like our SaveSystem) to avoid confusion.
  • Test Frequently:

    After each gameplay change that affects saved data, test the entire save/load cycle.
  • Graceful Handling of Missing or Corrupted Files:

    If loading fails, revert to default values and continue.
  • Backup Saves:

    Before overwriting a file, consider making a backup copy so you can roll back if something fails.

Conclusion

Switching to a binary format for your Godot 4 saves can streamline your save/load process, improve performance, and give you more control over your data format. While not as human-readable as JSON, binary data is easy to handle via store_var() and get_var(), and it provides a convenient solution for many games.

Whether you’re tackling a small indie title or a larger, more complex project, mastering binary-based game saves will serve as a solid foundation for a robust player experience. With the right planning and testing, your players can confidently dive into your world, knowing their progress is both safe and efficiently managed.