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
}
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
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"
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
}
### 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
}
### 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
}
### 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
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.")
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()
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()
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):
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"}
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
### 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)
### 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
### 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
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!")
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!")
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)
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()
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()
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)
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!
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.
FULL TUTORIAL
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)
Hey guys, please know that we will be making a BETTER system in a later part!😊
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 underspawned_enemies
node.But when we load it we just
add_child
to EnemySpawner node (which is a parent ofspawned_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 notspawned_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 )