# Part 12 - Increasing Difficulty

Despite the fact that we can go down floors now, the dungeon doesn’t get progressively more difficult as the player descends. This is because the method in which we place monsters and items is the same on each floor. In this chapter, we’ll adjust how we place things in the dungeon, so things get more difficult with each floor.

Currently, we pass `maximum_monsters` and `maximum_items` into the `place_entities` function, and this number does not change. To adjust the difficulty of our game, we can change these numbers based on the floor number. The way we’ll accomplish this is by setting up a list of tuples, which will contain two integers: the floor number, and the number of items/monsters.

Add the following to `procgen.py`:

    ...
    if TYPE_CHECKING:
        from engine import Engine

    +max_items_by_floor = [
    +   (1, 1),
    +   (4, 2),
    +]

    +max_monsters_by_floor = [
    +   (1, 2),
    +   (4, 3),
    +   (6, 5),
    +]

    class RectangularRoom:
        ...

    ...
    if TYPE_CHECKING:
        from engine import Engine

    max_items_by_floor = [
        (1, 1),
        (4, 2),
    ]

    max_monsters_by_floor = [
        (1, 2),
        (4, 3),
        (6, 5),
    ]

    class RectangularRoom:
        ...

As mentioned, the first number in these tuples represents the floor number, and the second represents the maximum of either the items or the monsters.

You might be wondering why we’ve only supplied values for only certain floors. Rather than having to type out each floor number, we’ll provide the floor numbers that have a different value, so that we can loop through the list and stop when we hit a floor number higher than the one we’re on. For example, if we’re on floor 3, we’ll take the floor 1 entry for both items and monsters, and stop iteration when we reach the second item in the list, since floor 4 is higher than floor 3.

Let’s write the function to take care of this. We’ll call it `get_max_value_for_floor`, as we’re getting the maximum value for either the items or monsters. It looks like this:

    ...
    max_items_by_floor = [
        (1, 1),
        (4, 2),
    ]

    max_monsters_by_floor = [
        (1, 2),
        (4, 3),
        (6, 5),
    ]

    +def get_max_value_for_floor(
    +   weighted_chances_by_floor: List[Tuple[int, int]], floor: int
    +) -> int:
    +   current_value = 0

    +   for floor_minimum, value in weighted_chances_by_floor:
    +       if floor_minimum > floor:
    +           break
    +       else:
    +           current_value = value

    +   return current_value

    class RectangularRoom:
        ...

    ...
    max_items_by_floor = [
        (1, 1),
        (4, 2),
    ]

    max_monsters_by_floor = [
        (1, 2),
        (4, 3),
        (6, 5),
    ]

    def get_max_value_for_floor(
        max_value_by_floor: List[Tuple[int, int]], floor: int
    ) -> int:
        current_value = 0

        for floor_minimum, value in max_value_by_floor:
            if floor_minimum > floor:
                break
            else:
                current_value = value

        return current_value

    class RectangularRoom:
        ...

Using this function is quite simple: we simply remove the `maximum_monsters` and `maximum_items` parameters from the `place_entities` function, pass the `floor_number` instead, and use that to get our maximum values from the `get_max_value_for_floor` function.

    -def place_entities(
    -   room: RectangularRoom, dungeon: GameMap, maximum_monsters: int, maximum_items: int
    -) -> None:
    -   number_of_monsters = random.randint(0, maximum_monsters)
    -   number_of_items = random.randint(0, maximum_items)
    +def place_entities(room: RectangularRoom, dungeon: GameMap, floor_number: int,) -> None:
    +   number_of_monsters = random.randint(
    +       0, get_max_value_for_floor(max_monsters_by_floor, floor_number)
    +   )
    +   number_of_items = random.randint(
    +       0, get_max_value_for_floor(max_items_by_floor, floor_number)
    +   )

        for i in range(number_of_monsters):
            ...

    ...

    def generate_dungeon(
        max_rooms: int,
        room_min_size: int,
        room_max_size: int,
        map_width: int,
        map_height: int,
    -   max_monsters_per_room: int,
    -   max_items_per_room: int,
        engine: Engine,
    ) -> GameMap:
        ...

                ...
                center_of_last_room = new_room.center

    -       place_entities(new_room, dungeon, max_monsters_per_room, max_items_per_room)
    +       place_entities(new_room, dungeon, engine.game_world.current_floor)

            dungeon.tiles[center_of_last_room] = tile_types.down_stairs
            dungeon.downstairs_location = center_of_last_room

    def place_entities(
        room: RectangularRoom, dungeon: GameMap, maximum_monsters: int, maximum_items: int
    ) -> None:
        number_of_monsters = random.randint(0, maximum_monsters)
        number_of_items = random.randint(0, maximum_items)
    def place_entities(room: RectangularRoom, dungeon: GameMap, floor_number: int,) -> None:
        number_of_monsters = random.randint(
            0, get_max_value_for_floor(max_monsters_by_floor, floor_number)
        )
        number_of_items = random.randint(
            0, get_max_value_for_floor(max_items_by_floor, floor_number)
        )

        for i in range(number_of_monsters):
            ...

    ...

    def generate_dungeon(
        max_rooms: int,
        room_min_size: int,
        room_max_size: int,
        map_width: int,
        map_height: int,
        max_monsters_per_room: int,
        max_items_per_room: int,
        engine: Engine,
    ) -> GameMap:
        ...

                ...
                center_of_last_room = new_room.center

            place_entities(new_room, dungeon, max_monsters_per_room, max_items_per_room)
            place_entities(new_room, dungeon, engine.game_world.current_floor)

            dungeon.tiles[center_of_last_room] = tile_types.down_stairs
            dungeon.downstairs_location = center_of_last_room

We can also remove `max_monsters_per_room` and `max_items_per_room` from `GameWorld`. Remove these lines from `game_map.py`:

    class GameWorld:
        """
        Holds the settings for the GameMap, and generates new maps when moving down the stairs.
        """

        def __init__(
            self,
            *,
            engine: Engine,
            map_width: int,
            map_height: int,
            max_rooms: int,
            room_min_size: int,
            room_max_size: int,
    -       max_monsters_per_room: int,
    -       max_items_per_room: int,
            current_floor: int = 0
        ):
            self.engine = engine

            self.map_width = map_width
            self.map_height = map_height

            self.max_rooms = max_rooms

            self.room_min_size = room_min_size
            self.room_max_size = room_max_size

    -       self.max_monsters_per_room = max_monsters_per_room
    -       self.max_items_per_room = max_items_per_room

            self.current_floor = current_floor

        def generate_floor(self) -> None:
            from procgen import generate_dungeon

            self.current_floor += 1

            self.engine.game_map = generate_dungeon(
                max_rooms=self.max_rooms,
                room_min_size=self.room_min_size,
                room_max_size=self.room_max_size,
                map_width=self.map_width,
                map_height=self.map_height,
    -           max_monsters_per_room=self.max_monsters_per_room,
    -           max_items_per_room=self.max_items_per_room,
                engine=self.engine,
            )

    class GameWorld:
        """
        Holds the settings for the GameMap, and generates new maps when moving down the stairs.
        """

        def __init__(
            self,
            *,
            engine: Engine,
            map_width: int,
            map_height: int,
            max_rooms: int,
            room_min_size: int,
            room_max_size: int,
            max_monsters_per_room: int,
            max_items_per_room: int,
            current_floor: int = 0
        ):
            self.engine = engine

            self.map_width = map_width
            self.map_height = map_height

            self.max_rooms = max_rooms

            self.room_min_size = room_min_size
            self.room_max_size = room_max_size

            self.max_monsters_per_room = max_monsters_per_room
            self.max_items_per_room = max_items_per_room

            self.current_floor = current_floor

        def generate_floor(self) -> None:
            from procgen import generate_dungeon

            self.current_floor += 1

            self.engine.game_map = generate_dungeon(
                max_rooms=self.max_rooms,
                room_min_size=self.room_min_size,
                room_max_size=self.room_max_size,
                map_width=self.map_width,
                map_height=self.map_height,
                max_monsters_per_room=self.max_monsters_per_room,
                max_items_per_room=self.max_items_per_room,
                engine=self.engine,
            )

