Yet Another Roguelike Tutorial, Part 11

Publish date: Dec 2, 2023
Tags: godot4 tutorial roguelike

Part 11: Delving into the Dungeon

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:

This part will be all about levels, both descending the levels of the dungeon as well as levelling up the player character.

To start out we add a new color to which we use for the message log when the player descends:

const PLAYER_ATTACK = Color("e0e0e0")
const ENEMY_ATTACK = Color("ffc0c0")
const STATUS_EFFECT_APPLIED = Color("3fff3f")
const DESCEND = Color("9f3fff")

Next, we create a new TileDefinition resource at res://assets/definitions/tiles/tile_definition_down_stairs.tres. Use the same method as before to set the texture to an appropriate icon. I have used pure white for Color Lit and mid grey ("#7f7f7f") for Color Dark. Just as the floor, the down stairs tile Is Walkable and Is Transparent.

We will later need to check if the player is standing on a down stairs tile, so we prepare a variable in to store its location. While we’re at it we’ll also introduce a variable to store the floor level we’re on currently:

var entities: Array[Entity]
var player: Entity
var down_stairs_location: Vector2i
var current_floor: int = 0

We have a save system since the last part, so we need to remember to make these variables persistent. We expand get_save_data() as follows:

func get_save_data() -> Dictionary:
	var save_data := {
		"width": width,
		"height": height,
		"player": player.get_save_data(),
		"current_floor": current_floor,
		"down_stairs_location": {"x": down_stairs_location.x, "y": down_stairs_location.y},
		"entities": [],
		"tiles": []
    # ...

This will make sure the location gets saved, but we also need to retrieve it:

func restore(save_data: Dictionary) -> void:
	width = save_data["width"]
	height = save_data["height"]
	down_stairs_location = Vector2i(save_data["down_stairs_location"]["x"], save_data["down_stairs_location"]["y"])
	current_floor = save_data["current_floor"]
    # ...

And just like that this information will be properly saved and restored. Now we just have to actually create this tile. We do so in, in the generate_dungeon() function. Here is how this function looks now:

func generate_dungeon(player: Entity, current_floor: int) -> MapData:
	var dungeon :=, map_height, player)
	dungeon.current_floor = current_floor
	var rooms: Array[Rect2i] = []
	var center_last_room: Vector2i
	for _try_room in max_rooms:
		var room_width: int = _rng.randi_range(room_min_size, room_max_size)
		var room_height: int = _rng.randi_range(room_min_size, room_max_size)
		var x: int = _rng.randi_range(0, dungeon.width - room_width - 1)
		var y: int = _rng.randi_range(0, dungeon.height - room_height - 1)
		var new_room := Rect2i(x, y, room_width, room_height)
		var has_intersections := false
		for room in rooms:
			if room.intersects(new_room):
				has_intersections = true
		if has_intersections:
		_carve_room(dungeon, new_room)
		center_last_room = new_room.get_center()
		if rooms.is_empty():
			player.grid_position = new_room.get_center()
			player.map_data = dungeon
			_tunnel_between(dungeon, rooms.back().get_center(), new_room.get_center())
		_place_entities(dungeon, new_room)
	dungeon.down_stairs_location = center_last_room
	var down_tile: Tile = dungeon.get_tile(center_last_room)
	return dungeon

The only things we’re really changing here is that we keep track of the center of the last room while generating. Then, once generation of the rooms is complete, we know that that definitely was the last room, so we assign this position to the variable we previously created. After that we get the tile at that position and set it to the down stairs type.

Of course for that to work we need to add our tile definition to the range of available tiles. To do that we append the tile_types constant in by one entry:

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

At this point new dungeons should already get generated with a down stair tile in the “last” room. However, we can’t interact with it yet. To do that we’ll need an appropriate action.

Before that we will quickly configure an entry in the input map to trigger that action. In Project > Project Settings > Input Map add a new input action called “descend”. I configured it to the < key.

Now it’s time to create the action. Create a new script extending Action at res://src/Entities/Actors/Actions/ Here is the code:

class_name TakeStairsAction
extends Action

func perform() -> bool:
	if entity.grid_position == get_map_data().down_stairs_location:
		MessageLog.send_message("You descend the staircase.", GameColors.DESCEND)
		MessageLog.send_message("There are no stairs here.", GameColors.IMPOSSIBLE)
	return false

Inside perform() we first check if the entity is standing on the stairs location. If they are, we emit a signal via the SignalBus that the player descended. We use a signal here because we will handle our next level code in the Map node, and getting that information there would be cumbersome otherwise. We also print a message to the log, telling the player that they have descended. In the else branch we just inform the player that they need to find stairs to perform that action. Regardless of whether we can descend or not we return false. If we don’t, then it shouldn’t cost the player a turn, and if we do we want the player to take the first turn on the new floor.

We can handle this action in res://src/Game/EventHandlers/, in the get_action() function:

	if Input.is_action_just_pressed("look"):
		await get_grid_position(player, 0)
	if Input.is_action_just_pressed("descend"):
		action =

As mentioned above we extend the list of signals in

signal player_died
signal player_descended
signal message_sent(text, color)
signal escape_requested

In we will connect this signal to a function we’ll shortly write:

func _ready() -> void:

Here is the corresponding next_floor() function:

func next_floor() -> void:
	var player: Entity = map_data.player
	for entity in entities.get_children():
	for tile in tiles.get_children():
	generate(player, map_data.current_floor + 1)

First, we obtain a reference to the player, because they are the only entity we need to keep between levels. We remove the player from the entities node, before freeing all the existing entity and tile nodes. We then use the generate() function to simply overwrite our map data, automatically placing our player in the new map. The player’s camera had left the tree momentarily, so we need to set it as the current camera again. Lastly, we need to handle the field of view.

There are two changes here we need to account for. We call generate() with an additional parameter, so that the floor number increases. Also we have a new reset_fov() function the FieldOfView node. Let’s implement the latter first. In add the following function:

func reset_fov() -> void:
	_fov = []

This simply clears the _fov array which would otherwise still hold references to freed nodes, and we need to make sure we don’t try to access them anymore.

Back in we change generate() as follows:

func generate(player: Entity, current_floor: int = 1) -> void:
	map_data = dungeon_generator.generate_dungeon(player, current_floor)
	if not map_data.entity_placed.is_connected(entities.add_child):

We now have a current_floor argument, which we hand over to the dungeon generator. We use a default argument of 1, so when we start a new game and this function gets called as usual without the extra argument we’ll start on floor 1. Also we’ll emit a new signal, which we’ll use for hooking up information about the dungeon depth with the gui in a moment. We need to add that signal as well:

class_name Map
extends Node2D

signal dungeon_floor_changed(floor)

var map_data: MapData

We’ve seen that generate() in the Map node delegates to generate_dungeon() in the DungeonGenerator. We still need to handle information about the current floor in

func generate_dungeon(player: Entity, current_floor: int) -> MapData:
	var dungeon :=, map_height, player)
	dungeon.current_floor = current_floor

We had to change things at a few places, but that’s it. Stairs are generated in the dungeon generation, the player can interact with them, and descending the stairs will drop the player in a brand new map.

The player will want to know what floor they’re on. To do that we add a new node of type Label under InfoBar/StatsPanel/VBoxContainer, right after the HpDisplay node. We’ll call that new label DungeonFloorLabel, and as usual we’ll add some LabelSettings to it which we will fill with our font. We need a tiny bit of functionality, so we’ll add a script to the node and save it at res://src/GUI/ Here’s that script:

extends Label

func set_dungeon_floor(current_floor: int) -> void:
	text = "Dungeon Level: %d" % current_floor

It’s not a lot, but it allows us to just call this function and supply the floor number, and the label will know what to do with that. Now on the Map node find the signals tab and double click on our dungeon_floor_changed signal. Select the DungeonFloorLabel node in the window that popped up and then click on Pick to the lower right. Select the set_dungeon_floor() function and click on ok. Now, whenever the generate() function on the Map node is executed, the gui will update automatically to reflect the current floor.

There’s still one problem with this approach. When we save the game and then load it, we won’t call generate(). That means we need to emit the signal from the load_game() function in as well:

func load_game(player: Entity) -> bool:
	map_data =, 0, player)
	if not map_data.load_game():
		return false
	return true

And now dungeon levels should work properly. Try it out, if you like. Of course, right now that basically just means “more map”. We’ll handle how to do increasing risk and reward in the next part. For now, however, we’ll add another type of levels: character levels.

We’ll create a new component that handles both gaining experience points as well as awarding them on death (in case of the enemies). Once enough xp have accumulated, this component will handle leveling up, which will increase the stats of the fighter component. Again, we will start with the data part, i.e., a definition. Create a new script extending Resource at res://src/Entities/Actors/Components/ComponentDefinitions/ Add the following code to it:

class_name LevelComponentDefinition
extends Resource

@export var level_up_base: int = 0
@export var level_up_factor: int = 150
@export var xp_given: int = 0

We define three variables. The first two are parts of our level equation. They’ll allow the level component to calculate how many xp are necessary to reach each level. The last variable defines how much xp an entity will award when it dies.

Now onto the level component itself. This one is a bit more extensive, so we’ll go through it bit by bit. First, create a new script extending Component at res://src/Entities/Actors/Components/ Here’s the top of the script:

class_name LevelComponent
extends Component

signal level_up_required
signal leveled_up
signal xp_changed(xp, max_xp)

var current_level: int = 1
var current_xp: int = 0
var level_up_base: int
var level_up_factor: int
var xp_given: int

We have three signals. The first one is emitted when we have enough xp for a level up, the second one when we actually level up. The player will get a choice with every level up, so we need to separate those steps. The last signal is emitted when we gain xp. We’ll use these signals to properly communicate with the gui.

Next we have two variables holding information about the current level and current xp, as well as three variables corresponding to the ones from the level definition.

func _init(definition: LevelComponentDefinition) -> void:
	level_up_base = definition.level_up_base
	level_up_factor = definition.level_up_factor
	xp_given = definition.xp_given

func get_experience_to_next_level() -> int:
	return level_up_base + current_level * level_up_factor

func is_level_up_required() -> bool:
	return current_xp >= get_experience_to_next_level()

The _init() function simply initializes the variables just mentioned from the definition, just as we did for other components. Next are two very simple functions. One calculates the xp required for the next level, the other can tell us if we already fulfill the requirements for a level up. The first of these is the one you’ll want to change if you want another type of function for the level progression. Currently we have a linear function, but certain kinds of polynomial or exponential functions are also popular.

func add_xp(xp: int) -> void:
	if xp == 0 or level_up_base == 0:
	current_xp += xp
	MessageLog.send_message("You gain %d experience points." % xp, Color.WHITE)
	xp_changed.emit(current_xp, get_experience_to_next_level())
	if is_level_up_required():
		MessageLog.send_message("You advance to level %d!" % (current_level + 1), Color.WHITE)

Here we have a function we’ll call when we add xp. We first check whether adding xp even makes sense (e.g., we don’t want a message telling us we gained 0 xp). We add the xp and inform the player in the message log, and inform the gui via the signal. After that we check whether we already meet the requirements for the next level. If we do, we also log that to the message log, and emit the appropriate signal for that. We’ll see later how we’ll handle that signal.

func increase_level() -> void:
	current_xp -= get_experience_to_next_level()
	current_level += 1

This function does the actual leveling up. It subtracts the xp necessary to get to the next level. We subtract from the current xp rather than set them to 0, because if the player just got xp and overshot the requirement for the next level we don’t want to let those overshooting xp go to waste.

func increase_max_hp(amount: int = 20) -> void:
	var fighter: FighterComponent = entity.fighter_component
	fighter.max_hp += amount
	fighter.hp += amount
	MessageLog.send_message("Your health improves!", Color.WHITE)

func increase_power(amount: int = 1) -> void:
	var fighter: FighterComponent = entity.fighter_component
	fighter.power += amount
	MessageLog.send_message("You feel stronger!", Color.WHITE)

func increase_defense(amount: int = 1) -> void:
	var fighter: FighterComponent = entity.fighter_component
	fighter.defense += amount
	MessageLog.send_message("Your movements are getting swifter!", Color.WHITE)

When the player levels up they will get to choose which stat they want to increase. Once they do we’ll call one of these functions. They all get the fighter, increase the stat, inform the player via the message log and the gui via a signal, so that it can update the display of the current stats (which we still have to implement).

func get_save_data() -> Dictionary:
	return {
		"current_level": current_level,
		"current_xp": current_xp,
		"level_up_base": level_up_base,
		"level_up_factor": level_up_factor,
		"xp_given": xp_given

func restore(save_data: Dictionary) -> void:
	current_level = save_data["current_level"]
	current_xp = save_data["current_xp"]
	level_up_base = save_data["level_up_base"]
	level_up_factor = save_data["level_up_factor"]
	xp_given = save_data["xp_given"]

Lastly we ensure the player will keep their hard earned levels and xp after they save and load the game. That’s the level component done, now we have to add it to

var fighter_component: FighterComponent
var ai_component: BaseAIComponent
var consumable_component: ConsumableComponent
var inventory_component: InventoryComponent
var level_component: LevelComponent

Add the following to set_entity_type():

	if entity_definition.level_info:
		level_component =

Of course, the entity also needs to make sure to include this new component in its save data. In get_save_data():

	if inventory_component:
		save_data["inventory_component"] = inventory_component.get_save_data()
	if level_component:
		save_data["level_component"] = level_component.get_save_data()
	return save_data

And at the end of restore():

	if inventory_component and save_data.has("inventory_component"):
	if level_component and save_data.has("level_component"):

We also need to add a new slot to

@export var fighter_definition: FighterComponentDefinition
@export var ai_type: Entity.AIType
@export var consumable_definition: ConsumableComponentDefinition
@export var inventory_capacity: int = 0
@export var level_info: LevelComponentDefinition

Now it’s time to go over our entities again and add some level definitions. Add a new LevelComponentDefinition in Level Info of entity_definition_player.tres. Set the Level Up Base to 200.

Add such a component to the definitions for the orc and troll as well. However, here we will only change the Xp Given. The orc will give 35 xp, and the troll will give 100 xp.

The game still doesn’t do anything with these xp though. To change that we need to handle xp in the die() function of

func die(trigger_side_effects := 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
		death_message = "%s is dead!" % entity.get_entity_name()
		death_message_color = GameColors.ENEMY_DIE
	if trigger_side_effects:
		MessageLog.send_message(death_message, death_message_color)
	entity.texture = death_texture
	entity.modulate = death_color
	entity.ai_component = null
	entity.entity_name = "Remains of %s" % entity.entity_name
	entity.blocks_movement = false
	entity.type = Entity.EntityType.CORPSE

I have changed the name of the log_message parameter we introduced last time to trigger_side_effects, to be a bit more clear, as we will use that same parameter to decide whether we should give the player xp. We don’t want to give the player a bunch of xp from just loading the game. In the if trigger_side_effects block you see a new line of code next to the message log. it retrieves the player’s level component and calls add_xp() on it with the xp the dying entity’s level component defines.

I want to take the opportunity here to point out that this code is getting a bit more “fragile” than I would like it to be. It should work, but I fear that it might easily break. However, I don’t want to get too verbose here with error handling, and I don’t know what would be a better solution. The issue here is that components should facilitate composition in a very loosely coupled way. There may be dependencies, right now we’re not handling those very well. If we create an entity that has a fighter component but no level component, we would expect this combination to result in the player not getting xp when the entity died. However, if you look at the code above, you will see that such setup would simply crash the game, because the fighter component expects its entity to have a valid level component. As long as we keep to the rules we set up and give every entity with a fighter component a level component, this will work just fine. Still, if you work on a bigger game I would recommend thinking a bit about better decoupling and dependency handling.

Back to our tutorial. The logic of leveling works fine, but we need some gui to tie it together. Firstly, we’ll need a screen to handle the player’s choice when leveling up. Secondly, we want to show the player their current level, xp, etc. We’ll start with the level up menu. Create a new scene with a CanvasLayer node as its root, which we will call LevelUpMenu. Save this scene under res://src/GUI/LevelUpMenu/level_up_menu.tscn.

Let’s start with the scene structure. Add a CenterContainer to the root node. Add a PanelContainer to the CenterContainer node. Add a VBoxContainer to the PanelContainer. The following nodes will all be children of that VBoxContainer. Add two Label nodes, a HSeparator. To save us some work, open the inventory_menu_item.tscn scene, copy the InventoryMenuItem button from there and add it to our level up menu scene as well. Copy it twice, so you have three buttons.

Now to configuring the nodes. Set the Anchor Preset of CenterContainer to Full Rect. Open the inventory_menu.tscn scene and select the PanelContainer there. Under Theme Overrides > Styles > Panel copy the existing style box resource. Then, back in the level up menu scene paste this in the same slot on the PanelContainer there. The label will get the same configuration we have used previously (you could simply copy the Label Settings from another scene as well). Set the labels Horizontal Alignment and Vertical Alignment both to Center. Set the text of the first label to “Level Up”, and the text of the second one to “Congratulations! You level up! Select an attribute to increase.”. On the HSeparator we go into Theme Overrides. Set Constants > Separation to 0, and in Styles > Separator create a new StyleBoxLine. We only need to change the Color on that style box to white.

Now for the buttons. We rename the first one to HealthUpgradeButton, the second one to PowerUpgradeButton and the third one to DefenseUpgradeButton. We already have shortcuts on the button defined, however, currently the buttons share that resource. Therefore, we need to right click the Shortcut resource and select Make Unique. Do the same with the InputEventKey resource inside the shortcut. Then change the first button’s shortcut key to a, the second one’s to b, and the third one’s to c. As a las step set all three buttons to Access as Unique Name in the scene tree.

Now create a new script on the LevelUpMenu node, and save it at res://src/GUI/LevelUpMenu/ Here’s the top of the script:

class_name LevelUpMenu
extends CanvasLayer

signal level_up_completed

var player: Entity

@onready var health_upgrade_button: Button = $"%HealthUpgradeButton"
@onready var power_upgrade_button: Button = $"%PowerUpgradeButton"
@onready var defense_upgrade_button: Button = $"%DefenseUpgradeButton"

We need to keep a reference to the player, and to the three buttons. Next we’ll have a setup() function.

func setup(player: Entity) -> void:
	self.player = player
	var fighter: FighterComponent = player.fighter_component
	health_upgrade_button.text = "(a) Constitution (+20 HP, from %d)" % fighter.max_hp
	power_upgrade_button.text = "(b) Strength (+1 attack, from %d)" % fighter.power
	defense_upgrade_button.text = "(c) Agility (+1 defense, from %d)" % fighter.defense

We get the player and obtain its fighter component. We’ll use that to create the texts of the buttons, so that they also display the current value of the stat they improve. Next, on each of the buttons connect the button_pressed signal to this script. We’ll fill the created functions with the following code:

func _on_health_upgrade_button_pressed() -> void:

func _on_power_upgrade_button_pressed() -> void:

func _on_defense_upgrade_button_pressed() -> void:

Depending on which button was pressed we will call the corresponding function on the level component. After that each of the buttons will call queue_free(), deleting the level up menu, and then emit the level_up_completed signal. Now we need a place to bind all these signals together. We will do that in the Game node. Inside we add a new constant:

const level_up_menu_scene: PackedScene = preload("res://src/GUI/LevelUpMenu/level_up_menu.tscn")

We will use this in the following new function:

func _on_player_level_up_requested() -> void:
	var level_up_menu: LevelUpMenu = level_up_menu_scene.instantiate()
	await level_up_menu.level_up_completed

We instantiate the level up menu, add it as a child and set it up with the player. Then we disable physics processing. As the code getting actions is called in the physics processing this will interrupt the game (and is by the way a much cleaner way than the dummy state I have used previously). We wait until we get the level_up_completed signal from the level up menu, after which we enable physics processing, and thereby our game loop, again. Now we need to make sure this function gets called when the player needs a level up. We connect it in new_game():

func new_game() -> void:
	player =, Vector2i.ZERO, "player")

Of course, we need to make sure its also connected when loading a game:

func load_game() -> bool:
	player =, Vector2i.ZERO, "")
	if not map.load_game(player):
		return false

