Learn Godot 4 by Making a 2D Platformer — Part 20: Saving & Loading Levels

christine - Aug 8 '23 - - Dev Community

*You can find the links to the previous parts at the bottom of this tutorial.

Our game is almost complete, but no game can be complete without a saving and loading system. In a previous tutorial series that I did, I made a saving and loading system that saved the game object’s states to an external JSON file. This way works if you have to save your game’s states — such as your player’s position, enemy spawn counts, and inventory values. Since we will reload the level completely, thus resetting our player’s health and score, and position, we will do it in a much simpler manner without having to parse to JSON (which is the parse in the arse!).


WHAT YOU WILL LEARN IN THIS PART:

  • How to create and manage ConfigFile objects.
  • How to save/load games.
  • How to error handle.

We will make use of our Global script to handle our saving and loading functions. Our save game function will create a new ConfigFile object, which stores variables on the filesystem using INI-style formatting. The stored values are identified by a section and a key.

A ConfigFile output looks like this:

    [section]
    variable=value
Enter fullscreen mode Exit fullscreen mode

We will use the ConfigFile object instead of the FileAccess object because we can save and retrieve data directly without accessing the filesystem.

Saving With FileAccess:

    var data = {
    "health": $Player.health,
    "stamina" : $Player.stamina
    }
    var json = JSON.new()
    var to_json = json.stringify(data)
    var file = FileAccess.open(save_path, FileAccess.WRITE)
    file.store_line(to_json)
    file.close()

    #### result:
    {"health": 100, "stamina": 80}
Enter fullscreen mode Exit fullscreen mode

Saving With ConfigFile:

    var save_file = ConfigFile.new()
    save_file.set_value("player", "health", "defaultvalue")
    save_file.set_value("player", "stamina", "defaultvalue")
    save_file.save(SAVE_PATH)

    ### result:
    [player]
    health="100"
    stamina="100"
Enter fullscreen mode Exit fullscreen mode

To load our game, we will simply get the value from our save file.

Loading With FileAccess:

    var file = FileAccess.open(save_path, FileAccess.READ)
    var data = JSON.parse_string(file.get_as_text())
    file.close()
    $Player.data_to_load(data.player)
Enter fullscreen mode Exit fullscreen mode

Loading with ConfigFile:

    var load_value = save_file.get_value("player", "health", "defaultvalue")
    var new_valye = load(load_value)
Enter fullscreen mode Exit fullscreen mode

As you can see from the code snippets above, you can see that the ConfigFile makes it a bit quicker to get and set the values that we want to save. But it comes with its issues — which you will see in a minute.

STEP 1: SAVING THE GAME

Figure 12: Save System Overview

Figure 12: Save System Overview

Before we create a function to save our game, we need to define the path that it will save to. By default, all of our AppData for Godot gets saved under our “C:\Users\name\AppData\Roaming\Godot\app_userdata\project name” directory. You will need to enable “Hidden Items” to be able to see some of these folders.

Godot 2D Platformer

You will see that there is a default config file and a logs folder in your directory. The logs contain every result that we printed to the output console for your current day.

Godot 2D Platformer

In your Global script, let’s define a new path that our game will save to. This file will override itself if it already exists.

    ### Global.gd
    #older code

    #path to save the game
    const SAVE_PATH = "user://savegame.save"
Enter fullscreen mode Exit fullscreen mode

After you’ve saved your game in your save function (which we still need to create), your file will show up in your app directory.

Godot 2D Platformer

Now, let’s create a new function that will save our file. To save our file, we need to create a new ConfigFile object using the .new() method.

    ### Global.gd

    #older code

    # Function to save the game
    func save_game():
        var save_file = ConfigFile.new()
Enter fullscreen mode Exit fullscreen mode

After our ConfigFile has been created, we need to set the values that we want to save. We set our values via the set_value() method. This assigns a value to the specified key of the specified section. If either the section or the key does not exist, they are created. In our case, we only want to set our current_level value. This will store the value of our current level in which the game has been saved, such as “res://Scenes/Main.tscn” or “res://Scenes/Main_2.tscn”.

    ### Global.gd

    #older code

    # Function to save the game
    func save_game():
        var save_file = ConfigFile.new()
        # Save the current level
        save_file.set_value("level", "current_level", "res://Scenes/" + Global.current_scene_name + ".tscn")