Also remove the same variables from `setup_game.py`:

    def new_game() -> Engine:
        """Return a brand new game session as an Engine instance."""
        map_width = 80
        map_height = 43

        room_max_size = 10
        room_min_size = 6
        max_rooms = 30

    -   max_monsters_per_room = 2
    -   max_items_per_room = 2

        player = copy.deepcopy(entity_factories.player)

        engine = Engine(player=player)

        engine.game_world = GameWorld(
            engine=engine,
            max_rooms=max_rooms,
            room_min_size=room_min_size,
            room_max_size=room_max_size,
            map_width=map_width,
            map_height=map_height,
    -       max_monsters_per_room=max_monsters_per_room,
    -       max_items_per_room=max_items_per_room,
        )

        engine.game_world.generate_floor()
        engine.update_fov()

        engine.message_log.add_message(
            "Hello and welcome, adventurer, to yet another dungeon!", color.welcome_text
        )
        return engine

    def new_game() -> Engine:
        """Return a brand new game session as an Engine instance."""
        map_width = 80
        map_height = 43

        room_max_size = 10
        room_min_size = 6
        max_rooms = 30

        max_monsters_per_room = 2
        max_items_per_room = 2

        player = copy.deepcopy(entity_factories.player)

        engine = Engine(player=player)

        engine.game_world = GameWorld(
            engine=engine,
            max_rooms=max_rooms,
            room_min_size=room_min_size,
            room_max_size=room_max_size,
            map_width=map_width,
            map_height=map_height,
            max_monsters_per_room=max_monsters_per_room,
            max_items_per_room=max_items_per_room,
        )

        engine.game_world.generate_floor()
        engine.update_fov()

        engine.message_log.add_message(
            "Hello and welcome, adventurer, to yet another dungeon!", color.welcome_text
        )
        return engine

Now we’re adjusting the number of items and monsters based on the floor. The next step is to control which entities appear on which floor, instead of allowing any entity to appear on any floor. The first floor will only have health potions and orcs, and we’ll gradually add different items and enemies as the player goes deeper into the dungeon.

We need a function that allows us to get these entities at random, based on a set of weights. We also need to define the weights themselves.

What are “weights” in this context? Basically, we could define all of the odds of generating a type of entity the way we have already, by getting a random number and comparing against a set of values, but that will quickly become cumbersome as we add more entities. Imagine wanting to add a new enemy type, but needing to adjust the values for dozens, or perhaps hundreds, of other entities.

Instead, we’ll just give each entity a value, or a “weight”, which we’ll use to determine how common that entity should be. We’ll use Python’s `random.choices` function, which allows the user to pass a list of items and a set of weights. It returns a number of items that you specify, based on the weights you give it.

First, we need to define our weights for the entity types, along with the minimum floor that the item or monster will appear on. Add the following to `procgen.py`:

    from __future__ import annotations

    import random
    -from typing import Iterator, List, Tuple, TYPE_CHECKING
    +from typing import Dict, Iterator, List, Tuple, TYPE_CHECKING

    import tcod

    import entity_factories
    from game_map import GameMap
    import tile_types

    if TYPE_CHECKING:
        from engine import Engine
    +   from entity import Entity

    max_items_by_floor = [
        (1, 1),
        (4, 2),
    ]

    max_monsters_by_floor = [
        (1, 2),
        (4, 3),
        (6, 5),
    ]

    +item_chances: Dict[int, List[Tuple[Entity, int]]] = {
    +   0: [(entity_factories.health_potion, 35)],
    +   2: [(entity_factories.confusion_scroll, 10)],
    +   4: [(entity_factories.lightning_scroll, 25)],
    +   6: [(entity_factories.fireball_scroll, 25)],
    +}

    +enemy_chances: Dict[int, List[Tuple[Entity, int]]] = {
    +   0: [(entity_factories.orc, 80)],
    +   3: [(entity_factories.troll, 15)],
    +   5: [(entity_factories.troll, 30)],
    +   7: [(entity_factories.troll, 60)],
    +}

    def get_max_value_for_floor(
        ...

    from __future__ import annotations

    import random
    from typing import Iterator, List, Tuple, TYPE_CHECKING
    from typing import Dict, Iterator, List, Tuple, TYPE_CHECKING

    import tcod

    import entity_factories
    from game_map import GameMap
    import tile_types

    if TYPE_CHECKING:
        from engine import Engine
        from entity import Entity

    max_items_by_floor = [
        (1, 1),
        (4, 2),
    ]

    max_monsters_by_floor = [
        (1, 2),
        (4, 3),
        (6, 5),
    ]

    item_chances: Dict[int, List[Tuple[Entity, int]]] = {
        0: [(entity_factories.health_potion, 35)],
        2: [(entity_factories.confusion_scroll, 10)],
        4: [(entity_factories.lightning_scroll, 25)],
        6: [(entity_factories.fireball_scroll, 25)],
    }

    enemy_chances: Dict[int, List[Tuple[Entity, int]]] = {
        0: [(entity_factories.orc, 80)],
        3: [(entity_factories.troll, 15)],
        5: [(entity_factories.troll, 30)],
        7: [(entity_factories.troll, 60)],
    }

    def get_max_value_for_floor(
        ...

They keys in the dictionary represent the floor number, and the value is a list of tuples. The tuples contain an entity and the weights at which they’ll be generated. Notice that Trolls get defined multiple times in `enemy_chances`, and their weights grow higher when the floor number increases. This will allow Trolls to be generated more frequently as the player dives into the dungeon, thus making the dungeon more dangerous with each passing floor.

Why a list of tuples, though? While there isn’t any examples here, we want it to be possible to define many entity types and weights for each floor. For example, imagine we added a new enemy type that appears on floor 5. We could put that as a tuple inside the list, alongside the Troll’s tuple. We’ll see an example of this in the next chapter, when we start adding equipment.

With our weights defined, we need a function to actually pick which entities we want to create. As mentioned, it will utilize `random.choices` from the Python standard library to choose the entities. Add this function to `procgen.py`:

    def get_max_value_for_floor(
        weighted_chances_by_floor: List[Tuple[int, int]], floor: int
    ) -> int:
        ...

    +def get_entities_at_random(
    +   weighted_chances_by_floor: Dict[int, List[Tuple[Entity, int]]],
    +   number_of_entities: int,
    +   floor: int,
    +) -> List[Entity]:
    +   entity_weighted_chances = {}

    +   for key, values in weighted_chances_by_floor.items():
    +       if key > floor:
    +           break
    +       else:
    +           for value in values:
    +               entity = value[0]
    +               weighted_chance = value[1]

    +               entity_weighted_chances[entity] = weighted_chance

    +   entities = list(entity_weighted_chances.keys())
    +   entity_weighted_chance_values = list(entity_weighted_chances.values())

    +   chosen_entities = random.choices(
    +       entities, weights=entity_weighted_chance_values, k=number_of_entities
    +   )

    +   return chosen_entities

    class RectangularRoom:
        ...

    def get_max_value_for_floor(
        weighted_chances_by_floor: List[Tuple[int, int]], floor: int
    ) -> int:
        ...

    def get_entities_at_random(
        weighted_chances_by_floor: Dict[int, List[Tuple[Entity, int]]],
        number_of_entities: int,
        floor: int,
    ) -> List[Entity]:
        entity_weighted_chances = {}

        for key, values in weighted_chances_by_floor.items():
            if key > floor:
                break
            else:
                for value in values:
                    entity = value[0]
                    weighted_chance = value[1]

                    entity_weighted_chances[entity] = weighted_chance

        entities = list(entity_weighted_chances.keys())
        entity_weighted_chance_values = list(entity_weighted_chances.values())

        chosen_entities = random.choices(
            entities, weights=entity_weighted_chance_values, k=number_of_entities
        )

        return chosen_entities

    class RectangularRoom:
        ...

This function goes through they keys (floor numbers) and values (list of weighted entities), stopping when the key is higher than the given floor number. It sets up a dictionary of the weights for each entity, based on which floor the player is currently on. So if we were trying to get the weights for floor 6, `entity_weighted_chances` would look like this: `{ orc: 80, troll: 30 }`.

Then, we get both the keys and values in list format, so that they can be passed to `random.choices` (it accepts choices and weights as lists). `k` represents the number of items that `random.choices` should pick, so we can simply pass the number of entities we’ve decided to generate. Finally, we return the list of chosen entities.

Putting this function to use is quite simple. In fact, it will reduce the amount of code in our `place_entities` function quite nicely:

    def place_entities(room: RectangularRoom, dungeon: GameMap, floor_number: int,) -> None:
        number_of_monsters = random.randint(
            0, get_weight_for_floor(max_monsters_by_floor, floor_number)
        )
        number_of_items = random.randint(
            0, get_weight_for_floor(max_items_by_floor, floor_number)
        )

    +   monsters: List[Entity] = get_entities_at_random(
    +       enemy_chances, number_of_monsters, floor_number
    +   )
    +   items: List[Entity] = get_entities_at_random(
    +       item_chances, number_of_items, floor_number
    +   )

    -   for i in range(number_of_monsters):
    -       x = random.randint(room.x1 + 1, room.x2 - 1)
    -       y = random.randint(room.y1 + 1, room.y2 - 1)

    -       if not any(entity.x == x and entity.y == y for entity in dungeon.entities):
    -           if random.random() < 0.8:
    -               entity_factories.orc.spawn(dungeon, x, y)
    -           else:
    -               entity_factories.troll.spawn(dungeon, x, y)

    -   for i in range(number_of_items):
    +   for entity in monsters + items:
            x = random.randint(room.x1 + 1, room.x2 - 1)
            y = random.randint(room.y1 + 1, room.y2 - 1)

            if not any(entity.x == x and entity.y == y for entity in dungeon.entities):
    -           item_chance = random.random()

    -           if item_chance < 0.7:
    -               entity_factories.health_potion.spawn(dungeon, x, y)
    -           elif item_chance < 0.80:
    -               entity_factories.fireball_scroll.spawn(dungeon, x, y)
    -           elif item_chance < 0.90:
    -               entity_factories.confusion_scroll.spawn(dungeon, x, y)
    -           else:
    -               entity_factories.lightning_scroll.spawn(dungeon, x, y)
    +           entity.spawn(dungeon, x, y)

    ...

    def place_entities(room: RectangularRoom, dungeon: GameMap, floor_number: int,) -> None:
        number_of_monsters = random.randint(
            0, get_weight_for_floor(max_monsters_by_floor, floor_number)
        )
        number_of_items = random.randint(
            0, get_weight_for_floor(max_items_by_floor, floor_number)
        )

        monsters: List[Entity] = get_entities_at_random(
            enemy_chances, number_of_monsters, floor_number
        )
        items: List[Entity] = get_entities_at_random(
            item_chances, number_of_items, floor_number
        )

        for i in range(number_of_monsters):
            x = random.randint(room.x1 + 1, room.x2 - 1)
            y = random.randint(room.y1 + 1, room.y2 - 1)

            if not any(entity.x == x and entity.y == y for entity in dungeon.entities):
                if random.random() < 0.8:
                    entity_factories.orc.spawn(dungeon, x, y)
                else:
                    entity_factories.troll.spawn(dungeon, x, y)

        for i in range(number_of_items):
        for entity in monsters + items:
            x = random.randint(room.x1 + 1, room.x2 - 1)
            y = random.randint(room.y1 + 1, room.y2 - 1)

            if not any(entity.x == x and entity.y == y for entity in dungeon.entities):
                item_chance = random.random()

                if item_chance < 0.7:
                    entity_factories.health_potion.spawn(dungeon, x, y)
                elif item_chance < 0.80:
                    entity_factories.fireball_scroll.spawn(dungeon, x, y)
                elif item_chance < 0.90:
                    entity_factories.confusion_scroll.spawn(dungeon, x, y)
                else:
                    entity_factories.lightning_scroll.spawn(dungeon, x, y)
                entity.spawn(dungeon, x, y)

    ...

Now `place_entities` is just getting the amount of monsters and items to generate, and leaving it up to `get_entities_at_random` to determine which ones to create.

With those changes, the dungeon will get progressively more difficult! You may want to tweak certain numbers, like the strength of the enemies or how much health you recover with potions, to get a more challenging experience (our game is still not that difficult, if you increase your defense by just 1, Orcs are no longer a threat).

If you want to see the code so far in its entirety, click here.

Click here to move on to the next part of this tutorial.