Yet Another Roguelike Tutorial, Part 10

Publish date: Nov 12, 2023
Tags: godot4 tutorial roguelike

Part 10: Saving and Loading

Welcome back to the roguelike tutorial series. This tutorial will continue from where the last one left off. You can find the previous tutorial here: https://selinadev.github.io/13-rogueliketutorial-09/

It’s been a while since the last tutorial, but luckily we can jump right back in. And this time we’ll make it so that our players may do the same. Up until now if a player wanted to pause and return to the game later they would have to keep the game open in tha background. However, after this tutorial part they’ll be able to save the game and close it and load the game again when they open the application again. Granted, longer playsessions aren’t that interesting yet, as once the player has explored all of the dungeon there isn’t anything more to do, but we’ll tackle that problem next time.

Before we tackle that, let’s quickly fix a bug that has crept into our inventory handling. In main_game_input_handler.gd we need to change get_item() a bit.

func get_item(window_title: String, inventory: InventoryComponent, evaluate_for_next_step: bool = false) -> Entity:
	if inventory.items.is_empty():
		await get_tree().physics_frame
		MessageLog.send_message("No items in inventory.", GameColors.IMPOSSIBLE)
		return null
	var inventory_menu: InventoryMenu = inventory_menu_scene.instantiate()
	add_child(inventory_menu)
	inventory_menu.build(window_title, inventory)
	get_parent().transition_to(InputHandler.InputHandlers.DUMMY)
	var selected_item: Entity = await inventory_menu.item_selected
	var has_item: bool = selected_item != null
	var needs_targeting: bool = has_item and selected_item.consumable_component and selected_item.consumable_component.get_targeting_radius() != -1
	if not evaluate_for_next_step or not has_item or not needs_targeting:
		await get_tree().physics_frame
		get_parent().call_deferred("transition_to", InputHandler.InputHandlers.MAIN_GAME)
	return selected_item

We added a block at the start, that will prevent the inventory from opening if it’s empty, which will prevent the player from getting stuck in the dummy input handler if the inventory is empty. Also we had to modify the condition for the if statement a bit (including a new variable to make things more readable). This prevents a bug that would stop us from dropping items.

Here’s what we’ll be doing this time. First, the player will need a way to choose wheter they want to start a new run or load an ongoin one when they start a session. To do that we’ll start by implementing a very simple main menu. Once we have that we can look into how we want to save and later restore the game state. The save format I’ll show you is far from optimal, but I’ll try my best to explain why that is the easiest way to implement it and what you may would want to do differently.

So, let us start with creating the main menu. We could just use a blank background, but I went with the same background the original tutorial uses. You can find it in the github repository under part_10/assets/images/menu_background.png, or download it here:

Background for our menu

This image should be saved under part_10/assets/images/menu_background.png

Now, create a new scene with a Control node at its root. Call that node MainMenu and save the scene under res://src/GUI/MainMenu/main_menu.tscn. Add a TextureRect node to it and name it Background, as well as a VBoxContainer. Add two children to the VBoxContainer: a CenterContainer and a Label. Add another VBoxcontainer as child of the CenterContainer. This last VBoxContainer will get three children: a Label and three Button nodes. Name the buttons NewButton, LoadButton, and QuitButton. Now let’s go over configuring this scene.

Set the root MainMenu node’s Anchors Preset to Full Rect.

For the Background node first drag our new image from res://assets/images/menu_background.png into the Texture property. Set the Stretch Mode to Keep Aspect Covered. Other than the python tutorial this game will allow changing the game’s resolution, and this setting should ensure that the image won’t be stretched unnaturally while still covering the whole window. Speaking of which, we also need to set the Full Rect Anchor Preset on this node to have it cover the viewport.

We do the same with the outer VBoxContainer, setting it to Full Rect as well.

We want the CenterContainer to take up most of the screen, but have a little text line at the bottom. To do that we check Expand under Layout > Container Sizing > Vertical.

No changes for the inner VBoxContainer. The (inner) Label will again get Label Settings defined. We give it res://assets/fonts/kenney_kenney-fonts/Fonts/Kenney Pixel.ttf as Font, set the Size to 32, and as this should stand out as the title we give it a bright yellow Color (#ffff00). We also give it an outline of Size 2 with black Color (so it will always stand out event against the bright parts of the background). As text we set “DUNGEON OF THE MAD GOD(OT)”.

The three buttons will all be configured basically the same, so I will go through NewButto and then only point out the differences for the other two buttons. Set the Text to “[ N ] Play a new game”. Down in the Theme Overrides we change a few things so these buttons behave more or less like the buttons we already have. Tick the checkbox next to Colors > Font Outline Color. Change Constants > Outline to 2. Change Fonts > Font to the same font as for the label above. Set Font Sizes > Font Size to 32. Under Styles we create (or better copy) the same style boxes as for our other buttons. A StyleBoxEmpty for Normal, Hover, and Disabled, and a StyleBoxFlat in which we do not Draw Center, but have a white border of 1 pixel on all edges. We also want to be able to trigger our buttons via keyboard commands, wo we configure shortcuts again. Back up under Shortcut disable Shortcut Feedback, then create a new Shortcut in the Shortcut slot. Increase the size of that shortcut’s Events array to 1. In the slot that appeared in the array add a new InputEventKey. Click on the Configure button and press the ‘n’ key on your keyboard. That should configure everything properly.

