Yet Another Roguelike Tutorial, Part 6

Publish date: Jul 28, 2023
Tags: godot4 tutorial roguelike

Part 6: Doing (and taking) some damage

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/09-rogueliketutorial-05/

Note on Godot Version

Please make sure to use (at least) Godot 4.1. The previous tutorials should also work with Godot 4.0, but this part utilizes the AStarGrid2D, which has changed slightly from 4.0 to 4.1, meaning that this part (and all further parts, as they build on this one) will not work in 4.0.

Refactoring

At this point the original tutorial refactors some code, and we will do so as well. Some things will turn out cleaner this way (and I will have to keep less differences in mind when converting the code).

First, let’s start with the action system. We won’t really change how we use it now, but in principle the Command Pattern allows delaying a command (or action as we call it here). We only delay it very little, creating an action in one function and executing it once it’s returned. That’s why we can easily keep track of the entity associated with that action, and therefore can pass it once we call perform(). But a better way would be to encode the entity right in the action, so once we have the action we only need to call perform(), without any arguments, and whatever action we encoded before just happens.

Now, we passed a Game argument into actions before. That was easy, as the Game node was the one executing the actions. However, getting that at the time of construction would be a bit wonky. Sure, we could pass a reference to the game to the EventHandler node. However, we only really needed the game to get access to the map_data. So the simplest way forward is to give entities a reference to the map data. We need to know the entity anyway for the action, so that’s a good place to put that information. So let’s adapt entity.gd a bit:

var _definition: EntityDefinition
var map_data: MapData

func _init(map_data: MapData, start_position: Vector2i, entity_definition: EntityDefinition) -> void:
	centered = false
	grid_position = start_position
	self.map_data = map_data
	set_entity_type(entity_definition)

In our variables we simply create a new variable to hold the map_data, which is filled in the constructor. The first entity created is the player, and we need to handle them a bit separately. We change how the player is created in game.gd in the _ready() function:

func _ready() -> void:
	player = Entity.new(null, Vector2i.ZERO, player_definition)
	remove_child(camera)
	player.add_child(camera)
	map.generate(player)
	map.update_fov(player.grid_position)

The player is a bit of an odd case, as we create it before we create the map. We therefore pass null here, and set the map_data manually later. We do so in dungeon_generator.gd, when we place the player in generate_dungeon():

if rooms.is_empty():
    player.grid_position = new_room.get_center()
    player.map_data = dungeon
else:
    _tunnel_between(dungeon, rooms.back().get_center(), new_room.get_center())

Here we simply add a line where we set the player’s map_data to dungeon, which is the MapData object the game will use. In the same script we also modify _place_entities() which is responsible for instantiating new enemies:

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

Here we just modified the calls to Entity.new() to also pass dungeon. With that out of the way we can get to the actual actions we wanted to modify. Go into action.gd and replace it with the following:

class_name Action
extends RefCounted

var entity: Entity


func _init(entity: Entity) -> void:
	self.entity = entity


func perform() -> void:
	pass


func get_map_data() -> MapData:
	return entity.map_data

We add a new variable to hold the entity performing the action. We pass this entity during the creation of the action, in the new _init() function. perform(), however, now loses all its arguments. Lastly, we create a convenient way to get the map_data from the entity, by creating the get_map_data() function. Next we have to go over all the actions we have and modify them to this new interface. Here’s escape_action.gd:

class_name EscapeAction
extends Action


func perform() -> void:
	entity.get_tree().quit()

As mentioned, we remove the arguments and simply call get_tree().quit() from the entity. Here is the new action_with_direction.gd:

class_name ActionWithDirection
extends Action

var offset: Vector2i


func _init(entity: Entity, dx: int, dy: int) -> void:
	super._init(entity)
	offset = Vector2i(dx, dy)


func get_destination() -> Vector2i:
	return entity.grid_position + offset


func get_blocking_entity_at_destination() -> Entity:
	return get_map_data().get_blocking_entity_at_location(get_destination())

Here, we modify the _init() function to also take in the entity as an argument. As we overload the Action constructor here, we must explicitly call super._init(entity). Beyond that we create two more convenience functions, one to calculate the destination coordinates, and one to get a blocking entity from that destination. Next, melee_action.gd:

class_name MeleeAction
extends ActionWithDirection


func perform() -> void:
	var target: Entity = get_blocking_entity_at_destination()
	if not target:
		return
		
	print("You kick the %s, much to it's annoyance!" % target.get_entity_name())

Here we get to use get_blocking_entity_at_location() to write our code a bit more concisely. We do a similar thing in movement_action.gd:

class_name MovementAction
extends ActionWithDirection


func perform() -> void:
	var map_data: MapData = get_map_data()
	var destination_tile: Tile = map_data.get_tile(get_destination())
	if not destination_tile or not destination_tile.is_walkable():
		return
	if get_blocking_entity_at_destination():
		return
	entity.move(offset)

A bit different, but ultimately nothing special here. Lastly, bump_action.gd:

class_name BumpAction
extends ActionWithDirection


func perform() -> void:
	if get_target_actor():
		MeleeAction.new(entity, offset.x, offset.y).perform()
	else:
		MovementAction.new(entity, offset.x, offset.y).perform()

With our actions modified, let’s now modify EventHandler.gd where the actions are generated. The first thing we will do is rename it. It took me until now to notice that the EventHandler class name is overloading an internal class of Godot. Also, I messed up when creating the file, and used the wrong capitalization. So first let’s rename the file to input_handler.gd. Then, modify that script as follows:

class_name InputHandler
extends Node


func get_action(player: Entity) -> Action:
	var action: Action = null
	
	if Input.is_action_just_pressed("ui_up"):
		action = BumpAction.new(player, 0, -1)
	elif Input.is_action_just_pressed("ui_down"):
		action = BumpAction.new(player, 0, 1)
	elif Input.is_action_just_pressed("ui_left"):
		action = BumpAction.new(player, -1, 0)
	elif Input.is_action_just_pressed("ui_right"):
		action = BumpAction.new(player, 1, 0)
	
	if Input.is_action_just_pressed("ui_cancel"):
		action = EscapeAction.new(player)
	
	return action

Now that we changed the class_name we no longer have that name collision. Now, in our our main scene also change the name of the EventHandler node to InputHandler. Also, we now pass the player into get_action() and use it to create our actions. The last piece to this puzzle is to modify game.gd. First, we need to reflect the changed class name where we type the variables:

@onready var player: Entity
@onready var input_handler: InputHandler = $InputHandler
@onready var map: Map = $Map
@onready var camera: Camera2D = $Camera2D

Here we change all the event handler naming to input handler. Now we need to modify _physics_process():

func _physics_process(_delta: float) -> void:
	var action: Action = event_handler.get_action()
	if action:
		var previous_player_position: Vector2i = player.grid_position
		action.perform()
		_handle_enemy_turns()
		map.update_fov(player.grid_position)

Here we change action.perform(self, player) to action.perform(), as the action already knows everything it needs to perform itself. Now that we have the refactoring out of the way we can move on to …

Part 6

In this tutorial we will create our combat system. This will include things like health and dealing damage. This also includes a (very simple) enemy AI. We will do this by composition. Entities will be able to hold several components that extend their functionality. A Fighter component will hold health and similar. And an AI component will handle the actions the enemies will perform.

One deviation from the original tutorial will be that I will omit the Actor class. I wasn’t sure which approach I preferred, because the other option is to extend our Entity class by some functions that don’t really make sense for non-actors. However, strict inheritance is also limiting. A fighter component allows an entity to have a health stat, allows it to be attacked by other entities, and allows it to be destroyed. If we wanted to have, say, an obstacle like a barrel, or a door, then that’s just what we would want of this. If we were to go the inheritance route we would need to make that barrel an actor, even though it won’t act. We won’t create things like barrels or doors in this tutorial series, but extensibility is still something I want to consider. Therefore I prefer to just use composition, to give every entity the option to have any component. This means we have to give things like the is_alive() function to all entities, but technically we can ask if, for example a barrel, is alive. You notice though that I still use the actor terminology in the directory structure though, just to organize things a bit better.

So let’s start with components. We will start with a simple base component. Create a new script extending Node at res://src/Entities/Actors/Components/base_component.gd. It’ll look like this:

class_name Component
extends Node

@onready var entity: Entity = get_parent() as Entity


func get_map_data() -> MapData:
	return entity.map_data

We use an @onready variable to get the entity the component will be attached to. The components will be attached directly to entities, so we can just get the parent. We will also very often need access to the map_data, so we again create our familiar convenience function. Now let’s create an actually useful component. Create a new script extending Component at res://src/Entity/Actors/Components/fighter_component.gd. Here’s the code for that component:

class_name FighterComponent
extends Component

var max_hp: int
var hp: int:
	set(value):
		hp = clampi(value, 0, max_hp)
var defense: int
var power: int


func _init(definition: FighterComponentDefinition) -> void:
	max_hp = definition.max_hp
	hp = definition.max_hp
	defense = definition.defense
	power = definition.power

A fighter component holds the maximum health points, current health points, defense, and power of the entity. The hp variable representing the current health points has a setter that ensures that health point values stay between 0 and max_hp. You also see that all these values are initialized from a FighterComponentDefinition, an approach we already used for entities, as well as tiles. You will soon see why using this resource-driven approach is especially useful, but first we need to create that resource type. Create a new script extending Resource at res://src/Entities/Actors/Components/ComponentDefinitions/fighter_component_definition.gd. It will hold the initial values for a FighterComponent:

class_name FighterComponentDefinition
extends Resource

@export_category("Stats")
@export var max_hp: int
@export var power: int
@export var defense: int

Before we attach things to our entities, let’s handle the AI. However, there are a few prerequisites for that as well. The AI will use pathfinding, so we want a system for that. Pathfinding will be the responsibility of the MapData class. So let’s modify map_data.gd a bit:

const entity_pathfinding_weight = 10.0

var width: int
var height: int
var tiles: Array[Tile]
var entities: Array[Entity]
var player: Entity
var pathfinder: AStarGrid2D

We add two things at the start of the script. We create a new constant called entity_pathfinding_weight. This is the weight we use for blocking entities. These entities block actual movement, and we want other entities to try pathfinding around them wherever possible, but we don’t actually want to block pathfinding through them. Suppose a player flees into a corridor, followed by two orcs. If the orc closer to the player would block the other orc’s pathfinding, that other orc would seemingly run away from the action while he tries to find a way to the players position through any other corridor and room. By blocking entities just increasing the weight for the pathfinding, the orc will go around the other orc when it can, but queue up nicely behind the other orc if other paths would be too long. Apart from the pathfinding weight we also add a pathfinder variable of type AStarGrid2D. This version of the A* algorithm makes pathfinding on a grid pretty easy. Now let’s add some functions to manage the pathfinder:

func register_blocking_entity(entity: Entity) -> void:
	pathfinder.set_point_weight_scale(entity.grid_position, entity_pathfinding_weight)


func unregister_blocking_entity(entity: Entity) -> void:
	pathfinder.set_point_weight_scale(entity.grid_position, 0)

We have two functions to register and unregister blocking entities on the pathfinder. The register function tells the pathfinder that the entity is there by setting the pathfinding weight on the entities position to entity_pathfinding_weight, just as we described above. To tell the pathfinder the entity isn’t there anymore, we use the unregister function, which sets the weight back to 0. However, we still need to setup the pathfinder properly, so it knows which tiles are walkable and which are not.

func setup_pathfinding() -> void:
	pathfinder = AStarGrid2D.new()
	pathfinder.region = Rect2i(0, 0, width, height)
	pathfinder.update()
	for y in height:
		for x in width:
			var grid_position := Vector2i(x, y)
			var tile: Tile = get_tile(grid_position)
			pathfinder.set_point_solid(grid_position, not tile.is_walkable())
	for entity in entities:
		if entity.is_blocking_movement():
			register_blocking_entity(entity)

Here we first create a new AStarGrid2D instance and set it to the dimensions of the map (which requires us to call update()). We then go through the whole map. Unfortunately we can’t just loop through the tiles, which is why we have nested loops for y and x. We then have a position, we retrieve a tile, and subsequently set the set that point in the pathfinder to solid if the tile is walkable. After that we go through all the entities and register them if they are blocking entities. You see that this kind of setup requires a finished dungeon. That’s why we need to call it in dungeon_generator.gd, at the end just before the return statement of generate_dungeon():

    if rooms.is_empty():
        player.grid_position = new_room.get_center()
        player.map_data = dungeon
    else:
        _tunnel_between(dungeon, rooms.back().get_center(), new_room.get_center())
    
    _place_entities(dungeon, new_room)
    
    rooms.append(new_room)

dungeon.setup_pathfinding()
return dungeon

The last thing we need to do is some management of reflecting moving entities in the pathfinder. We can do this in entity.gd:

func move(move_offset: Vector2i) -> void:
	map_data.unregister_blocking_entity(self)
	grid_position += move_offset
	map_data.register_blocking_entity(self)

Now we unregister the entity from its current position before it moves, then we actually move it, followed by registering it at the new position. Now the pathfinder will properly be updated to the current state of the game. With all that in place, we can now move to the AI.

We start with a base AI by creating a new script extending Component at res://src/Entities/Actors/Components/base_ai_component.gd. It will simply define the functions all AIs will have:

class_name BaseAIComponent
extends Component


func perform() -> void:
	pass


func get_point_path_to(destination: Vector2i) -> PackedVector2Array:
	return get_map_data().pathfinder.get_point_path(entity.grid_position, destination)

We will have a perform() function that will work similar to actions (the original tutorial actually uses multiple inheritance to have the AI also extend the Action class, but this will work just as well). We also have a get_point_path_to() function which gives us a path from one point on the map to another, using our new pathfinding functionality. With all the work we already did this is thankfully just a one-liner. This isn’t an exciting AI yet, so let’s make an actually useful one. Create a new script extending BaseAIComponent at res://src/Entities/Actors/Components/hostile_enemy_ai_component.gd. The AI will look as follows:

class_name HostileEnemyAIComponent
extends BaseAIComponent


var path: Array = []

func perform() -> void:
	var target: Entity = get_map_data().player
	var target_grid_position: Vector2i = target.grid_position
	var offset: Vector2i = target_grid_position - entity.grid_position
	var distance: int = max(abs(offset.x), abs(offset.y))
	
	if get_map_data().get_tile(entity.grid_position).is_in_view:
		if distance <= 1:
			return MeleeAction.new(entity, offset.x, offset.y).perform()
		
		path = get_point_path_to(target_grid_position)
		path.pop_front()
	
	if not path.is_empty():
		var destination := Vector2i(path[0])
		if get_map_data().get_blocking_entity_at_location(destination):
			return WaitAction.new(entity).perform()
		path.pop_front()
		var move_offset: Vector2i = destination - entity.grid_position
		return MovementAction.new(entity, move_offset.x, move_offset.y).perform()
	
	return WaitAction.new(entity).perform()

We first need to store a path. This is kind of the memory of the entity. If the player is in view it will update the path variable, but if it looses sight it will follow its current path to the position it last saw the player. Let’s look at perform in detail:

var target: Entity = get_map_data().player
var target_grid_position: Vector2i = target.grid_position
var offset: Vector2i = target_grid_position - entity.grid_position
var distance: int = max(abs(offset.x), abs(offset.y))

Our target will always be the player. We retrieve it and get its position. We then calculate the offset, i.e. the relative position from the entity to the player, and the distance between them (here as Chebyeshev distance, i.e., moving horizontally, vertically, or diagonally all takes one step).

if get_map_data().get_tile(entity.grid_position).is_in_view:
    if distance <= 1:
        return MeleeAction.new(entity, offset.x, offset.y).perform()
    
    path = get_point_path_to(target_grid_position)
    path.pop_front()

We then check if the entity is in view of the player. If the player can see the enemy, the enemy also can see the player, so in that case we want to act accordingly. If the distance is 1 or less, meaning the enemy is standing right next to the player, it will hit the player by creating a new MeleeAction and perform it. If we are further away than that, we request a new path from the pathfinder. We then need to remove the first element of that path, because that is the tile the enemy already stands on, and we only want the next tiles we need to go to.

if not path.is_empty():
		var destination := Vector2i(path[0])
		if get_map_data().get_blocking_entity_at_location(destination):
			return WaitAction.new(entity).perform()
		path.pop_front()
		var move_offset: Vector2i = destination - entity.grid_position
		return MovementAction.new(entity, move_offset.x, move_offset.y).perform()
	
	return WaitAction.new(entity).perform()

Then, regardless of whether or not the enemy sees the player, it will try moving along the path. If the path isn’t empty, we get the next tile to move to from it. Of course we need to check whether the entity will be able to move there, otherwise we don’t want to consume a step in the path. If a horde of enemies is chasing the player, then they might block each other. If the entity would be blocked, we make it wait a turn. Otherwise we can use up the next path seep and calculate how we need to move, i.e., the move_offset, and then create and perform the corresponding MovementAction. Lastly, if we have not returned yet by either attacking or moving we create and perform a WaitAction. We haven’t created that one yet, so let’s do so now. Create a new script extending Action at res://src/Entities/Actors/Actions/wait_action.gd, and fill it with this very simple code:

class_name WaitAction
extends Action

func perform() -> void:
	pass

Unsurprisingly the WaitAction causes the entity to do nothing. Let’s now turn our attention to the Entity so it can hold and manage those components. For that we start by expanding entity_definition.gd a bit:

@export_category("Components")
@export var fighter_definition: FighterComponentDefinition
@export var ai_type: Entity.AIType

We create a new category for components. The fighter_definition will be a FighterComponentDefinition. The neat thing I mentioned earlier is that this now can be a sub-resource. Whenever we create an entity definition we can fill this variable with a new resource we can edit right in that same inspector window. And if we don’t want the entity to have such a component we leave that field empty. This makes this very flexible. We also have an ai_type variable. This variable refers to an enum we will create shortly. The reason we don’t have a AiComponentDefinition is that we will only a small number of fixed AIs, so we just need a way to know which of these to instantiate. Now to entity.gd:

class_name Entity
extends Sprite2D

enum AIType {NONE, HOSTILE}

Create a new enum at the start. We will have two types of AI for now. Either None or the hostile AI. We also need references to the components:

var fighter_component: FighterComponent
var ai_component: BaseAIComponent

We then use set_entity_type() to extract the component information from the entity_definition:

func set_entity_type(entity_definition: EntityDefinition) -> void:
	_definition = entity_definition
	texture = entity_definition.texture
	modulate = entity_definition.color
	
	match entity_definition.ai_type:
		AIType.HOSTILE:
			ai_component = HostileEnemyAIComponent.new()
			add_child(ai_component)
	
	if entity_definition.fighter_definition:
		fighter_component = FighterComponent.new(entity_definition.fighter_definition)
		add_child(fighter_component)

The match block is new. We check which type of AI it should be. If it should be the hostile ai we create a new HostileEnemyAIComponent and add it as a child. We don’t handle the none type, because in that case nothing will happen with the ai_component anyway. Then we check if the entity_definition has a fighter_definition sub-resource. If so, we take that info and create a new FighterComponent from it, and add that as a child. Last thing for now is one more function in this script:

func is_alive() -> bool:
	return ai_component != null

We have the aforementioned is_alive() function, which simply checks if we have anything in the ai_component variable or not. By removing the AI component when an entity dies we can both stop it from doing any more actions and we can mark it as a dead entity.

We now can have entities with components, but to actually do so we need to add these options to our existing entity definition resources. So go into entity_definition_orc.tres and you will see our new Components category. The easy thing first, set Ai Type to Hostile. Now click into the slot for the Fighter Definition and create a new FighterComponentDefinition. You can now click on the sub resource in that slot to expand and collapse it. Set the Max Hp to 10, the Power to 3, and the Defense to 0. Next, open entity_definition_troll.tres, and set the Ai Type to Hostile as well. Create another sub resource in Fighter Definition and fill it with a Max Hp of 16, Power of 4, and Defense of 1.

Lastly, we do the same with the entity_definition_player.tres. Create a Fighter Definition and set the Max Hp to 30, the Power to 5, and the Defense to 2. You might expect that we leave Ai Type as None, because the player is not controlled by an ai. But remember our is_alive() function. If the player had no ai_component, the game would treat it as dead. However, we will handle the player separately anyway, so we set Ai Type to Hostile. This will give the player a HostileEnemyAiComponent, but that AI simply won’t do anything. Let me show you what I mean. In game.gd we change the _handle_enemy_turns() function as follows:

func _handle_enemy_turns() -> void:
	for entity in get_map_data().get_actors():
		if entity.is_alive() and entity != player:
			entity.ai_component.perform()

We loop through all actors, and we check for two conditions. First, the entity has to be alive, i.e., have an ai_component, and second the entity mustn’t be the player. That way we know we have an alive enemy, and we call it’s ai_component.perform() function to have it choose and perform an action. We don’t have the function to get the array of actors yet. While I did explain that I do not want to make that distinction here in terms of class hierarchy, we can create something similar by just returning an array of all alive entities. If then seems redundant to check is_alive() here, and in our current game it is. But imagine if we extended the game in a way that would allow an enemy further back in the list to die during the earlier enemy turns. It would still be in that array, but when we get to it it won’t be alive anymore. So just to make sure, we check again here. So let’s now create that get_actors() function in map_data.gd:

func get_actors() -> Array[Entity]:
	var actors: Array[Entity] = []
	for entity in entities:
		if entity.is_alive():
			actors.append(entity)
	return actors


func get_actor_at_location(location: Vector2i) -> Entity:
	for actor in get_actors():
		if actor.grid_position == location:
			return actor
	return null

In get_actors() we just create an array and filter for all the alive entities. We also create a function to get_actor_at_location() which works very similarly to get_blocking entity_at_location(), just for actors.

Now, the way we set up our AI pathfinding allows enemies to move diagonally. The player can’t at the moment, as there’s no input that creates a diagonal BumpAction. So let us now expand our input system a bit. We’ll have three ways to move the player. The first is the arrow keys, a before, but we’ll extend those by some special keys we use for diagonals. Not the most intuitive, so we’ll also use my favorite method, the numpad. Not everyone has a numpad on their keyboard though, so we’ll also implement the classic vim-style direction keys on the right side of the home row.

To do all this, go to Project > Settings in the menu bar, and there select the tab reading Input Map. Type move_up in the field reading Add New Action then click the Add button to the right of this. You should now see the move_up action in the list, and a plus sign all the way to the right. Click that, and assign the keys you want for moving up. You can add multiple keys after one another. Keep it at physical keycode, that way the layout will stay the same, even for people using another keyboard layout. Here’s the actions and corresponding keys we’ll use (use the ones that make sens in your keyboard layout):

Action Arrow Keys Numpad Vim keys
move_up Up arrow key NumPad 8 K
move_down Down arrow key NumPad 2 J
move_left Left arrow key NumPad 4 H
move_right Right arrow key NumPad 6 L
move_up_left Home NumPad 7 Y
move_up_right End NumPad 9 U
move_down_left Page Up NumPad 1 B
_move_down_right Page Down NumPad 3 N
wait Delete NumPad 5 Period
quit ESC (also uses ESC) (also uses ESC)

With our actions defined, let’s rewrite input_handler.gd. Replace the code with the following:

class_name InputHandler

const directions = {
	"move_up": Vector2i.UP,
	"move_down": Vector2i.DOWN,
	"move_left": Vector2i.LEFT,
	"move_right": Vector2i.RIGHT,
	"move_up_left": Vector2i.UP + Vector2i.LEFT,
	"move_up_right": Vector2i.UP + Vector2i.RIGHT,
	"move_down_left": Vector2i.DOWN + Vector2i.LEFT,
	"move_down_right": Vector2i.DOWN + Vector2i.RIGHT,
}


func get_action(player: Entity) -> Action:
	var action: Action = null
	
	for direction in directions:
		if Input.is_action_just_pressed(direction):
			var offset: Vector2i = directions[direction]
			action = BumpAction.new(player, offset.x, offset.y)
	
	if Input.is_action_just_pressed("wait"):
		action = WaitAction.new(player)
	
	if Input.is_action_just_pressed("quit"):
		action = EscapeAction.new(player)
	
	return action

Here we create an array that associates the names of the action strings with the desired move direction. That way we don’t need four more if checks, we can simply loop through the keys of our dictionary of directions, and if that action is pressed, we create a BumpAction with the corresponding offset. We also added a WaitAction for the player, and changed the code for the EscapeAction to use our new actions.

Now that both the player and the enemies can move around freely, let’s have them do some actual damage. First, we only want to hit living things (in this tutorial anyway), so to make that easier we add the following function to action_with_direction.gd:

func get_target_actor() -> Entity:
	return get_map_data().get_actor_at_location(get_destination())

Let’s use that function right away in bump_action.gd:

func perform() -> void:
	if get_target_actor():
		MeleeAction.new(entity, offset.x, offset.y).perform()
	else:
		MovementAction.new(entity, offset.x, offset.y).perform()

Next we rewrite the MeleeAction. So far that action has only printed a little statement. We will still print a statement so we know what happened, but now we will also do damage. Change the perform() function in melee_action.gd as follows:

func perform() -> void:
	var target: Entity = get_target_actor()
	if not target:
		return
	
	var damage: int = entity.fighter_component.power - target.fighter_component.defense
	
	var attack_description: String = "%s attacks %s" % [entity.get_entity_name(), target.get_entity_name()]
	if damage > 0:
		attack_description += " for %d hit points." % damage
		target.fighter_component.hp -= damage
	else:
		attack_description += " but does no damage."
	print(attack_description)

The top of the function is the same as before, with the exception that we now use the get_target_actor() function instead of get_blocking_entity(). Then we calculate the damage. A more exciting game would have damage ranges, maybe some simulated dice throws, but here we will just take the attacker’s power and subtract the defender’s defense from it, and that’s our damage. Next, we cobble together an attack description. The description starts with who is hitting who. Then it either tells us how much damage it did, or that it did not do any damage. In case we do damage, we also subtract that damage from the target’s health points.

Now that entities can take damage, they should also have a way to die. Here we come to a bit of an issue. Where I previously though it would be a nice trick to have values like is_blocking_movement just refer to the entity type definition, we now will want to change that value for individual entities when they die. Therefore we need to go back and modify entity.gd a bit:

var _definition: EntityDefinition
var entity_name: String
var blocks_movement: bool
var map_data: MapData

We now have a variable for the name and for blocking movement. I would have preferred if I could have followed the convention to name it is_blocking_movement, but that would have collided with the function of that name. We initialize these values in set_entity_type():

func set_entity_type(entity_definition: EntityDefinition) -> void:
	_definition = entity_definition
	blocks_movement = _definition.is_blocking_movement
	entity_name = _definition.name
	texture = entity_definition.texture
	modulate = entity_definition.color
	
	match entity_definition.ai_type:
		AIType.HOSTILE:
			ai_component = HostileEnemyAIComponent.new()
			add_child(ai_component)
	
	if entity_definition.fighter_definition:
		fighter_component = FighterComponent.new(entity_definition.fighter_definition)
		add_child(fighter_component)

All our code so far has referred to the functions we wrote to retrieve these values from the definition, so in order to keep all that other code without the need for any further changes we modify these function to now give us the values directly:

func is_blocking_movement() -> bool:
	return blocks_movement


func get_entity_name() -> String:
	return entity_name

That’s it for the entity refactor. Next, we need a visual representation for a corpse. For that create a new resource of type AtlasTexture at res://assets/resources/default_death_texture.tres. Fill it with our sprite sheet as the atlas and select an appropriate icon, such as the bone. We will now use this to add the following to fighter_component_definition.gd:

@export_category("Visuals")
@export var death_texture: AtlasTexture = preload("res://assets/resources/default_death_texture.tres")
@export var death_color: Color = Color.DARK_RED

This will allow us to customize what the remains of monsters will look like, while also having a good default. With that, everything is in place to let our entities die. Let’s write a function for that in fighter_component.gd:

func die() -> void:
	var death_message: String
	
	if get_map_data().player == entity:
		death_message = "You died!"
	else:
		death_message = "%s is dead!" % entity.get_entity_name()
	
	print(death_message)
	entity.texture = death_texture
	entity.modulate = death_color
	entity.ai_component.queue_free()
	entity.ai_component = null
	entity.entity_name = "Remains of %s" % entity.entity_name
	entity.blocks_movement = false
	get_map_data().unregister_blocking_entity(entity)

Here we start piecing a death message together again. This is all leading up to the next part, where we will create a message log. For now we print to the console. We differentiate here whether the player or an enemy died, and create an appropriate message. After printing that message comes the housekeeping. We set the entity texture to the death texture, and it’s modulate to the death color. Both these we’ll have to add to the fighter component in a moment. We delete the AI component and remove the reference to it. We change the name of the entity to reflect that it’s dead. And lastly, we set it to not block movement, which also implies unregistering it from the pathfinder. Now, modify the top of the script:

var max_hp: int
var hp: int:
	set(value):
		hp = clampi(value, 0, max_hp)
		if hp <= 0:
			die()
var defense: int
var power: int

var death_texture: Texture
var death_color: Color


func _init(definition: FighterComponentDefinition) -> void:
	max_hp = definition.max_hp
	hp = definition.max_hp
	defense = definition.defense
	power = definition.power
	death_texture = definition.death_texture
	death_color = definition.death_color

Firstly, we add a line to the hp setter that calls die() when the hp are changed to 0. Secondly, we add the aforementioned death texture and color. And thirdly, we set those in the _init() function.

And just like that, things can die. If you run the project now, however, you would notice two problems. The first is that alive entities might be drawn behind alive entities, which we don’t want. The second is that when the player dies, nothing much changes apart from their appearance and name. “Remains of Player” can still happily run around through the dungeon and hit the other entities. Let’s start with the first problem, as that’s a bit easier to solve.

To do that, we will a bit of code to entity.gd:

enum EntityType {CORPSE, ITEM, ACTOR}

var type: EntityType:
	set(value):
		type = value
		z_index = type

We add both an enum EntityType and a variable type. In the setter we set the z_index to the type. As the enum is actually an integer value, entity types further to the end of the list will be drawn above those at the beginning of the list. So if an entity is set to the type ACTOR it will be drawn above one that is set to CORPSE. To make use of this edit set_entity_type():

func set_entity_type(entity_definition: EntityDefinition) -> void:
	_definition = entity_definition
	type = _definition.type
	...

You see we need to get that from the definition. We add a line for that to entity_definition.gd:

@export_category("Mechanics")
@export var is_blocking_movement: bool = true
@export var type: Entity.EntityType = Entity.EntityType.ACTOR

The default value is ACTOR here, as all the entities we have so far that use an entity definition are actors, meaning we don’t have to update them manually. Lastly, at the end of the die() function in fighter_component.gd:

	print(death_message)
	entity.texture = death_texture
	entity.modulate = death_color
	entity.ai_component.queue_free()
	entity.ai_component = null
	entity.entity_name = "Remains of %s" % entity.entity_name
	entity.blocks_movement = false
	get_map_data().unregister_blocking_entity(entity)
	entity.type = Entity.EntityType.CORPSE

We simply add one line at the end, to set the entity type to CORPSE. Now the player or living enemies should never be drawn behind a dead enemy.

Now for the last problem, taking control away from the player when their character dies. To do that we will expand our system for input handling a bit. We will create a state machine that defers the call to an action to the currently active child. Create a new script extending Node at res://src/res://src/Game/EventHandlers/base_input_handler.gd:

class_name BaseInputHandler
extends Node


func get_action(player: Entity) -> Action:
	return null

It only has a get_action() method. Now rename the file input_handler.gd to main_game_input_handler.gd, and change the first line to extends BaseInputHandler . Now in the main game scene remove the script from the InputHandler node. Create a new script at res://src/Game/EventHandlers/input_handler.gd. This process might seem to be a bit confusing, but what we do here is to change how input handling works while preserving how it’s called. The Game node will now call the new input handler and just request an action from the new input handler, which will in turn request it from the MainGameInputHandler, which has all the old code. But let’s first write the new top-level input_handler.gd:

class_name InputHandler
extends Node

enum InputHandlers {MAIN_GAME, GAME_OVER}

@export var start_input_handler: InputHandlers

@onready var input_handler_nodes := {
	InputHandlers.MAIN_GAME: $MainGameInputHandler,
	InputHandlers.GAME_OVER: $GameOverInputHandler,
}

var current_input_handler: BaseInputHandler


func _ready() -> void:
	transition_to(start_input_handler)


func get_action(player: Entity) -> Action:
	return current_input_handler.get_action(player)


func transition_to(input_handler: InputHandlers) -> void:
	current_input_handler = input_handler_nodes[input_handler]

Here we first define a range of enums that represent the different input handlers we will have. We also have an exported variable that lets us select with which one we should start. We create an @onready dictionary associating the enum values with the available child nodes, which we’ll create in a minute. The variable current_input_handler remembers which child input handler we’re currently using.

In the _ready() function we call transition_to() with the starting handler. Transition to simply retrieves the appropriate node and stores it in current_input_handler. This then allows us to define get_action() as simply in turn calling get_action() on the current input handler.

To make this actually work, let’s add two new Nodes as children of InputHandler. Call the first one MainGameInputHendler and the second one GameOverInputHandler. Now attach main_game_input_handler.gd to MainGameInputHanlder. Let’s also create a new script on GameOverInputHandler, have it extend BaseInputHandler and save it at res://src/Game/EventHandlers/game_over_input_handler.gd. Here’s its contents:

extends BaseInputHandler

func get_action(player: Entity) -> Action:
	var action: Action
	
	if Input.is_action_just_pressed("quit"):
		action = EscapeAction.new(player)
	
	return action

Once the player is dead, the only thing we allow it for now is quitting the game. Now that that’s in place we need a way to trigger the transition from one input handler to the other. For that we’ll introduce a signal bus. That way, if the player dies we can emit a signal that automatically triggers that change. Create a new script extending Node at res://src/Utils/signal_bus.gd. For now it only defines a single signal:

extends Node

signal player_died

Now we need to make the signal bus an Autoload. Go to Project > Settings again, this time into the Autoload tab. Click on the folder icon next to Path and navigate to signal_bus.gd. Set the Node_Name to SignalBus and click add. Now you should see the signal bus entered into the list below.

We will emit this new signal in fighter_component.gd, in the die() function, when we determine that it was the player that died:

if get_map_data().player == entity:
	death_message = "You died!"
	SignalBus.player_died.emit()
else:
	death_message = "%s is dead!" % entity.get_entity_name()

To catch that signal we add one line to the _ready() function of input_handler.gd:

func _ready() -> void:
	transition_to(start_input_handler)
	SignalBus.player_died.connect(transition_to.bind(InputHandlers.GAME_OVER))

This connects the player_died signal in the SignalBus directly to transition_to, and will bind the value InputHandlers.GAME_OVER, meaning that value will be used as an argument. That way, when the player dies and that signal is emitted, we automatically transition to the game over input handler, which will prevent the player from doing anything further than quit the game.

And with that, I declare this part of the tutorial complete. The original tutorial also adds a little display for the player’s hp, but in our setup that’s slightly more involved, so I’ll save this for the next part, when we will tackle the interface anyway.

This part has been very long with a lot of changes, and I hope you could follow. If there is anything that’s not working for you with this code, remember that you can find the working code on this tutorial’s GitHub repository: https://github.com/SelinaDev/Godot-Roguelike-Tutorial. Once everything is working, you can move on to the next part: https://selinadev.github.io/11-rogueliketutorial-07/