DEV Community

Cover image for Let’s Learn Godot 4 by Making an RPG — Part 20: Persistent Saving & Loading System🤠
christine
christine

Posted on • Updated on

Let’s Learn Godot 4 by Making an RPG — Part 20: Persistent Saving & Loading System🤠

In this part, we’ll be adding the ability for our players to save and load their game. When we save the game, we need to store all the necessary variables from all of our different scripts to save the current state of those variables.


WHAT YOU WILL LEARN IN THIS PART:
· How to create persistent saving and loading systems
· How to parse JSON files.
· How to read and write files using the FileAccess object.
· How to save/load game variables.


We’ll save these variables in a dictionary, which will store our values as keys. The syntax of Dictionaries is similar to JSON, which is beneficial to us since we will be saving our save_file into a JSON format. JSON is an open standard file format and data interchange format that uses human-readable text to store and transmit data objects consisting of attribute–value pairs and arrays. This is useful for serializing data to save to a file or send over the network.

Our save file will follow the following format:



    save_dictionary = {
      "variable name": variable reference,
      "player_health": health
    }


Enter fullscreen mode Exit fullscreen mode

Save system overview

Save system overview

To load the game from our save file that we’ll create, we’ll have to convert the JSON file back into dictionary format. We’ll do this via a function that will load the data from the JSON file.

Our load function will follow the following format:



    func load_save_file(data):
      variable name = data.variable reference
      player_health = data.health


Enter fullscreen mode Exit fullscreen mode

Load System Overview

Load system overview

Let’s get started with our Saving functionality!

SAVING THE GAME

In our Global script, let’s create a new variable that will hold the save path of our JSON save file. We will set our path to be under “user://dusty_trails_save.json”.

On a Windows machine, this save file will be found under %APPDATA%\Roaming\Godot\app_userdata\Dusty-Trails.



    ### Global.gd

    # older code

    # Saving & Loading
    var save_path = "user://dusty_trails_save.json"


Enter fullscreen mode Exit fullscreen mode

To save our game, we need to go and create a dictionary in each script to store our values. We’ll then store all these dictionaries in another dictionary in our Main scene which will compile all the separate dictionaries into a singular structure which will then be stored in our JSON file.

We want to save the following values from the following scripts:

  • Player -> position, health, pickups, stamina, xp, and level

  • Enemy -> position, health

  • EnemySpawner -> spawned enemies

  • NPC -> position, quest status, quest completion state

At the end of each of these scripts, let’s create these dictionaries to store these values. We’ll store the enemies in our EnemySpawner as an array so that when we load our enemies our spawner knows how many to continue counting from to add/remove enemy counts. We’ll also append the enemy data from the Enemy scripts data_to_save() function in the EnemySpawner’s save function. Therefore, our spawner saves our enemy count plus each enemy’s health and position values.



    ### Player.gd

    #older code

    # -------------------------------- Saving & Loading -----------------------
    #data to save
    func data_to_save():
        return {
            "position" : [position.x, position.y],
            "health" : health,
            "max_health" : max_health,
            "stamina" : stamina,
            "max_stamina" : max_stamina,
            "xp" : xp,
            "xp_requirements" : xp_requirements,
            "level" : level,
            "ammo_pickup" : ammo_pickup,
            "health_pickup" : health_pickup,
            "stamina_pickup" : stamina_pickup
        }


Enter fullscreen mode Exit fullscreen mode



    ### NPC.gd

    #older code

    # -------------------------------- Saving & Loading -----------------------
    #data to save
    func data_to_save():
        return {
            "position" : [position.x, position.y],
            "quest_status": quest_status,
            "quest_complete": quest_complete
        }


Enter fullscreen mode Exit fullscreen mode


    ### Enemy.gd

    #older code

    # -------------------------------- Saving & Loading -----------------------
    #data to save
    func data_to_save():
        return {
            "position" : [position.x, position.y],
            "health" : health,
            "max_health" : max_health
        }


Enter fullscreen mode Exit fullscreen mode


    ### EnemySpawner.gd

    #older code

    # -------------------------------- Saving & Loading -----------------------
    #data to save
    func data_to_save():
        var enemies = []
        for enemy in spawned_enemies.get_children():
            #saves enemy amount, plus their stored health & position values
            if enemy.name.find("Enemy") >= 0:
                enemies.append(enemy.data_to_save())
        return enemies


Enter fullscreen mode Exit fullscreen mode

Now in our Global script, we need to create a new function that will save our game. In this function, we’ll need to create a dictionary of items to save, and that will include the dictionaries we added in our Player, EnemySpawner, and NPC scripts. We also only save the data if the nodes are present in the current scene. This means that it will only save NPC data if we have a npc in our scene. If we don’t, it will skip the data for that NPC and save the other valid data fields. In summary, this function saves the game by retrieving the current scene, collecting data from specific nodes within the scene, converting the data to JSON format, and storing it in a file. The data saved includes the scene name, player data, NPC data, and enemy spawner data if they exist in the current scene.

To save a file to this path, we will have to use the FileAccess object. We’ll first need to convert our “data” dictionary to a JSON string. We can do this by using the stringify method, which converts the data to a JSON-formatted string.

Then we will use our .open method from our FileAccess object to open our save_path file. Opening this file allows us to read or write to it. In this instance, we will use the FileAccess.WRITE method to specify that the file should be opened in write mode.

After it has been opened, we will write to this file using the store_line function that writes a string to the file and adds a new line character at the end.

After all that’s been done, we need to close the file. This is important to ensure that all data is written and resources are released.



    # ------------------------ Saving & Loading --------------------------
    # save game 
    func save():
        var current_scene = get_tree().get_current_scene()
        if current_scene != null:
            current_scene_name = current_scene.name
            # data to save
            var data = {
                "scene_name" : current_scene_name,
            }
            #check if nodes exist before saving
            if current_scene.has_node("Player"):
                var player = get_tree().get_root().get_node("%s/Player" % current_scene_name)
                print("Player exists: ", player != null)
                data["player"] = player.data_to_save()   
            if current_scene.has_node("SpawnedNPC/NPC"):
                var npc = get_tree().get_root().get_node("%s/SpawnedNPC/NPC" % current_scene_name)
                print("NPC exists: ", npc != null)
                data["npc"] = npc.data_to_save()
            if current_scene.has_node("EnemySpawner"):
                var enemy_spawner = get_tree().get_root().get_node("%s/EnemySpawner" % current_scene_name)
                print("EnemySpawner exists: ", enemy_spawner != null)
                data["enemies"] = enemy_spawner.data_to_save()     
            # converts dictionary (data) into json
            var json = JSON.new()
            var to_json = json.stringify(data)
            # opens save file for writing
            var file = FileAccess.open(save_path, FileAccess.WRITE)
            # writes to save file
            file.store_line(to_json)
            # close the file
            file.close()
        else:
            print("No active scene. Cannot save.")


Enter fullscreen mode Exit fullscreen mode

Now we will save our game when we change scenes. This will prevent our game from returning NULL values for our scene paths. It also helps us carry over the last captured player data from the previous scene into the new scene.



    ### Global.gd

    # older code

     # Change scene
    func change_scene(scene_path):
        save()
        # Get the current scene
        current_scene_name = scene_path.get_file().get_basename()
        var current_scene = get_tree().get_root().get_child(get_tree().get_root().get_child_count() - 1)
        # Free it for the new scene
        current_scene.queue_free()
        # Change the scene
        var new_scene = load(scene_path).instantiate()
        get_tree().get_root().call_deferred("add_child", new_scene) 
        get_tree().call_deferred("set_current_scene", new_scene)    
        call_deferred("post_scene_change_initialization")

    func post_scene_change_initialization():
        scene_changed.emit()


Enter fullscreen mode Exit fullscreen mode

Now we need to go back to our Player script and call our save function from our Global script if we press our save button.



    ### Player.gd

    # older code

    # save game
    func _on_save_pressed():
        Global.save()


Enter fullscreen mode Exit fullscreen mode

If you now play your game and you save your save file should be created. The save file will look like this if you open it (your values will be different from mine):

Godot RPG



    JSON SAVE FILE DATA:

    {"enemies":[],"player":{"ammo_pickup":13,"health":100,"health_pickup":2,"level":1,"max_health":100,"max_stamina":100,"position":[439,154],"stamina":100,"stamina_pickup":2,"xp":0,"xp_requirements":100},"scene_name":"Main"}


Enter fullscreen mode Exit fullscreen mode

LOADING THE GAME

To load our game, we will have to go to each of our scripts once again and define the values that we want to load from them.

We want to load the following values from the following scripts:

· Player -> position, health, pickups, stamina, xp, and level

· Enemy -> position, health

· EnemySpawner -> spawned enemies

· NPC -> position, quest status, quest completion state

We’ll do this by creating a function in each of our Player, NPC, Enemy, and EnemySpawner scripts. This function will pass the “data” parameter, which will reference the saved values from our “data” dictionary that we created in our Main scene.



    ### Player.gd

    #older code

    #loads data from saved data
    func data_to_load(data):
        position = Vector2(data.position[0], data.position[1])
        health = data.health
        max_health = data.max_health
        stamina = data.stamina
        max_stamina = data.max_stamina
        xp = data.xp
        xp_requirements = data.xp_requirements
        level = data.level
        ammo_pickup = data.ammo_pickup
        health_pickup = data.health_pickup
        stamina_pickup = data.stamina_pickup


Enter fullscreen mode Exit fullscreen mode


    ### EnemySpawner.gd

    #older code

    #load data from save file
    func data_to_load(data):
        enemy_count = data.size()
        for enemy_data in data:
            var enemy = Global.enemy_scene.instantiate()
            enemy.data_to_load(enemy_data)
            add_child(enemy)


Enter fullscreen mode Exit fullscreen mode


    ### NPC.gd

    #older code

    #loads data from save
    func data_to_load(data):
        position = Vector2(data.position[0], data.position[1])
        quest_status = int(data.quest_status)
        quest_complete = data.quest_complete


Enter fullscreen mode Exit fullscreen mode


    ### Enemy.gd

    #older code

    #data to load from save file
    func data_to_load(data):
        position = Vector2(data.position[0], data.position[1])
        health = data.health
        max_health = data.max_health


Enter fullscreen mode Exit fullscreen mode

Now, we want to load our entire game data. In our load function, we will load a saved game state by reading our JSON-formatted save file, loading the corresponding scene, adding it to the scene tree, setting it as the current scene, and loading the saved data into specific nodes within the scene. We’ll only load the data for our respective nodes (such as npc, player, and enemy) if the data is present in our save file.

We can do this via the file_exists method.If the file does exist, we need to open our file and read its content. We can do this by specifying that we want to read the file via the FileAccess.READ method. After we open it, we need to read the entire contents of the file as text using file.get_as_text() and parse it as JSON using JSON.parse_string().

We then need to close our file. After we’ve read the file, we need to load our data from our player, enemy spawner, and npc from the parsed JSON. If the quest state is set to complete, we also need to remove the quest item from the scene. You can also do the same for your pickups, but I want our pickups to respawn on load.



    ### Global.gd

    #older code

    # Saving & Loading
    var save_path = "user://dusty_trails_save.json"
    var loading = false

    # older code


    func load_game():
        if loading and FileAccess.file_exists(save_path):
            print("Save file found!")
            var file = FileAccess.open(save_path, FileAccess.READ)
            var data = JSON.parse_string(file.get_as_text())
            file.close()
            # Load the saved scene
            var scene_path = "res://Scenes/%s.tscn" % data["scene_name"]
            print(scene_path)
            var game_resource = load(scene_path)
            var game = game_resource.instantiate()
            # Change to the loaded scene
            get_tree().root.call_deferred("add_child", game)        
            get_tree().call_deferred("set_current_scene", game)
            current_scene_name = game.name
            # Now you can load data into the nodes
            var player = game.get_node("Player")    
            var npc = game.get_node("SpawnedNPC/NPC") 
            var enemy_spawner = game.get_node("EnemySpawner")
            #checks if they are valid before loading their data
            if player:
                player.data_to_load(data["player"])
            if npc:
                npc.data_to_load(data["npc"])
            if enemy_spawner:
                enemy_spawner.data_to_load(data["enemies"])
            if(npc and npc.quest_complete):
                game.get_node("SpawnedQuestItems/QuestItem").queue_free()
        else:
            print("Save file not found!")


Enter fullscreen mode Exit fullscreen mode

We also need to create a function that will load our player’s data when they enter a new scene. This function will load our player data (such as their health, ammo, and coin count from the previous scene) when entering a new scene by checking if a save file exists, reading and parsing the file to obtain the player data, and loading the data into the “Player” node of the current scene if it exists. This will allow our UI components to show the correct values on game load.



    ## Global.gd

    #player data to load when changing scenes
    func load_data():
        var current_scene = get_tree().get_current_scene()
        if current_scene and FileAccess.file_exists(save_path):
            print("Save file found!")
            var file = FileAccess.open(save_path, FileAccess.READ)
            var data = JSON.parse_string(file.get_as_text())
            file.close()

            # Now you can load data into the nodes
            var player = current_scene.get_node("Player")
            if player and data.has("player"):
                player.values_to_load(data["player"])
        else:
            print("Save file not found!")


Enter fullscreen mode Exit fullscreen mode

When this function loads our player data, it looks for a function inside of our Player script called values_to_load(). This is a new function that loads all of our Player’s data except its position since we don’t want our position to load in some random area on the map (which could be an area that is our of bounds)! In your Player script, let’s create this function.



    ## Player.gd

    # older code

    func values_to_load(data):
        health = data.health
        max_health = data.max_health
        stamina = data.stamina
        max_stamina = data.max_stamina
        xp = data.xp
        xp_requirements = data.xp_requirements
        level = data.level
        ammo_pickup = data.ammo_pickup
        health_pickup = data.health_pickup
        stamina_pickup = data.stamina_pickup    
        coins = data.coins

        # Emit signals to update UI
        health_updated.emit(health, max_health)
        stamina_updated.emit(stamina, max_stamina)
        ammo_pickups_updated.emit(ammo_pickup)
        health_pickups_updated.emit(health_pickup)
        stamina_pickups_updated.emit(stamina_pickup)
        xp_updated.emit(xp)
        level_updated.emit(level)
        coins_updated.emit(coins)

        # Update UI components directly
        $UI/AmmoAmount/Value.text = str(data.ammo_pickup)
        $UI/StaminaAmount/Value.text =  str(data.stamina_pickup)
        $UI/HealthAmount/Value.text =  str(data.health_pickup)
        $UI/XP/Value.text =  str(data.xp)
        $UI/XP/Value2.text =  "/ " + str(data.xp_requirements)
        $UI/Level/Value.text = str(data.level)
        $UI/CoinAmount/Value.text = str(data.coins)


Enter fullscreen mode Exit fullscreen mode

We now also need to update our MainMenu script’s code to call our newly created functions. Our load button will call our load_game() function from our Global script.



    ### MainScene.gd

    extends Node2D

    func _ready():
        Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)

    # New game
    func _on_new_pressed():
        Global.change_scene("res://Scenes/Main.tscn")
        Global.scene_changed.connect(_on_scene_changed)

    # Load game  
    func _on_load_pressed():
        Global.loading = true
        Global.load_game()
        queue_free()

    # Quit Game
    func _on_quit_pressed():
        get_tree().quit()

    #only after scene has been changed, do we free our resource     
    func _on_scene_changed():
        queue_free()


Enter fullscreen mode Exit fullscreen mode

Finally, we need to also load our data when we change the scene to keep our data persistent.



    ### Global.gd

    # older code

    # ----------------------- Scene handling ----------------------------
    #set current scene on load
    func _ready():
        current_scene_name = get_tree().get_current_scene().name


     # Change scene
    func change_scene(scene_path):
        save()
        # Get the current scene
        current_scene_name = scene_path.get_file().get_basename()
        var current_scene = get_tree().get_root().get_child(get_tree().get_root().get_child_count() - 1)
        # Free it for the new scene
        current_scene.queue_free()
        # Change the scene
        var new_scene = load(scene_path).instantiate()
        get_tree().get_root().call_deferred("add_child", new_scene) 
        get_tree().call_deferred("set_current_scene", new_scene)    
        call_deferred("post_scene_change_initialization")

    func post_scene_change_initialization():
        load_data()
        scene_changed.emit()


Enter fullscreen mode Exit fullscreen mode

We also need to set our player’s UI values in their ready function to update the values when they enter a new scene.



    ### Player.gd

    # older code

    func _ready():
        # Connect the signals to the UI components' functions
        health_updated.connect(health_bar.update_health_ui)
        stamina_updated.connect(stamina_bar.update_stamina_ui)
        ammo_pickups_updated.connect(ammo_amount.update_ammo_pickup_ui)
        health_pickups_updated.connect(health_amount.update_health_pickup_ui)
        stamina_pickups_updated.connect(stamina_amount.update_stamina_pickup_ui)
        xp_updated.connect(xp_amount.update_xp_ui)
        xp_requirements_updated.connect(xp_amount.update_xp_requirements_ui)
        level_updated.connect(level_amount.update_level_ui)
        coins_updated.connect(coin_amount.update_coin_amount_ui)

        # Reset color
        animation_sprite.modulate = Color(1,1,1,1)
        Input.set_mouse_mode(Input.MOUSE_MODE_HIDDEN)

        #update ui components to show correct loaded data   
        $UI/AmmoAmount/Value.text = str(ammo_pickup)
        $UI/StaminaAmount/Value.text =  str(stamina_pickup)
        $UI/HealthAmount/Value.text =  str(health_pickup)
        $UI/XP/Value.text =  str(xp)
        $UI/XP/Value2.text =  "/ " + str(xp_requirements)
        $UI/Level/Value.text = str(level)


Enter fullscreen mode Exit fullscreen mode

Now if you run your scene, you should be able to save/load as per usual. You should also be able to change scenes, and your data from the previous scene should carry over!

Godot RPG

Godot RPG

Congratulations, you now have a persistent saving and loading system! Remember to save your project, and I’ll see you in the next part.

The final source code for this part should look like this.

Buy Me a Coffee at ko-fi.com


FULL TUTORIAL

Godot RPG

The tutorial series has 23 chapters. I’ll be releasing all of the chapters in sectional daily parts over the next couple of weeks.

If you like this series or want to skip the wait and access the offline, full version of the tutorial series, you can support me by buying the offline booklet for just $4 on Ko-fi!😊

You can find the updated list of the tutorial links for all 23 parts in this series here.

Top comments (2)

Collapse
 
christinec_dev profile image
christine

Hey guys, please know that we will be making a BETTER system in a later part!😊

Collapse
 
facepalm profile image
Chris

Thanks for the awesome tutorial. Just one thing that confuses me..

In EnemySpawner.gd, when you spawn enemy, we add_child to spawned_enemies node. And when we save, we iterate child nodes under spawned_enemies node.

But when we load it we just add_child to EnemySpawner node (which is a parent of spawned_enemies node)?

Wouldn't it cause the enemies to not save properly the second time you try to save, because some enemies would be under EnemySpawner node and not spawned_enemies node?

I tried doing spawned_enemies.add_child(enemy) inside data_to_load, but I got this error instead: Invalid call. Nonexistent function 'add_child' in base 'Nil' Which sort of makes sense because when we call 'data_to_load' method inside Global, EnemySpawner node has not been created yet (I think).

Am I making a mistake? Or if not, how to fix it? (Sorry about the long comment :S )