At this point the player is ready to level up and increase their stats. However, we also need to show the player how much xp they still need. And while we’re at it we’ll also show them their stats.

Still in game.tscn duplicate the HpDisplay node. Rename the new node to XpDisplay, and its children to XpBar and XpLabel. Make those two children accessible as unique names. To differentiate the two bars by color go into Theme Overrides > Styles on the XpBar. Make sure to right click both style boxes and select Make Unique. Then change the color on each. For the Background I chose a mid blue (#00007f), and for the Fill a light blue (#0000ff). Remove script from XpLabel and create a new one on it, which we’ll save at res://src/GUI/ The logic is very similar to the HpDisplay, we just need to listen for different signals and take our initial data from a different source:

extends MarginContainer

@onready var xp_bar: ProgressBar = $"%XpBar"
@onready var xp_label: Label = $"%XpLabel"

func initialize(player: Entity) -> void:
	if not is_inside_tree():
		await ready
	var player_xp: int = player.level_component.current_xp
	var player_max_xp: int = player.level_component.get_experience_to_next_level()
	player_xp_changed(player_xp, player_max_xp)

func player_xp_changed(xp: int, max_xp: int) -> void:
	xp_bar.max_value = max_xp
	xp_bar.value = xp
	xp_label.text = "XP: %d/%d" % [xp, max_xp]

Under the same VBoxContainer, between XpDisplay and DungeonFlorLabel we’ll create a new HBoxContainer, which we’ll call CharacterInfoBox. We create three Label nodes as children, which we call LevelLabel, AttackLabel, and DefenseLabel. Set them up just like all the other labels, with a LabelSettings resource pointing to the usual font. Also set both their Alignment properties to Center, and tick the Expand box under Layout > Container Sizing > Horizontal. Now create a new script on CharacterInfoBox, and save it at res://src/GUI/ Here is that script:

extends HBoxContainer

var _player: Entity

@onready var level_label: Label = $LevelLabel
@onready var attack_label: Label = $AttackLabel
@onready var defense_label: Label = $DefenseLabel

func setup(player: Entity) -> void:
	_player = player

func update_labels() -> void:
	level_label.text = "LVL: %d" % _player.level_component.current_level
	attack_label.text = "ATK: %d" % _player.fighter_component.power
	defense_label.text = "DEF: %d" % _player.fighter_component.defense

We have an update_labels() function, which pulls data from the level and fighter components of the player. In the setup() function we get a reference to the player, and also connect its level component’s leveled_up signal to our update function.

Now we have two new UI elements that still need to be initialized with the player. Select the Game node and go into the Node > Signals tab. The player_created signal should already be connected to the initialize() function of the HpDisplay. Now we connect the same signal to the initialize() function of the XpDisplay and the setup() function of the CharacterInfoBox (the reason these functions have different names is that I did not pay enough attention to consistency). And now the new UI elements are connected to the game. You should now be able to run it, and see your XP increase after killing some monsters. At some point you should then see our level up screen pop up.

This concludes this part of the tutorial. If you are stuck and can’t get the code to run you can find the complete project in the accompanying GitHub repository:

Our character can now go deeper into the dungeon. However, right now all the floors are identical in their challenge. The RNG will mean that they will encounter different mixtures of monsters and items, but it still lacks progression. So next time we will look at a way to increase both the difficulty and the reward the deeper the player goes. You can find the next part here: