Yet Another Roguelike Tutorial, Part 12

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

Part 12: Increasing Difficulty

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/15-rogueliketutorial-11/

In the last part we implemented the ability to down into deeper levels of the dungeon. However, currently the only thing that really does is create a new map and increase the displayed depth. Sure, it’s a new dungeon in the same run, but that new floor is essentially the same as the last one. The random generation will create a new layout, a new arrangement of items and monsters, but the parameters behind that generation procedure are the same. The dungeon floor does not qualitatively change in its risk or reward. To remedy that, we will introduce some variety, so that the type and composition of spawned monsters and items will change depending on the depth of the floor.

Such spawn tables are data, and as such I would prefer to implement them as Resources. However, the way we will implement this uses dictionaries, which at the moment don’t allow typing of their contents. This means that if we use exported dictionaries, we have to manually set the type both the key and the value for each entry. Even if it’s something as simple as having strings for both, this would mean first creating a new entry, then selecting the type of the key from a long list of options, then selecting the type of the value from the same long list of options. Only after that we could enter the values. That is not convenient, so I will use constant values in the code. In a bigger project you might want to separate out your data somehow, but for the small scope we’re dealing with here this should be a good starting point. So how do these spawn tables look like? Open up dungeon_generator.gd and add the following constants to the top of the script:

const max_items_by_floor = [
	[1, 1],
	[4, 2]
]

const max_monsters_by_floor = [
	[1, 2],
	[4, 3],
	[6, 5]
]

These will replace our previous max_items_per_room and max_monsters_per_room (so delete the two entries below @export_category("Entities RNG")). They consist of an array of arrays with two entries each. The first entry is the (minimum) floor for that configuration, and the second one is the maximum number of entities per room. So from floor 1 up to floor 3 we have up to 1 item in each room, and from floor 4 downwards we have up to 2 items. The monsters work the same way. Next we need to define which monsters and items can spawn on each floor:

const item_chances = {
	0: {"health_potion": 35},
	2: {"confusion_scroll": 10},
	4: {"lightning_scroll": 25},
	6: {"fireball_scroll": 25},
}

const enemy_chances = {
	0: {"orc": 80},
	3: {"troll": 15},
	5: {"troll": 30},
	7: {"troll": 60}
}

Here we have dictionaries with integer keys, each associated with a value that is itself a dictionary. Those dictionary could have any number of string keys (corresponding to the strings we use for spawning entities) with integer weights. These weights are arbitrary numbers, only the relationship between them matters. We will later code a function that will see what entities can spawn on each floor. This function will go through the keys, up to the number of the current floor, and add up all the keys and values that appear, with keys that appear again overriding older entries. Then we can use the associated weight to perform a weighted draw. For example, on floor 6 we see that "orc" has a weight of 80, and troll first gets a weight of 15, which then gets changed to 30. Each weighted draw is then as if we give the orcs 80 raffle tickets and the trolls 30. We see that trolls will become more common the deeper we go, but will be outnumbered by orcs even on the deeper levels.

Let’s look at the code, first for the maximum amount of entities per room. Add the following new function:

func _get_max_value_for_floor(weighted_chances_by_floor: Array, current_floor: int) -> int:
	var current_value = 0
	
	for chance in weighted_chances_by_floor:
		if chance[0] > current_floor:
			break
		else:
			current_value = chance[1]
	
	return current_value

This function takes one of our const arrays (meaning it will work both for monsters and for items, depending on what we feed it) and the current floor. We then go through the array, and as long as the first entry of an array element is less than or equal to the current floor, we update the current value with the second entry of the array element. That way we get the larges applicable value and return it. Let’s see how we cen use this function in _place_entities():

func _place_entities(dungeon: MapData, room: Rect2i, current_floor: int) -> void:
	var max_monsters_per_room: int = _get_max_value_for_floor(max_monsters_by_floor, current_floor)
	var max_items_per_room: int = _get_max_value_for_floor(max_items_by_floor, current_floor)
	var number_of_monsters: int = _rng.randi_range(0, max_monsters_per_room)
	var number_of_items: int = _rng.randi_range(0, max_items_per_room)
    #...

The first thing to point out is that we get a new parameter for the current floor. Then we calculate max_monsters_per_room and max_items_per_room. These have the same name and work in the same way like the constants we had previously used, so the rest of the code works just fine. Of course, we also need to change the call to this function inside generate_dungeon() to _place_entities(dungeon, new_room, current_floor).

Let’s now turn to the random selection of entities I mentioned above:

func _get_entities_at_random(weighted_chances_by_floor: Dictionary, number_of_entities: int, current_floor: int) -> Array[String]:
	var entity_weighted_chances = {}
	var chosen_entities: Array[String] = []
	
	for key in weighted_chances_by_floor:
		if key > current_floor:
			break
		else:
			for entity_name in weighted_chances_by_floor[key]:
				entity_weighted_chances[entity_name] = weighted_chances_by_floor[key][entity_name]
	
	for _i in number_of_entities:
		chosen_entities.append(_pick_weighted(entity_weighted_chances))
	
	return chosen_entities

This function takes a dictionary in the form we specified above, the number of entities to generate, as well as the current floor. It will return an array of the keys we will use for spawning the entities. The first part of the function constructs the appropriate chances for the current floor. We go through all the floors up to the current floor and put all all the keys and associated weights in entity_weighted_changes, successively overwriting any keys and weights that are already entered. Once we have that, we fill chosen_entities with keys by calling _pick_weighted() a number of times equal to number_of_entities, before returning chosen_entities. Here’s now that _pick_weighted() function:

func _pick_weighted(weighted_chances: Dictionary) -> String:
	var keys: Array[String] = []
	var cumulative_chances := []
	var sum: int = 0
	for key in weighted_chances:
		keys.append(key)
		var chance: int = weighted_chances[key]
		sum += chance
		cumulative_chances.append(sum)
	var random_chance: int = _rng.randi_range(0, sum - 1)
	var selection: String
	
	for i in cumulative_chances.size():
		if cumulative_chances[i] > random_chance:
			selection = keys[i]
			break
	
	return selection

This takes the weighted_chances dictionary we have constructed for the current floor. This dictionary contains a number of string keys with associated weights. From these, we crate an array with cumulative weights. E.g., if the input dictionary was {"orc": 80, "troll": 30}, then we create an array [80, 110]. then we generate a random_chance that’s inside that range (so in this example up to 109). Then we go through the array for as long as the random number is bigger than the current array entry. Once the random chance is lower than the current array entry we know that it is both bigger than the last one and lower than the current one, i.e., inside the slot associated with the current entry. We retrieve the associated key and return it.

The nice thing about this approach is that it abstracts getting entity keys for both monsters and items. This reduces the duplicated code we require in _place_entities():

func _place_entities(dungeon: MapData, room: Rect2i, current_floor: int) -> void:
	var max_monsters_per_room: int = _get_max_value_for_floor(max_monsters_by_floor, current_floor)
	var max_items_per_room: int = _get_max_value_for_floor(max_items_by_floor, current_floor)
	var number_of_monsters: int = _rng.randi_range(0, max_monsters_per_room)
	var number_of_items: int = _rng.randi_range(0, max_items_per_room)
	
	var monsters: Array[String] = _get_entities_at_random(enemy_chances, number_of_monsters, current_floor)
	var items: Array[String] = _get_entities_at_random(item_chances, number_of_items, current_floor)
	
	var entity_keys: Array[String] = monsters + items
	
	for entity_key in entity_keys:
		var x: int = _rng.randi_range(room.position.x + 1, room.end.x - 1)
		var y: int = _rng.randi_range(room.position.y + 1, room.end.y - 1)
		var new_entity_position := Vector2i(x, y)
		
		var can_place = true
		for entity in dungeon.entities:
			if entity.grid_position == new_entity_position:
				can_place = false
				break
		
		if can_place:
			var new_entity := Entity.new(dungeon, new_entity_position, entity_key)
			dungeon.entities.append(new_entity)

After creating number_of_monsters and number_of_items we create a monsters and an items array, filled with keys. Then we combine these into an entity_key array. We can then go through all these keys and use them to spawn all of these entities one by one, combining the placement code for both monsters and items.

And just like that the dungeon will be more interesting. If you start a new game now, then you won’t encounter anything other than orcs and health potions until deeper in the dungeon. And the deeper you go, the more enemies you will encounter at once.

This concludes the second-to-last tutorial of this tutorial series. Next time, we will conclude the tutorial with equipment.