Saving Progress

This is the eighth tutorial in a series that covers the creation of a simple true top-down 2D game with pixel graphics, using the Godot Engine, version 4. It follows Map Transitions and adds support for saving and loading game state.
This tutorial uses Godot 4.4, the regular version, but you could also use the .NET version.
Upgrade to Godot 4.4
Before we move on to the main part of the tutorial, we first upgrade our project from Godot version 4.3 to 4.4. This is simply a matter of opening the project with the newer Godot editor. The only significant change is that Godot now uses unique identifiers, known as UIDs, to identify resources internally. The project upgrade process will automatically generate separate .uid
files for the resources that need them.
Scenes also have UIDs, but they are stored in the scene files themselves instead of separately. Because I created the second map scene by duplicating the first one in an older version, they now end up with the same UID and Godot will issue a warning about duplicate UIDs. If you also get this issue it can be fixed by manually changing the UID. This is best done by creating and saving a new scene, opening its file in a text or code editor, and copying its UID. We can do this because we're saving the scenes in text format, using .tscn
files. Then delete the scene, open a map scene in the same way, and replace its UID with the one we got from the temporary scene. This ensures that we get a valid UID.
The UID is stored in the first line of the file, looking like this, with the UID marked:
[gd_scene load_steps=6 format=3 uid="uid://cfq4dnyqev3sm"]
Storing Map Path
We use map scene paths to progress from one map to the next. So to save the game we have to store the path of the current map. We'll only store the path, not the complete game state, because that would be far more complex. This means that quitting the game and later resuming it will reset the current map.
To making keeping track of the current map easier we'll add a variable for it to main.gd
. We set it via a new map_loaded()
function with a map path parameter. This replaces the local variable used in load_next_map()
.
var current_map_path: String
func load_next_map() -> void:
#var current_map_path := get_tree().current_scene.scene_file_path
var split_path := current_map_path.split(".")
…
func map_loaded(map_path: String) -> void:
current_map_path = map_path
It's the responsibility of the map to indicate which map has been loaded, by calling it when map.gd
is ready, passing along its own scene file path. We do it this way so the correct path is set no matter which map scene we run directly from the editor, and also after loading the next map.
func _ready() -> void:
for child_node in get_children():
…
Main€.map_loaded(scene_file_path)
Now we need to store the map path somewhere. We'll create a file for this in the persistent user data folder. The exact location of this path depends on the system the game runs on and can also be customized via the project settings, as explained by the Godot documentation.
The persistent data path is accessed via the user://
prefix. We'll store our game data in a game.save
file there. Add a constant for this path to main.gd
.
const GAME_SAVE_PATH := "user://game.save"
var current_map_path: String
To create a file we'll call FileAccess.open()
at the end of map_loaded()
with the path and FileAccess.WRITE
as arguments. This either creates the file or lets us replace the file if it already exists. The function returns an object that allows us to read and writing to the file. As we ask for write access permission in this case we can only write to it.
func map_loaded(map_path: String) -> void:
current_map_path = map_path
var game_save_file := FileAccess.open(GAME_SAVE_PATH, FileAccess.WRITE)
The simplest way to store the map path is by calling store_string()
on the file. This will make the entire contents of the file equal to the map path that we pass to it. You can verify this by opening the file in a text or code editor after runnning the game.
var game_save_file := FileAccess.open(GAME_SAVE_PATH, FileAccess.WRITE)
game_save_file.store_string(map_path)
We don't need to explicitly close the file afterwards, this will be done automatically when the function terminates and the file access object is no longer needed and will be freed.
Main Menu
We're now storing the path of whichever map was loaded last in our game save file. To do something with this information we have to give the user the option to continue playing on the last map. Let's create a main menu scene that provides this option, along with the standard option to start a new game.
Create a new scene, pick User Interface for its root node option, and save it as main_menu.tscn
. This gets us a scene with a Control
root node. Let's name it Main Menu
. This main menu control covers the entire screen. Add a child Control
node to it named Menu Buttons
. We'll use this control to position our buttons.
Set the menu button area's size to 100×80, via its Control › Layout › Custom Minimum Size inspector property. Then change its Layout Mode to Anchors and set Anchors Preset to Center. This positions our button area in the middle of the screen.

Next, add two Button
child nodes to Menu Buttons
, naming them New Game Button
and Continue Button
. Set their Layout Mode to Anchors as well. Set the Anchors Preset to Top Wide for the new game button and to Bottom Wide for the continue button. Set their Button › Text to New Game
and Continue
respectively.

Menu Buttonsnode selected.
To make the buttons functional, add a new main_menu.gd
script to the root node and connect the pressed() signal of both buttons to it via the inspector, using all default options. That gives us the following script:
extends Control
func _on_new_game_button_pressed() -> void:
pass
func _on_continue_button_pressed() -> void:
pass
Loading Any Map
To support loading an arbitrary map, let's split up the load_next_map()
function in main.dg
. Turn the part that does the loading in a separate load_map()
function with a map path parameter.
func load_next_map() -> void:
var split_path := current_map_path.split(".")
var next_map_number := split_path[1].to_int() + 1
split_path[1] = str(next_map_number).pad_zeros(3)
var next_map_path = ".".join(split_path)
if not ResourceLoader.exists(next_map_path):
split_path[1] = "001"
next_map_path = ".".join(split_path)
load_map(next_map_path)
func load_map(map_path: String) -> void:
ResourceLoader.load_threaded_request(map_path)
get_tree().paused = true
await MapTransition.play_exit_map()
get_tree().change_scene_to_packed(
ResourceLoader.load_threaded_get(map_path)
)
await MapTransition.play_enter_map()
get_tree().paused = false
We can now load the first map by calling Main€.load_map()
when the new game button is pressed in main_menu.gd
. To know which map to load let's export a PackedScene
variable and use its resource_path
. Then set it to the first map via the inspector.
@export var first_map: PackedScene
func _on_new_game_button_pressed() -> void:
Main€.load_map(first_map.resource_path)
When we run the main menu scene we'll transition to the first map when pressing the new game button. The main menu scene should be made the main scene of the project. This can be done via the project settings, but it is more convenient to do this is via the scene's context menu option Set as Main Scene in the FileSystem panel.
Note that the main menu scene is freed when we transition to a map, just like when we complete a map, which is fine as we no longer need it.
Continuing
We can only continue from the last map if a save game file exists. So let's add a can_continue()
function that returns a bool
to main.gd
that checks this, by calling FileAccess.file_exists()
with our game save path.
func can_continue() -> bool:
return FileAccess.file_exists(GAME_SAVE_PATH)
Also add a load_continue_map()
function that loads the game save file with FileAccess.READ
. The file contents can be retrieved by calling get_as_text()
on the file, which we then pass to load_map()
.
func load_continue_map() -> void:
var game_save_file := FileAccess.open(GAME_SAVE_PATH, FileAccess.READ)
load_map(game_save_file.get_as_text())
This only works if the game save file exists, so let's first check if we cannot continue. If so print an error that tells us that we tried to continue without a save file, and then abort by returning from the function.
func load_continue_map() -> void:
if not can_continue():
print("Tried to continue without save file!")
return
var game_save_file := FileAccess.open(GAME_SAVE_PATH, FileAccess.READ)
load_map(game_save_file.get_as_text())
Call this function when the continue button is pressed in main_scene.gd
.
func _on_continue_button_pressed() -> void:
Main€.load_continue_map()
We can now continue playing the last map that we were on during the previous game session. But continuing fails when there is no game safe file. Let's improve this by disabling the continue button when it is nonfunctional. Export a variable for the continue button and hook it up via the inspector. Then add a _ready()
function that turns on the disabled
state of the button if we cannot continue.
@export var continue_button: Button
func _ready() -> void:
if not Main€.can_continue():
continue_button.disabled = true
Better Save Format
Only storing the map path is rather crude. Let's make our save format more advanced and future-proof by storing a dictionary instead. In main.gd
, instead of calling store_string()
in map_loaded()
, we will instead call the more generic store_var()
. Then instead of storing the map path directly we create a dictionary in which we store the map path with the map_path
key. Define this key as a constant.
A shorthand way to create a dictionary is to write {key: value}
.
const KEY_MAP_PATH := "map_path"
…
func map_loaded(map_path: String) -> void:
current_map_path = map_path
var game_save_file := FileAccess.open(GAME_SAVE_PATH, FileAccess.WRITE)
game_save_file.store_var({KEY_MAP_PATH: map_path})
Now load_continue_map()
has to use get_var()
to retrieve the game save data, casting it to a Dictionary
. Then it can retrieve the map path from the dictionary.
func load_continue_map() -> void:
if not can_continue():
print("Tried to continue without save file!")
return
var game_save_file := FileAccess.open(GAME_SAVE_PATH, FileAccess.READ)
var game_save_data := game_save_file.get_var() as Dictionary
load_map(game_save_data[KEY_MAP_PATH])
This made it possible save additional data in future versions of our game. To make it easier to determine save file compatibility we'll add a version number to our save game file, using the version
key, starting with version 1. Dictionaries can be defined as comma-separated lists of key-value pairs. Also, Godot's style guide suggests adding a trailing comma if there is more than one pair, one per line, so we do this as well. This is convenient when checking file change history via source control.
const GAME_SAVE_VERSION := 1
const KEY_GAME_SAVE_VERSION := "version"
…
func map_loaded(map_path: String) -> void:
current_map_path = map_path
var game_save_file := FileAccess.open(GAME_SAVE_PATH, FileAccess.WRITE)
game_save_file.store_var({
KEY_MAP_PATH: map_path,
KEY_GAME_SAVE_VERSION: GAME_SAVE_VERSION,
})
We should check in load_continue_map()
if the save file version exceeds the version that we know about. If so, it might contain essential data that we don't know what to do with, so we should log this and abort loading.
var game_save_data := game_save_file.get_var() as Dictionary
if game_save_data[KEY_GAME_SAVE_VERSION] > GAME_SAVE_VERSION:
print("Tried to load unknown future save file format!")
return
Also, although we're now no longer storing plain-text data, it's still possible to see strings like the map path in the data. To avoid loading nonsensical scenes, let's check whether the map path starts with res://maps/
before loading. If not, we log that there is an invalid map path and abort. We can use the string's begins_with()
function for this.
const MAP_PATH_PREFIX := "res://maps/"
…
func load_continue_map() -> void:
…
var map_path := game_save_data[KEY_MAP_PATH] as String
if not map_path.begins_with(MAP_PATH_PREFIX):
print("Invalid map path!")
return
load_map(map_path)
Finally, it is possible for a path to contain ..
to go up a folder, which allows a path to escape from the maps
folder. For example, we could access the main menu scene via res://maps/../main_menu.tscn
. To guard against this we declare the path invalid when either the prefix is missing or the path contains ..
.
if (
not map_path.begins_with(MAP_PATH_PREFIX)
or map_path.contains("..")
):
print("Invalid map path!")
return
Is this save file format secure?
The discussion of security is not about being able to edit save files, because that is almost impossible to prevent and isn't a security issue. The problem is the potential execution of arbitrary code, inserted via inline scripts stored in serialized objects. The attack is carried out by sharing malicious save files. Of course people could always modify a Godot game and end up using malicious mods, but that's on them. The idea is that save files should be harmless.
The attack is possible when arbitrary Godot resources are loaded. It is also possible when arbitrary objects are stored and loaded with store_var()
and get_var()
. However, by default objects cannot be stored and loaded via these functions. This functionality has to be explicitly enabled by passing them an optional argument. We don't do this, so our code doesn't load arbitrary objects and is thus safe.
We can now let the player continue playing the last map that they were on. It is also possible to store more data in the save file later, which we'll make use of in the future.