You can configure the other two buttons separately, but it’s probably easiest to duplicate and rename the NewButton after you have fully configured it. Now, for the LoadButton set the Text to “[ C ] Continue last game”. This button also has a different shortcut, so we need to configure a different key press. Note that if you did duplicate the NewButton to create this button it will hold the same shortcut resource. So changing the key here would also change it for the other button. So make sure you click Make Unique both on the Shortcut resource and the input event resource before changing the shortcut. For the LoadButton we will use the ‘c’ key as shortcut, as indicated in the text. The QuitButton will get the Text “[ Q ] Quit and the shortcut key ‘q’.

We’ll later need two of the buttons in code, so we will configure NewButton and LoadButton to Access as Unique Name (by right clicking them in the scene tree).

Lastly, the outermost Label well again create new LabelSettings (at this point a common theme resource would save us a lot of work, but I hadn’t thought of that in the beginning and now it would mean a lot of refactoring). We set it to the same Font again, with Size 16 and the same yellow Color as the other label. We also give it a black Outline of 2 pixels. Next, we set the Horizontal Alignment to Center. I wrote “By Selina” into the Text property, but feel free to change that to your name.

Now our main menu is set up, and we can move to coding its behavior. We’ll see in a moment why this scene actually does very little on its own. Create a new script on MainMenu and save it under res://src/GUI/MainMenu/main_menu.gd. Here’s the top of the script:

class_name MainMenu
extends Control

signal game_requested(load)

@onready var first_button: Button = $"%NewButton"
@onready var load_button: Button = $"%LoadButton"

As mentioned the main menu won’t load the game on its own, but rather request it. We do this via a signal, which will have a boolean value that tells us whether the game should attempt to load a game, or, if not, create a new game. We also get references to the two buttons we specified. Next the ready function.

func _ready():
	first_button.grab_focus()
	var has_save_file: bool = FileAccess.file_exists("user://save_game.dat")
	load_button.disabled = not has_save_file

We use the first button, in our case the new game button, to grab focus. That way it will be preselected, which will make navigating the menu via the keyboard easier. We then check if a file called save_game.dat exists in the user directory. If it does not, we know that we don’t have a saved game and disable the LoadButton. Next we’ll create a function for each of the buttons:

func _on_new_button_pressed():
	game_requested.emit(false)


func _on_load_button_pressed():
	game_requested.emit(true)


func _on_quit_button_pressed():
	get_tree().quit()

If the NewButton is pressed we emit signal mentioned above with false, indicating we don’t want to try loading a game. If the LoadButton was pressed we emit the same signal but with true, which will tell our other code we want to try to load a saved game. Lastly, if the QuitButton was pressed we directly quit the game. All that’s left to do now is to hook up these signals. In the editor select NewButton and switch from the Inspector tab to the Node tab. Connect the pressed() signal (using the default values). Do the same with the LoadButton and the QuitButton.

With the code in place we can look at how to use this menu scene. The menu scene itself merely signals requests for changing to the game. The reason for that is that both the NewButton and the LoadButton should switch to the same scene. However, how that scene should act differs based on whether we want to start a new game or load an existing one. The change_scene() function in Godot only allows us to switch the scene, we can’t configure behavior or send any data to the new scene. There are several ways to handle this issue. One simple way would be to have an autoload that stores such information for us. However, the method I prefer is to use a state machine. We will implement this as a new root node for everything. Then, when we switch scenes we don’t switch to a completely new root node (as change_scene() would do) but have the GameManager root node (which we will shortly create) free its child and instantiate the new scene in its place. The GameManager can then run specific code depending on how the scene change was requested. Let’s start implementing that.

Create a new scene with a Node at its root and call it GameManager. Save the scene at res://src/Game/game_manager.tscn. The reason we need a scene with just one node is that this scene will be our new start scene. Go into Project > Project Settings and navigate to Application > Run. There you will find the option Main Scene. Click the folder icon to the far right and select our new res://src/Game/game_manager.tscn as the new Main Scene. If you were to run the project now you’d get a black screen. The GameManager node “works”, it simply doesn’t do anything at the moment. So bach in the scene create a new script on GameManager and save it at res://src/Game/game_manager.gd.

Here’s the top of the script:

extends Node

const game_scene: PackedScene = preload("res://src/Game/game.tscn")
const main_menu_scene: PackedScene = preload("res://src/GUI/MainMenu/main_menu.tscn")

var current_child: Node

We have variables that hold the two packed scenes we can switch to, and a variable that tracks the current child (the game manager should only ever have one child, but I still find it cleaner this way). We use the latter quite a lot in the code for switching the scene:

func switch_to_scene(scene: PackedScene) -> Node:
	if current_child != null:
		current_child.queue_free()
	current_child = scene.instantiate()
	add_child(current_child)
	return current_child

If we currently have a scene in our child node, we call queue_free() on it. Then, we instantiate the new scene and store it in the current_child variable, which we then actually add as a child. Lastly, we might want to do specific stuff with the instantiated scene in the calling function, so we return that scene. Here’s how this function fits in with the function that loads the main menu:

func load_main_menu() -> void:
	var main_menu: MainMenu = switch_to_scene(main_menu_scene)
	main_menu.game_requested.connect(_on_game_requested)

We first create a new main menu in place of the existing scene. After that we connect its game_requested signal to the _on_game_requested() function. Here’s a temporary implementation of that function:

func _on_game_requested(try_load: bool) -> void:
	switch_to_scene(game_scene)

Two issues are remaining here. The first issue is that we don’t have an initial state for this system. This is remedied with a simple _ready() function:

func _ready():
	load_main_menu()

We simply start the game off by loading the main menu. The second issue is that right now the actual game is a terminal state. We have no way of getting back to the main menu. Ok, the third issue might be that we don’t handle all the new game/load game stuff, but that has to wait till after we actually implemented saving and loading. For now we have to refactor the existing game a bit, to better integrate it with our game manager system.

To do that we want two tings in our game scene. First is a top level script our game manager can interact with. We originally had the Game node, but since we have restructured our game.tscn to accomodate the GUI, we don’t have a script in the root node. So now we’ll give the root node a small bit of functionality, just so it can relay things to the Game node. The second thing we want is a way to communicate back to the game manager, for which we’ll use a signal again. That signal will be called from our escape actions. Setting those up to communicate with the game scene root node would be a bit convoluted, so we’ll make use of our SignalBus once more. Add one more signal to signal_bus.gd:

extends Node

signal player_died
signal message_sent(text, color)
signal escape_requested

We’ll use escape_requested to signal that we want to go out of the game, which now will mean going into the main menu instead of quitting the game altogether. Next, game.tscn create a new script on the InterfaceRoot node. Save it at res://src/Game/game_root.gd (as this is not only the root node of the interface, but of the whole game scene). We’ll want to refer to the Game node, so before jumping into the script make sure to enable Access as Unique Name on the Game node. Then we can start with the script:

class_name GameRoot
extends Control

signal main_menu_requested

@onready var game: Game = $"%Game"


func _ready() -> void:
	SignalBus.escape_requested.connect(_on_escape_requested)


func _on_escape_requested() -> void:
	main_menu_requested.emit()

We have a signal that will be emitted from this node to the game manager. Also here’s the reference to the Game node. We’ll use it in a bit when we handle new games and later loading. In the _ready() function we connect our new escape_requested from the SignalBus to a _on_escape_requested() function, which will in turn emit the main_menu_requested signal. At the moment nothing calls the signal from the SignalBus, so we’ll handle that next. As mentioned, we’ll use the EscapeAction for that. So in escape_action.gd change the perform() function to the following:

func perform() -> bool:
	SignalBus.escape_requested.emit()
	return false

Now the signal get’s called at the appropriate moment. We can now use it in game_manager.gd. Change the _on_game_requested() function to the following:

func _on_game_requested(try_load: bool) -> void:
	var game: GameRoot = switch_to_scene(game_scene)
	game.main_menu_requested.connect(load_main_menu)

We added a line to connect the new main_menu_requested signal to the load_main_menu() function. At this point you should be able to start the game to see the main menu. From there you can start a new game. Pressing Escape should take you back to the main menu, where you can again start a new game. Not terribly useful at the moment, but it’s the setup we’ll need for what comes next: the actual saving and loading.

Before we dive into it, let me briefly describe the approach I’m using. One thing that helps us with saving is our MapData class. This object holds the information about the map and all relevant objects in the game. So what we ultimately want to do is to be able to save and restore that MapData object. From there we will go in a hierarchical fashion. We will use an interface where every object that can be saved implements a get_save_data() function. This function will return a Dictionary that each objet will fill with the data it needs to save. In addition to that each object knows what other objects it is responsible for that might need saving. For example, an Entity know that it needs to save data from its Components. It does not need to know how to save data from the components, because the save system is hierarchical, so the Components will themselves also implement get_save_data(), and the Entity will just call that. What we will end up with is kind of tree inside a Dictionary. The nice thing about dictionaries is that they are relatively easy to save into JSON format.

Once it comes to loading we will go in reverse. The MapData will see that it needs to restore an Entity, so it will instantiate one and hand it its appropriate save data, via a restore() funciton. The entity will restore itself from that data, and then hand the contained save data concerning its components to those components, which will also restore themselves to the appropriate state. There will be a few edge cases we’ll need to handle, but that’s the theory.

One thing I need to mention is a shortcut I’m taking in regards to the format. The benefit of JSON is that it’s human readable. Theoretically you can go into the save file and inspect the saved game state. You could even edit it, and give yourself a lot of healing potions. And you can accidentally delete half of an entity, and bring the save file into a state that cannot be easily loaded. To load a save the game expects the data in a certain format. Now, one way to discourage messing with the save file is to serialize to a binary format, which can also bring you the benefits of faster write/read times and smaller file sizes. Saving everything as a bunch of (mostly redundant) strings is easy, but not very efficient. However, in both cases you need to account for the possibility of corrupted saves. If you don’t the game might crash when trying to load a corrupted save file. This means you don’t just load a game, you just try to load it, and you stop as soon as something doesn’t quite fit. That would be the proper way. However, that means lots and lots of error handling in every function that is involved in restoring data. To keep this already relatively complex tutorial a bit shorter, I will omit that. Or rather I will use the aforementioned shortcut to summarize error handling into one check. To do that we will actually save two things: the complete save data in JSON format and a hash of that data. Then, when we load the save data we will hash the loaded data again and compare that hash with the one we load from the save file. If they don’t match then we know that this isn’t the data that we saved, and we declare loading the save game a failure right away. Of course this means that the user can’t edit the data, even if it would result in data we could properly load. Still, we take that reduced functionality, as it saves us a lot of error handling. Or rather, it saves me a lot of error handling. In a proper project I would recommend you to look into implementing such error handling.

Now, while the way we can use MapData to save everything was good forward thinking (or luck), I did mess up a bit with entities and tiles. Tiles don’t know what tiles they are, and neither do entities know what entities they are. This is not optimal, because their state involves data they share with their templates, such as their textures. We really don’t want to save every entities texture in the save data. Therefore we need to do a bit of refactoring.

We start with the simple stuff, in our case the Tile class. Right now different tile types are handled by the dungeon generator. We’re gonna move that code over to the Tile class itself. At the top of the script add the following:

const tile_types = {
	"floor": preload("res://assets/definitions/tiles/tile_definition_floor.tres"),
	"wall": preload("res://assets/definitions/tiles/tile_definition_wall.tres"),
}

var key: String

We have a constant that encodes the mapping of different keys and the corresponding tile definitions. We also add a key variable to the tile class that will hold the key corresponding to the tile. We modify set_tile_type() to work with these new variables:

func set_tile_type(key: String) -> void:
	self.key = key
	_definition = tile_types[key]
	texture = _definition.texture
	modulate = _definition.color_dark

We call set_tile_type() in _init(), so that needs to be changed:

func _init(grid_position: Vector2i, key: String) -> void:
	visible = false
	centered = false
	position = Grid.grid_to_world(grid_position)
	set_tile_type(key)

This is another “do as I say, not as I do” moment, because the alternative I would recommend is to use enums instead of strings. Strings are quick and easy, and for the scale this project will take they are fine. However, if you want to expand from there and implement the amount of content many roguelikes have, then using strings like this is a bit error prone. And while the difference might be negligible in a lot of cases, Strings do use more memory. This also relates to what I discussed above about saving to JSON. If you use enums you can reduce keys like these to a single byte of data. The downside of enums is that they require more code (which is the main reason I’m omitting them here). If you decide to use them also be aware of your organization. If you use them for, e.g., entity ids, and you tend to alphabetize the keys, then you are changing the associated numbers. Imagine you save the game, and the value TROLL in your entities enum is equal to 2, so that gets saved. Then you work on the code, and add an ORC, which you insert before the TROLL in the enum. If you then load the previous game, the entity only knows it’s value number 2 in the enum, which in now ORC, and what was previously a troll now loads as an orc. That means that with enums you need to be extra careful to not introduce breaking changes with loading savefiles of previous versions.

Speaking of saving, let’s work on the interface we discussed above. There are two pieces of data the tile needs to save in order to be restored later. The newly introduced key, as well as is_explored. The position is encoded in the tile’s index in the tiles array, and is_visible can easily be restored by recalculating the field of view once we load the save. So here are the two functions:

func get_save_data() -> Dictionary:
	return {
		"key": key,
		"is_explored": is_explored
	}


func restore(save_data: Dictionary) -> void:
	set_tile_type(save_data["key"])
	is_explored = save_data["is_explored"]

In get_save_data() we create a dictionary holding those two pieces of information I just mentioned. The restore() function then can consume such a dictionary and set the tile to the correct properties via set_tile_type() and the stored key, and is_explored is simply restored from the save data. Now that we have actually implemented such a get_save_data()/restore() pair it is hopefully clear why loading could be a bit fickle. This function simply assumes that it gets the correct data. If it would get anything else than a Dictionary, if the dictionary would not contain the proper keys, or if the data didn’t have the correct types for example, the game would just crash. We would rather have a nice popup telling the player that the save data is corrupted, and can’t be loaded, so at each step we would have to make sure that everything is as we expect it. Just to illustrate here is how this might look with some more proper error checking (this code does not go into the project, don’t copy it):

func restore(save_data: Dictionary) -> bool:
	var stored_key = save_data.get("key")
	if not stored_key is String or not stored_key in tile_types:
		return false
	set_tile_type(stored_key)
	var stored_is_explored = save_data.get("is_explored")
	if not stored_is_explored is bool:
		return false
	is_explored = stored_is_explored
	return true

In such a system we would have a return value that would return a false early if there are any issues, and true if everything is going ok. If any of the restore() functions called by the load system fails, we would abort loading and declare it a failure. You see here that at every step we need to check if the data is as we expect. Thankfully, the get() function on the dictionary can safely get a value, i.e., if a key does not exist it returns null. By checking the type we can also ensure that the returned value isn’t null, which is why I did not need to explicitly check if the key exists in the dictionary. As you can see, this error checking can make our game more robust, but the extra checks would add up, which I want to spare us both.

We have changed the way we set tiles, which means refactoring in the two places that create or set tiles. In map_data.gd_ you can first remove the const dictionary at the top, as we now have this data directly in the Tile class. Then change _setup_tiles() to the following:

func _setup_tiles() -> void:
	tiles = []
	for y in height:
		for x in width:
			var tile_position := Vector2i(x, y)
			var tile := Tile.new(tile_position, "wall")
			tiles.append(tile)

Here we now use the string key instead of the dictionary key we used before. We also have to change the _carve_tile() function in dungeon_generatore.gd:

func _carve_tile(dungeon: MapData, x: int, y: int) -> void:
		var tile_position = Vector2i(x, y)
		var tile: Tile = dungeon.get_tile(tile_position)
		tile.set_tile_type("floor")

Next, we can move our attention to entities. We have to do similar changes there. However, entities are more complex than tiles and have several components, which also need to be saved. And you probably know by now how much I dislike having parser errors while writing the code, so we will start with implementing the save code on the components, so we can call get_save_dict() and restore() on them from the Entity class later without any red lines.

We can actually ignore the consumable component. That component does not really have any changing state. So if we restore an entity to, let’s say, a standard lightning scroll, then the consumable component on that will already be the correct one. So that’s one component done already without doing any work.

Next the fighter component, which is the most straight forward of the components to save, but it still needs some extra attention. In fighter_component.gd we add the two functions necessary for the save system:

func get_save_data() -> Dictionary:
	return {
		"max_hp": max_hp,
		"hp": hp,
		"power": power,
		"defense": defense
	}


func restore(save_data: Dictionary) -> void:
	max_hp = save_data["max_hp"]
	hp = save_data["hp"]
	power = save_data["power"]
	defense = save_data["defense"]

We simply store all the values that can change, and later restore all those. So far so good. However, what happens if we restore a dead enemy? Its hp will be zero, so setting it in the restore() function will call the setter on hp, which in turn will call die() function. This is good news for us, because we don’t need to store the death texture on the parent entity separately, or the changed name. This does, however, introduce two issues. One is that the die function might run before the entity is actually spawned, which can cause problems with accessing the entity texture, etc. The other problem is that the die() function creates a log message. Now, imagine you defeat every monster on a dungeon floor, then save the game, and upon loading the game again are greeted with dozens of “%s is dead!” messages. For that reason we change the die() function to allow entities to die silently:

func die(log_message := true) -> void:
	var death_message: String
	var death_message_color: Color
	
	if get_map_data().player == entity:
		death_message = "You died!"
		death_message_color = GameColors.PLAYER_DIE
		SignalBus.player_died.emit()
	else:
		death_message = "%s is dead!" % entity.get_entity_name()
		death_message_color = GameColors.ENEMY_DIE
	
	if log_message:
		MessageLog.send_message(death_message, death_message_color)
	...

So we only spawn the log message if we tell the function to. This function gets called in the hp setter, so we need to adapt that setter:

var hp: int:
	set(value):
		hp = clampi(value, 0, max_hp)
		hp_changed.emit(hp, max_hp)
		if hp <= 0:
			var die_silently := false
			if not is_inside_tree():
				die_silently = true
				await ready
			die(not die_silently)

The important part is that we check whether or not the FighterComponent is in the scene tree or not. If it is not, then that’s because the entity is dying during loading. In that case we want it to die silently (and wait to do so until it is in the tree). If it is in the scene tree, then it’s dying regularly, and we want to have the same behavior we had so far. Now that we handled that we move to another edge case. Remember how I said that we don’t need to handle the consumable component, because it doesn’t have state? Well, the enemy AI also doesn’t have state. Except for the confused AI. On top of that the confused ai should temporarily replace the default ai, but that one still needs to exist, so it can be restored after the confusion is over. For this reason I have decided to handle the AI component in the saving code for the entity. However, we still need info about the AI, namely which type it is, and if it’s a confused AI, how many turns are remaining. That’s why we’ll only implement the get_save_data() function on the AI components. So open base_ai_component.gd and add the following function:

func get_save_data() -> Dictionary:
	return {}

This is basically an abstract base class, so this won’t return any concrete data. However, the hostile enemy AI will inform us about its type. So the function for hostile_enemy_ai_component.gd looks like this:

func get_save_data() -> Dictionary:
	return {"type": "HostileEnemyAI"}

We simply save the name of the AI as a string. In confused_enemy_ai_component.gd we do the same, but in addition we save the remaining turns as well:

func get_save_data() -> Dictionary:
	return {
		"type": "ConfusedEnemyAI",
		"turns_remaining": turns_remaining
	}

That’s all the info we need from the AI component. We’ll look at how to restore it once we get to the restore code for the entity. For now we have one more component to take care of, the inventory. The inventory needs to save two things. It’s capacity (which isn’t strictly necessary because we don’t allow the capacity to change at the moment, so that technically isn’t part of its state) and the stored items. The items are entities, so here we do get to a cyclic dependency, but we’ll get to the entity shortly. So here’s the code for saving we’ll add to inventory_component.gd:

func get_save_data() -> Dictionary:
	var save_data: Dictionary = {
		"capacity": capacity,
		"items": []
	}
	for item in items:
		save_data["items"].append(item.get_save_data())
	return save_data

This is where our hierarchical approach shines. We create an array in which we will save the data for the items. Then we go through all the items and request the save data from each. Imagine if you were to create a container item that had an inventory itself. In that case the save system would still work, and would just recursively get the data from entities, their inventory components, their entities, their inventory components, their entities, and so on. Now to restoring the inventory:

func restore(save_data: Dictionary) -> void:
	capacity = save_data["capacity"]
	for item_data in save_data["items"]:
		var item: Entity = Entity.new(null, Vector2i(-1, -1), "")
		item.restore(item_data)
		items.append(item)

Here we read in an array which should contain multiple dictionaries, each containing save data of an entity (i.e., an item). We loop through each of those and instantiate a new entity for it. You will get a parsing error at this point, because this constructor does not match the constructor you currently have for the Entity class. Nonetheless, we pass the item’s save data to the newly created entities restore() function, and then add it to our items array.

Before we tackle saving and restoring entities, we have to refactor them a bit, just as we did with tiles. So, in entity.gd we’ll add the following two things:

class_name Entity
extends Sprite2D

enum AIType {NONE, HOSTILE}
enum EntityType {CORPSE, ITEM, ACTOR}

const entity_types = {
	"player": "res://assets/definitions/entities/actors/entity_definition_player.tres",
	"orc": "res://assets/definitions/entities/actors/entity_definition_orc.tres",
	"troll": "res://assets/definitions/entities/actors/entity_definition_troll.tres",
	"health_potion": "res://assets/definitions/entities/items/health_potion_definition.tres",
	"lightning_scroll": "res://assets/definitions/entities/items/lightning_scroll_definition.tres",
	"confusion_scroll": "res://assets/definitions/entities/items/confusion_scroll_definition.tres",
	"fireball_scroll": "res://assets/definitions/entities/items/fireball_scroll_definition.tres",
}

var key: String

Here we added a constant dictionary that maps string keys to entity definitions. This information was previously in the dungeon generator. One important thing to notice here is that we don’t use preload(). Unfortunately preload() would cause cyclic dependency errors, so instead we’ll load these resources at runtime. They’re pretty lightweight, so it won’t be an issue here. Apart from this dictionary we also added a key variable, so an entity can know what type of entity it is. As with the Tile class, this changes how we set the entity type. Here’s the (top part of) that function:

func set_entity_type(key: String) -> void:
	self.key = key
	var entity_definition: EntityDefinition = load(entity_types[key])
	_definition = entity_definition

We now give that function a key, which it will use to look up the path to the entity definition and load that. The rest of that function stays the same. This of course also affects our constructor again:

func _init(map_data: MapData, start_position: Vector2i, key: String = "") -> void:
	centered = false
	grid_position = start_position
	self.map_data = map_data
	if key != "":
		set_entity_type(key)

We now have a key string we pass into the function. We do need the ability to spawn an entity without a type and then set that type later, as you saw in the restore() function of the inventory component. We encode this with an empty string, in which case we simply don’t set any type.

Let’s clean up the rest of the code to work with these changes. Open dungeon_generator.gd and remove the entity_types constant from the top of the script. Then, in _place_entities() we change the way we instantiate new entities. Here are the relevant parts:

		if can_place:
			var new_entity: Entity
			if _rng.randf() < 0.8:
				new_entity = Entity.new(dungeon, new_entity_position, "orc")
			else:
				new_entity = Entity.new(dungeon, new_entity_position, "troll")
			dungeon.entities.append(new_entity)

		# ...

		if can_place:
			var item_chance: float = _rng.randf()
			var new_entity: Entity
			if item_chance < 0.7:
				new_entity = Entity.new(dungeon, new_entity_position, "health_potion")
			elif item_chance < 0.8:
				new_entity = Entity.new(dungeon, new_entity_position, "fireball_scroll")
			elif item_chance < 0.9:
				new_entity = Entity.new(dungeon, new_entity_position, "confusion_scroll")
			else:
				new_entity = Entity.new(dungeon, new_entity_position, "lightning_scroll")
			dungeon.entities.append(new_entity)

The one extra place we are create an entity is the _ready() function of game.gd, where we create the player:

func new_game() -> void:
	player = Entity.new(null, Vector2i.ZERO, "player")
	# ...

Now back to entity.gd. We are ready to save some data:

func get_save_data() -> Dictionary:
	var save_data: Dictionary = {
		"x": grid_position.x,
		"y": grid_position.y,
		"key": key,
	}
	if fighter_component:
		save_data["fighter_component"] = fighter_component.get_save_data()
	if ai_component:
		save_data["ai_component"] = ai_component.get_save_data()
	if inventory_component:
		save_data["inventory_component"] = inventory_component.get_save_data()
	return save_data

We save the position, the key and info about the three components for which we coded get_save_data() functions. One thing of note here is that JSON does not have a concept of Vector2i, so we save the coordinates individually. Next, the slightly more complicated restore() function:

func restore(save_data: Dictionary) -> void:
	grid_position = Vector2i(save_data["x"], save_data["y"])
	set_entity_type(save_data["key"])
	if fighter_component and save_data.has("fighter_component"):
		fighter_component.restore(save_data["fighter_component"])
	if ai_component and save_data.has("ai_component"):
		var ai_data: Dictionary = save_data["ai_component"]
		if ai_data["type"] == "ConfusedEnemyAI":
			var confused_enemy_ai := ConfusedEnemyAIComponent.new(ai_data["turns_remaining"])
			add_child(confused_enemy_ai)
	if inventory_component and save_data.has("inventory_component"):
		inventory_component.restore(save_data["inventory_component"])

Here, we first restore the position by rebuilding the grid_position vector from the individual coordinates we saved. Then we set the entity type. This tells us that restoring the entity type will be the entities job, and whatever creates the restored entities will create typeless entities (using an empty string as key). Restoring the fighter component and the inventory component is straight forward. We just have to check if these exist so our code works for all kinds of entities. For example items don’t have a fighter component. Where we have to do a bit more work is the AI component. Currently all actor entities will spawn (and therefore restore) with a HostileEnemyAIComponent. That’s good, because that’s the one we have to back up when using the confused AI. So in the case we detect that we have stored a confused AI, we create a new ConfusedEnemyAIComponent and get the number of turns remaining from the save data. Ten we add it as a child, from where that component will handle the rest.

The entities in the inventory will be handled recursively. However, there is an issue. Components get their parent entity when they are added to it, but if we restore entities within the inventory they are not added to the tree, and the _ready() function associating components with their parent entities won’t run. For consumables that is problematic, because when we create item actions they will try to access the parent entity. We can get around that by setting the parent entity manually. In entity.gd modify the handle_consumables() function:

func _handle_consumable(consumable_definition: ConsumableComponentDefinition) -> void:
	if consumable_definition is HealingConsumableComponentDefinition:
		consumable_component = HealingConsumableComponent.new(consumable_definition)
	elif consumable_definition is LightningDamageConsumableComponentDefinition:
		consumable_component = LightningDamageConsumableComponent.new(consumable_definition)
	elif consumable_definition is ConfusionConsumableComponentDefinition:
		consumable_component = ConfusionConsumableComponent.new(consumable_definition)
	elif consumable_definition is FireballDamageConsumableComponentDefinition:
		consumable_component = FireballDamageConsumableComponent.new(consumable_definition)
	
	if consumable_component:
		add_child(consumable_component)
	consumable_component.entity = self

We added a line that directly sets the entity of the consumable_component, and this will work even if the entity isn’t in the scene tree.

The last thing that needs code for saving and restoring is map_data.gd. The MapData will coordinate all the rest of the saving and loading. One last time we implement get_save_data():

func get_save_data() -> Dictionary:
	var save_data := {
		"width": width,
		"height": height,
		"player": player.get_save_data(),
		"entities": [],
		"tiles": []
	}
	for entity in entities:
		if entity == player:
			continue
		save_data["entities"].append(entity.get_save_data())
	for tile in tiles:
		save_data["tiles"].append(tile.get_save_data())
	return save_data

We save the width and height of the map itself. As we treat the player entity in a special way we store its data separately. This also means skipping it when we save the entities array. The other array we save is an array of save data from tiles. Now to restoring that data:

func restore(save_data: Dictionary) -> void:
	width = save_data["width"]
	height = save_data["height"]
	_setup_tiles()
	for i in tiles.size():
		tiles[i].restore(save_data["tiles"][i])
	setup_pathfinding()
	player.restore(save_data["player"])
	player.map_data = self
	entities = [player]
	for entity_data in save_data["entities"]:
		var new_entity := Entity.new(self, Vector2i.ZERO, "")
		new_entity.restore(entity_data)
		entities.append(new_entity)

We restore width and height, after which we can call _setup_tiles(). This fills the map data with the appropriate amount of tiles. We can then restore each tile with the appropriate save data. After that we can call setup_pathfinding() to create our AStar map. Our map data is fed a player from the outside when it is created, so we can directly restore that variable from the save data, and start a new entities array with it. Lastly, we go through all the saved entities, create new ones and restore them with the save data, then add them to the array.

As the MapData class is somewhat of an entry point into our save system we can also give it the responsibility of actually saving to and loading from a file. We start with a save() function:

func save() -> void:
	var file = FileAccess.open("user://save_game.dat", FileAccess.WRITE)
	var save_data: Dictionary = get_save_data()
	var save_string: String = JSON.stringify(save_data)
	var save_hash: String = save_string.sha256_text()
	file.store_line(save_hash)
	file.store_line(save_string)

We first open a file which we will call save_game.dat for writing. If the file doesn’t exist this will create a new one, otherwise it will overwrite an existing save file. Then we get the save data, which we subsequently turn into JSON string. We also compute a hash of that string. We then store the hash in the first line and the save data in a second line. This means that ultimately our save data is just a text file. Now to loading that data again:

func load_game() -> bool:
	var file = FileAccess.open("user://save_game.dat", FileAccess.READ)
	var retrieved_hash: String = file.get_line()
	var save_string: String = file.get_line()
	var calculated_hash: String = save_string.sha256_text()
	var valid_hash: bool = retrieved_hash == calculated_hash
	if not valid_hash:
		return false
	var save_data: Dictionary = JSON.parse_string(save_string)
	restore(save_data)
	return true

First thing to note is the return value, which will indicate if we could successfully load the save. In the function we start by opening the same save_game.dat file for reading. We can get both the saved hash and the save data by querying the two lines of the file. Next we calculate the hash of the save data we just retrieved from the file. If this did not change from the data that we originally saved to the file then this new hash should be the same as the one we stored, so this is what we check. If the hashes differ, we abort and return false, declaring loading a failure. However, if they match then everything should be ok and we can move forward with parsing the retrieved data string into a dictionary, and then using that dictionary to restore our previous game state.

This is the functionality we needed for saving and loading our data. We still need to make sure all the things render properly, which is the responsibility of our map.gd class. To do that we add a new function to it:

func load_game(player: Entity) -> bool:
	map_data = MapData.new(0, 0, player)
	map_data.entity_placed.connect(entities.add_child)
	if not map_data.load_game():
		return false
	_place_tiles()
	_place_entities()
	return true

Again we have the bool that indicates whether loading was successful or not. We create a new map data. Other than in generate() we don’t get it from the dungeon generator. Then we try to load the game. This will restore both the entities and the tiles array on map_data. Just like in generate(), however, the contents of these arrays are not yet in the scene tree, so we call _place_tiles() and _place_entities().

Next we go to game.gd, which will handle the top level code for new games and loading games. If you think about it, we already know how to create a brand new game. It’s what we’ve been doing all the time so far, and it’s what the Game node automatically does upon loading. So if we want a bit more control of when this code is called we can just rename the _ready() function to new_game():

func new_game() -> void:
	player = Entity.new(null, Vector2i.ZERO, "player")
	player_created.emit(player)
	remove_child(camera)
	player.add_child(camera)
	map.generate(player)
	map.update_fov(player.grid_position)
	MessageLog.send_message.bind(
		"Hello and welcome, adventurer, to yet another dungeon!",
		GameColors.WELCOME_TEXT
	).call_deferred()
	camera.make_current.call_deferred()

The alternative is to load a game, which works similarly but has some variation:

func load_game() -> bool:
	player = Entity.new(null, Vector2i.ZERO, "")
	remove_child(camera)
	player.add_child(camera)
	if not map.load_game(player):
		return false
	player_created.emit(player)
	map.update_fov(player.grid_position)
	MessageLog.send_message.bind(
		"Welcome back, adventurer!",
		GameColors.WELCOME_TEXT
	).call_deferred()
	camera.make_current.call_deferred()
	return true

The most important differences are that we return a bool to indicate whether loading was successful. Most of the other code is pretty similar, save for another message and that we don’t let the map generate anew, but load from the saved state, with the code we just implemented above. With that code in place we can go further up the tree, back to game_root.gd. We create two new functions:

func new_game() -> void:
	game.new_game()


func load_game() -> void:
	if not game.load_game():
		main_menu_requested.emit()

Here we handle the possibility for error. The proper way would probably be to display a popup. In our code, we simply return to the main menu if loading a save game fails. We can now call these functions from game_manager.gd. So now, for the proper version of _on_game_requested():

func _on_game_requested(try_load: bool) -> void:
	var game: GameRoot = switch_to_scene(game_scene)
	game.main_menu_requested.connect(load_main_menu)
	if try_load:
		game.load_game()
	else:
		game.new_game()

Depending on which button is pressed in the main menu, we now either create a new map or load an existing one. If you paid attention you might notice that nothing actually causes our game to save yet. We want to do that when exiting to the menu, meaning escape_action.gd is the proper spot for that:

func perform() -> bool:
	entity.map_data.save()
	SignalBus.escape_requested.emit()
	return false

Now we save the game right before we request a change back to the main menu.

There are a few issues that we need to tackle. First, a change to the UI. So far, we created the game and everything as within the Game node’s _ready() function. The way our tree was set up meant that at that point in time not all other nodes were ready themselves. We accounted for this in hp_display.gd, when we called await ready inside initialize(). However, now initialize() gets called after the HP display is already ready. So the function ends up waiting for the ready signal which it already missed, i.e., the function never gets executed. We can mitigate this by extending the function as follows:

func initialize(player: Entity) -> void:
	if not is_inside_tree():
		await ready
	player.fighter_component.hp_changed.connect(player_hp_changed)
	var player_hp: int = player.fighter_component.hp
	var player_max_hp: int = player.fighter_component.max_hp
	player_hp_changed(player_hp, player_max_hp)

Now we only await if the node is not inside the tree, i.e., is not ready yet. (Thinking about this now, after I have already written the code, this clause may be superfluous altogether, and we maybe could have just deleted await ready).

And that is the save system. If you run the game now and click on new game, then exit back to the menu, you should then be able to click on Continue last game and be taken back to the same map as before, with all your items still in your inventory, and so on.

And that concludes the saving and loading part of the tutorial. The next tutorial will be all about levels. Exploring multiple dungeon levels and leveling up your character while you’re at it. You can find that part here: https://selinadev.github.io/15-rogueliketutorial-11/