Enter fullscreen mode Exit fullscreen mode

You can store more than one value — just for your curiosity — but we just need one value!

    ### Global.gd

    #older code

    #example of multiple values - don't code this!
    save_file.set_value("level", "current_level", Global.current_scene_name + ".tscn")
    save_file.set_value("level", "level_number", get_current_level_number())
Enter fullscreen mode Exit fullscreen mode

With our ConfigFile created and our value set, we need to go ahead and save this file — using the save() method. This saves the contents of the ConfigFile object to the file specified as a parameter. The output file uses an INI-style structure. We will save our data to our SAVE_PATH defined above.

    ### Global.gd

    #older code

    # Function to save the game
    func save_game():
        var save_file = ConfigFile.new()
        # Save the current level
        save_file.set_value("level", "current_level", "res://Scenes/" + Global.current_scene_name + ".tscn")
        # Save the file
        var err = save_file.save(SAVE_PATH)
Enter fullscreen mode Exit fullscreen mode

For good practice, we also need to add some error handling for the scenario where our game fails to save. This will notify us of this error so that we can handle it appropriately. If there is no error — it will print that our game is saved.

    ### Global.gd

    #older code

    # Function to save the game
    func save_game():
        var save_file = ConfigFile.new()
        # Save the current level
        save_file.set_value("level", "current_level", "res://Scenes/" + Global.current_scene_name + ".tscn")
        # Save the file
        var err = save_file.save(SAVE_PATH)
        # Err handling
        if err != OK:
            print("An error occurred while saving the game")
        else:
            print("Saving game.")
Enter fullscreen mode Exit fullscreen mode

And that is our save function! Now all we have to do is call it in our Player script when we press our save button.

    ### Player.gd

    #older code

    #save game
    func _on_button_save_pressed():
        Global.save_game()
Enter fullscreen mode Exit fullscreen mode

Now if you run your scene and you press “Save Game” in your pause menu, your output console should notify you of the result and your save_file should be generated. You can save it in either one of your levels, and your current_scene value should update.

Godot 2D Platformer

Godot 2D Platformer

You should now be able to open your save file in Notepad or any other editor, and the result should look similar to this:

    [level]

    current_level="res://Scenes/Main.tscn"
Enter fullscreen mode Exit fullscreen mode

If you save in your second level, the output should be:

    [level]
    current_level="res://Scenes/Main_2.tscn"
Enter fullscreen mode Exit fullscreen mode

STEP 2: LOADING THE GAME

Figure 13: Load System Overview

Figure 13: Load System Overview

Now to load the game based on our level, the code will be a little more involved. In our load function, we will load a previously saved game from our ConfigFile. We’ll do this by reading the value of the saved level, and then loading that level as a new scene. So if the current_level=”res://Scenes/Main_2.tscn”, we will load our second level, and so forth.

Let’s create a new function that — just like in our save function — will create a new ConfigFile object. You have to do this so that our function can access the operations and methods of our config object.

    ### Global.gd

    #older code

    # Function to load the game
    func load_game():
        var save_file = ConfigFile.new()
Enter fullscreen mode Exit fullscreen mode

For our load function, we will do our error handling first by attempting to load the saved game data from the file specified by the constant SAVE_PATH. If the file is successfully loaded, our loading state will be equal to OK and our game will be loaded, and if not, we will return an error message.

    ### Global.gd

    #older code

    # Function to load the game
    func load_game():
        var save_file = ConfigFile.new()
        var err = save_file.load(SAVE_PATH)

        # Check if the save file exists and is successfully loaded
        if err == OK:
            #notify
            print("Loading game.")
        else:
            print("An error occurred while loading the game")
Enter fullscreen mode Exit fullscreen mode

If our file is loaded successfully (err == OK), then we’ll need to get the values from our save file via our get_value() method. This returns the current value for the specified section and key. For this, we’ll need to pass three parameters: the section name (“level”), the value name (“current_level”), and a default value that will be loaded if the current_level cannot be found (“res://Scenes/Main.tscn”). Don’t worry, the last parameter will update if the value is found!

    ### Global.gd

    #older code

    # Function to load the game
    func load_game():
        var save_file = ConfigFile.new()
        var err = save_file.load(SAVE_PATH)

        # Check if the save file exists and is successfully loaded
        if err == OK:
            # Get the full path to the current level from the save file
            var saved_level = save_file.get_value("level", "current_level", "res://Scenes/Main.tscn")
            #notify
            print("Loading game.")
        else:
            print("An error occurred while loading the game")
Enter fullscreen mode Exit fullscreen mode

Now, from the value loaded, we’ll need to create a new scene from it. So if our value is “res://Scenes/Main_2.tscn”, we’ll need to instance that scene and load it as a resource.

    ### Global.gd

    #older code

    # Function to load the game
    func load_game():
        var save_file = ConfigFile.new()
        var err = save_file.load(SAVE_PATH)

        # Check if the save file exists and is successfully loaded
        if err == OK:
            # Get the full path to the current level from the save file
            var saved_level = save_file.get_value("level", "current_level", "res://Scenes/Main.tscn")
            # Load the saved scene
            var new_scene_resource = load(saved_level)
            #notify
            print("Loading game.")
        else:
            print("An error occurred while loading the game")
Enter fullscreen mode Exit fullscreen mode

We will then need to instance the loaded resource into a node, which creates an actual scene that can be added to the scene tree.

    ### Global.gd

    #older code

    # Function to load the game
    func load_game():
        var save_file = ConfigFile.new()
        var err = save_file.load(SAVE_PATH)

        # Check if the save file exists and is successfully loaded
        if err == OK:
            # Get the full path to the current level from the save file
            var saved_level = save_file.get_value("level", "current_level", "res://Scenes/Main.tscn")
            # Load the saved scene
            var new_scene_resource = load(saved_level)
            # Instance the new resource
            var new_scene = new_scene_resource.instantiate()
            #notify
            print("Loading game.")
        else:
            print("An error occurred while loading the game")
Enter fullscreen mode Exit fullscreen mode

Then, we’ll need to add the newly instantiated scene as a child of the root node in the scene tree.

    ### Global.gd

    #older code

    # Function to load the game
    func load_game():
        var save_file = ConfigFile.new()
        var err = save_file.load(SAVE_PATH)

        # Check if the save file exists and is successfully loaded
        if err == OK:
            # Get the full path to the current level from the save file
            var saved_level = save_file.get_value("level", "current_level", "res://Scenes/Main.tscn")
            # Load the saved scene
            var new_scene_resource = load(saved_level)
            # Instance the new resource
            var new_scene = new_scene_resource.instantiate()
            # Add it to the root of the scene tree
            get_tree().get_root().add_child(new_scene)
            #notify
            print("Loading game.")
        else:
            print("An error occurred while loading the game")
Enter fullscreen mode Exit fullscreen mode

We don’t want this scene to be the child of any other scene, since we want it to be the new scene, so we’ll set the newly instantiated scene as the current scene.

    ### Global.gd

    #older code

    # Function to load the game
    func load_game():
        var save_file = ConfigFile.new()
        var err = save_file.load(SAVE_PATH)

        # Check if the save file exists and is successfully loaded
        if err == OK:
            # Get the full path to the current level from the save file
            var saved_level = save_file.get_value("level", "current_level", "res://Scenes/Main.tscn")
            # Load the saved scene
            var new_scene_resource = load(saved_level)
            # Instance the new resource
            var new_scene = new_scene_resource.instantiate()
            # Add it to the root of the scene tree
            get_tree().get_root().add_child(new_scene)
            # Set it as the current scene
            get_tree().current_scene = new_scene
            #notify
            print("Loading game.")
        else:
            print("An error occurred while loading the game")
Enter fullscreen mode Exit fullscreen mode

If we were to load our game from this alone, we will get an error because our game gets loaded as a resource, which will return names such as Main@2 instead of “Main”. This means that our current_scene_name will be equal to “Main@2”, and thus our BombSpawner won’t be able to find the path needed for its Path2D node (since Main@2/BombPath/Path2D/.. does not exist).

Therefore, we will need to create a function that will update our current_scene_name to a clean string without the @ values. We will do this by using the substr() method to return part of the string from the position with length len. If len is -1 (as by default), returns the rest of the string starting from the given position.

    ### Global.gd

    #older code

    #removes the @x@n from our name
    func clean_scene_name(scene_name):
        var at_position = scene_name.find("@")
        if at_position != -1:
            # Extract the portion of the string before the "@" character
            return scene_name.substr(0, at_position)
        else:
            # If "@" character is not found, return the original string
            return scene_name
Enter fullscreen mode Exit fullscreen mode

Then, in our ready() function, we will update our current_scene_name value to only contain this raw value returned from the clean_scene_name() function.

    ### Global.gd

    #older code

    func _ready():
        # Sets the current scene's name
        var scene_name = get_tree().get_current_scene().name
        current_scene_name = clean_scene_name(scene_name)
Enter fullscreen mode Exit fullscreen mode

Now in our load function, we can call this function to also update our current_scene_name whenever our game is loaded.

    ### Global.gd

    #older code

    # Function to load the game
    func load_game():
        var save_file = ConfigFile.new()
        var err = save_file.load(SAVE_PATH)

        # Check if the save file exists and is successfully loaded
        if err == OK:
            # Get the full path to the current level from the save file
            var saved_level = save_file.get_value("level", "current_level", "res://Scenes/Main.tscn")
            # Load and instantiate the saved scene
            var new_scene_resource = load(saved_level)
            var new_scene = new_scene_resource.instantiate()
            # Add it to the root of the scene tree
            get_tree().get_root().add_child(new_scene)
            # Set it as the current scene
            get_tree().current_scene = new_scene
            #update current scene name
            current_scene_name = get_tree().get_current_scene().name
            clean_scene_name(current_scene_name)
            #notify
            print("Loading game.")
        else:
            print("An error occurred while loading the game")
Enter fullscreen mode Exit fullscreen mode

We also need to create a new function in our BombSpawner script so that our paths for our “bomb_anim” and “bomb_path” are updated to read the new value of our current_scene_name. We will need to do this since it doesn’t re-read the scene name after it has been loaded, so it will always try and find the path for the previous current scene. We’ll also add some error handling to update our paths if the value of our paths is null — which will be true if it’s trying to read from Main@2/BombPath/Path2D/ instead of Main/BombPath/Path2D/. We will check these paths via the get_node_or_null() method, which is similar to get_node, but does not log an error if the path does not point to a valid Node.

    ### BombSpawner.gd

    #older code

    func _ready():
        #default animation on load
        $AnimatedSprite2D.play("cannon_idle")
        #initiates paths
        update_paths()
        #starts bomb movement
        if bomb_animation != null:
            bomb_animation.play("bomb_movement")
        else:
            print("bomb_animation is null")

    # Update paths
    func update_paths():
        current_scene_path = "/root/" + Global.current_scene_name + "/" 
        bomb_path = get_node_or_null(current_scene_path + "BombPath/Path2D/PathFollow2D") 
        bomb_animation = get_node_or_null(current_scene_path + "BombPath/Path2D/AnimationPlayer") 

    # shoot() remains the same

    #shoot and spawn bomb onto path
    func _on_timer_timeout():
        #reset animation before shooting
        $AnimatedSprite2D.play("cannon_idle")

        # Update paths before shooting
        if bomb_path == null || bomb_animation == null:
            update_paths()

        #spawns a bomb onto our path if there are no bombs available
        if bomb_path != null and bomb_path.get_child_count() == 0:
            bomb_path.add_child(shoot())

        # Clear all existing bombs
        if  bomb_path != null:
            if Global.is_bomb_moving == false:
                for bombs in bomb_path.get_children():
                    bomb_path.remove_child(bombs)
                    bombs.queue_free()
                    bomb_animation.stop()
Enter fullscreen mode Exit fullscreen mode

In our MainMenu script, we need to first free our current scene (which is our MainScene if it is currently open) before loading our new scene. In game development, managing memory efficiently is crucial. When you load a new scene without freeing the old scene, the resources used by the old scene remain in the application’s memory. Over time, as more scenes are loaded without the previous ones being freed, this can lead to a significant amount of memory being consumed, which might result in performance issues or even crashes due to a lack of available memory.

    ### MainMenu.gd

    #older code

    #load game
    func _on_button_load_pressed():
        # Get the current scene (MainMenu in this case)
        var current_scene = get_tree().current_scene
        # Free the current scene if it exists
        if current_scene:
            current_scene.queue_free()
Enter fullscreen mode Exit fullscreen mode

After we’ve freed the current_scene, we need to load our new scene. We can do this by simply calling our load function from our Global script. Also, unpause the game to ensure that the game isn’t loaded in a paused state.

    ### MainMenu.gd

    #older code

    #load game
    func _on_button_load_pressed():
        # Get the current scene (MainMenu in this case)
        var current_scene = get_tree().current_scene
        # Free the current scene if it exists
        if current_scene:
            current_scene.queue_free()
        #load game
        Global.load_game()
        #unpause scene
        get_tree().paused = false   
Enter fullscreen mode Exit fullscreen mode

We can do the same in our Player script’s _on_button_load_pressed() function.

    ### Player.gd

    #older code

    #load game
    func _on_button_load_pressed():
        # Get the current scene (Main or Main_2 in this case)
        var current_scene = get_tree().root.get_tree().current_scene
        # Free the current scene if it exists
        if current_scene:
            current_scene.queue_free()
        #load game
        Global.load_game()
        #unpause game
        get_tree().paused = false
Enter fullscreen mode Exit fullscreen mode

We also need to update our get_current_level_number() function in our Global script to also assign level “1” to our MainMenu scene, since we’ll be loading into it first.

    ### Global.gd

    #older code

    # Current level based on scene name
    func get_current_level_number():
        if current_scene_name == "Main" || current_scene_name == "MainMenu":
            return 1
        elif current_scene_name.begins_with("Main_"):
            # Extract the number after "Main_"
            var level_number = current_scene_name.get_slice("_", 1).to_int()
            return level_number
        else:
            return -1 # Indicate an unknown scene
Enter fullscreen mode Exit fullscreen mode

Your code should look like this.

Let’s change our Main Scene to our “MainMenu” scene instead of our “Main” scene. This will cause our MainMenu scene to be the first scene to load up when we run our game.

Godot 2D Platformer

Now if you run your scene, you should be able to start a new game, and then save and load your game from your Level scene. If you quit your game from your PauseMenu, you should be able to load your last saved scene from your MainMenu screen!

Godot 2D Platformer

Godot 2D Platformer

Congratulations on adding a saving & loading function to your game! The only remaining things that we still need to do for our game are adding a tutorial screen, and music & particle effects! Now would be a good time to save your project and make a backup of your project so that you can revert to this part if any game-breaking errors occur. Go back and revise what you’ve learned before you continue with the series, and once you’re ready, I’ll see you in the next part!


Next Part to the Tutorial Series

The tutorial series has 24 chapters. I’ll be posting all of the chapters in sectional daily parts over the next couple of weeks. You can find the updated list of the tutorial links for all 24 parts of this series on my GitBook. If you don’t see a link added to a part yet, then that means that it hasn’t been posted yet. Also, if there are any future updates to the series, my GitBook would be the place where you can keep up-to-date with everything!

Godot 2D Platformer


Support the Series & Gain Early Access!

If you like this series and would like to support me, you could donate any amount to my KoFi shop or you could purchase the offline PDF that has the entire series in one on-the-go booklet!

The booklet gives you lifelong access to the full, offline version of the “Learn Godot 4 by Making a 2D Platformer” PDF booklet. This is a 451-page document that contains all the tutorials of this series in a sequenced format, plus you get dedicated help from me if you ever get stuck or need advice. This means you don’t have to wait for me to release the next part of the tutorial series on Dev.to or Medium. You can just move on and continue the tutorial at your own pace — anytime and anywhere!

Godot 2D Platformer

This book will be updated continuously to fix newly discovered bugs, or to fix compatibility issues with newer versions of Godot 4.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .