Part 7 - Creating the Interface


Our game is looking more and more playable by the chapter, but before we move forward with the gameplay, we ought to take a moment to focus on how the project looks. Despite what some roguelike traditionalists may tell you, a good UI goes a long way.

One of the first things we can do is define a file that will hold our RGB colors. We’ve just been hard-coding them up until now, but it would be nice if they were all in one place and then imported when needed, so that we could easily update them if need be.

Create a new file, called color.py, and fill it with the following:

white = (0xFF, 0xFF, 0xFF)
black = (0x0, 0x0, 0x0)

player_atk = (0xE0, 0xE0, 0xE0)
enemy_atk = (0xFF, 0xC0, 0xC0)

player_die = (0xFF, 0x30, 0x30)
enemy_die = (0xFF, 0xA0, 0x30)

welcome_text = (0x20, 0xA0, 0xFF)

bar_text = white
bar_filled = (0x0, 0x60, 0x0)
bar_empty = (0x40, 0x10, 0x10)

Some of these colors, like welcome_text and bar_filled are things we haven’t added yet, but don’t worry, we’ll utilize them by the end of the chapter.

Last chapter, we implemented a basic HP tracker for the player, with the promise that we’d revisit in this chapter to make it look better. And now, the time has come!

We’ll create a bar that will gradually decrease as the player loses HP. This will help the player visualize how much HP is remaining. To do this, we’ll create a generic render_bar function, which can accept different values and change the bar’s length based on the current_value and maximum_value we give to it.

To house this new function (as well as some other functions that are coming soon), let’s create a new file, called render_functions.py. Put the following into it:

from __future__ import annotations

from typing import TYPE_CHECKING

import color

if TYPE_CHECKING:
    from tcod import Console


def render_bar(
    console: Console, current_value: int, maximum_value: int, total_width: int
) -> None:
    bar_width = int(float(current_value) / maximum_value * total_width)

    console.draw_rect(x=0, y=45, width=20, height=1, ch=1, bg=color.bar_empty)

    if bar_width > 0:
        console.draw_rect(
            x=0, y=45, width=bar_width, height=1, ch=1, bg=color.bar_filled
        )

    console.print(
        x=1, y=45, string=f"HP: {current_value}/{maximum_value}", fg=color.bar_text
    )

We’re utilizing the draw_rect functions provided by TCOD to draw rectangular bars. We’re actually drawing two bars, one on top of the other. The first one will be the background color, which in the case of our health bar, will be a red color. The second goes on top, and is green. The one on top will gradually decrease as the player drops hit points, as its width is determined by the bar_width variable, which is itself determined by the current_value over the maximum_value.

We also print the “HP” value over the bar, so the player knows the exact number.

In order to utilize this new function, make the following changes to engine.py:

...
from input_handlers import MainGameEventHandler
+from render_functions import render_bar

if TYPE_CHECKING:
    ...

    ...
    def render(self, console: Console, context: Context) -> None:
        self.game_map.render(console)

+       render_bar(
+           console=console,
+           current_value=self.player.fighter.hp,
+           maximum_value=self.player.fighter.max_hp,
+           total_width=20,
+       )
-       console.print(
-           x=1,
-           y=47,
-           string=f"HP: {self.player.fighter.hp}/{self.player.fighter.max_hp}",
-       )

        context.present(console)

        console.clear()
...
from input_handlers import MainGameEventHandler
from render_functions import render_bar

if TYPE_CHECKING:
    ...

    ...
    def render(self, console: Console, context: Context) -> None:
        self.game_map.render(console)

        render_bar(
            console=console,
            current_value=self.player.fighter.hp,
            maximum_value=self.player.fighter.max_hp,
            total_width=20,
        )
        console.print(
            x=1,
            y=47,
            string=f"HP: {self.player.fighter.hp}/{self.player.fighter.max_hp}",
        )

        context.present(console)

        console.clear()

Run the project now, and you should have a functioning health bar!

Part 7 - Health bar

What next? One obvious problem with our project at the moment is that the messages get printed to the terminal rather than showing up in the actual game. We can fix that by adding a message log, which can display messages along with different colors for a bit of flash.

Create a new file, called message_log.py, and put the following contents inside:

from typing import List, Reversible, Tuple
import textwrap

import tcod

import color


class Message:
    def __init__(self, text: str, fg: Tuple[int, int, int]):
        self.plain_text = text
        self.fg = fg
        self.count = 1

    @property
    def full_text(self) -> str:
        """The full text of this message, including the count if necessary."""
        if self.count > 1:
            return f"{self.plain_text} (x{self.count})"
        return self.plain_text


class MessageLog:
    def __init__(self) -> None:
        self.messages: List[Message] = []

    def add_message(
        self, text: str, fg: Tuple[int, int, int] = color.white, *, stack: bool = True,
    ) -> None:
        """Add a message to this log.
        `text` is the message text, `fg` is the text color.
        If `stack` is True then the message can stack with a previous message
        of the same text.
        """
        if stack and self.messages and text == self.messages[-1].plain_text:
            self.messages[-1].count += 1
        else:
            self.messages.append(Message(text, fg))

    def render(
        self, console: tcod.Console, x: int, y: int, width: int, height: int,
    ) -> None:
        """Render this log over the given area.
        `x`, `y`, `width`, `height` is the rectangular region to render onto
        the `console`.
        """
        self.render_messages(console, x, y, width, height, self.messages)

    @staticmethod
    def render_messages(
        console: tcod.Console,
        x: int,
        y: int,
        width: int,
        height: int,
        messages: Reversible[Message],
    ) -> None:
        """Render the messages provided.
        The `messages` are rendered starting at the last message and working
        backwards.
        """
        y_offset = height - 1

        for message in reversed(messages):
            for line in reversed(textwrap.wrap(message.full_text, width)):
                console.print(x=x, y=y + y_offset, string=line, fg=message.fg)
                y_offset -= 1
                if y_offset < 0:
                    return  # No more space to print messages.

Let’s go through the additions piece by piece.

class Message:
    def __init__(self, text: str, fg: Tuple[int, int, int]):
        self.plain_text = text
        self.fg = fg
        self.count = 1

    @property
    def full_text(self) -> str:
        """The full text of this message, including the count if necessary."""
        if self.count > 1:
            return f"{self.plain_text} (x{self.count})"
        return self.plain_text

The Message will be used to save and display messages in our log. It includes three pieces of information:

  • plain_text: The actual message text.
  • fg: The “foreground” color of the message.
  • count: This is used to display something like “The Orc attacks (x3).” Rather than crowding our message log with the same message over and over, we can “stack” the messages by increasing a message’s count. This only happens when the same message appears several times in a row.

The full_text property returns the text with its count, if the count is greater than 1. Otherwise, it just returns the message as-is.

Now, the actual message log:

class MessageLog:
    def __init__(self) -> None:
        self.messages: List[Message] = []

It keeps a list of the Messages received. Nothing too complex here.

    def add_message(
        self, text: str, fg: Tuple[int, int, int] = color.white, *, stack: bool = True,
    ) -> None:
        """Add a message to this log.
        `text` is the message text, `fg` is the text color.
        If `stack` is True then the message can stack with a previous message
        of the same text.
        """
        if stack and self.messages and text == self.messages[-1].plain_text:
            self.messages[-1].count += 1
        else:
            self.messages.append(Message(text, fg))

add_message is what adds the message to the log. text is required, but fg will just default to white if nothing is given. stack tells us whether to stack messages or not (which allows us to disable this behavior, if desired).

If we are allowing stacking, and the added message matches the previous message, we just increment the previous message’s count by 1. If it’s not a match, we add it to the list.

    def render(
        self, console: tcod.Console, x: int, y: int, width: int, height: int,
    ) -> None:
        """Render this log over the given area.
        `x`, `y`, `width`, `height` is the rectangular region to render onto
        the `console`.
        """
        self.render_messages(console, x, y, width, height, self.messages)
    
    @staticmethod
    def render_messages(
        console: tcod.Console,
        x: int,
        y: int,
        width: int,
        height: int,
        messages: Reversible[Message],
    ) -> None:
        """Render the messages provided.
        The `messages` are rendered starting at the last message and working
        backwards.
        """
        y_offset = height - 1

        for message in reversed(messages):
            for line in reversed(textwrap.wrap(message.full_text, width)):
                console.print(x=x, y=y + y_offset, string=line, fg=message.fg)
                y_offset -= 1
                if y_offset < 0:
                    return  # No more space to print messages.

This render calls render_messages, which is a static method that actually renders the messages to the screen. It renders them in reverse order, to make it appear that the messages are scrolling in an upwards direction. We use the textwrap.wrap function to wrap the text to fit within the given area, and then print each line to the console. We can only print so many messages to the console, however, so if y_offset reaches -1, we stop.

To utilize the message log, we’ll first need to add it to the Engine class. Modify engine.py like this:

...
from input_handlers import MainGameEventHandler
+from message_log import MessageLog
from render_functions import render_bar

if TYPE_CHECKING:
    from entity import Actor
    from game_map import GameMap
    from input_handlers import EventHandler

class Engine:
    game_map: GameMap

    def __init__(self, player: Actor):
        self.event_handler: EventHandler = MainGameEventHandler(self)
+       self.message_log = MessageLog()
        self.player = player
    ...

    def render(self, console: Console, context: Context) -> None:
        self.game_map.render(console)

+       self.message_log.render(console=console, x=21, y=45, width=40, height=5)

        render_bar(
            ...
...
from input_handlers import MainGameEventHandler
from message_log import MessageLog
from render_functions import render_bar

if TYPE_CHECKING:
    from entity import Actor
    from game_map import GameMap
    from input_handlers import EventHandler

class Engine:
    game_map: GameMap

    def __init__(self, player: Actor):
        self.event_handler: EventHandler = MainGameEventHandler(self)
        self.message_log = MessageLog()
        self.player = player
    ...

    def render(self, console: Console, context: Context) -> None:
        self.game_map.render(console)

        self.message_log.render(console=console, x=21, y=45, width=40, height=5)

        render_bar(
            ...

We’re adding an instance of MessageLog in the initializer, and rendering the log in the Engine’s render method. Nothing too complicated here.

We need to make a small change to main.py in order to actually make room for our message log. We can also add a friendly welcome message here.

#!/usr/bin/env python3
import copy

import tcod

+import color
from engine import Engine
import entity_factories
...

    ...
    map_width = 80
-   map_height = 45
+   map_height = 43

    room_max_size = 10
    ...

    ...
    engine.update_fov()

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

    with tcod.context.new_terminal(
        ...
#!/usr/bin/env python3
import copy

import tcod

import color
from engine import Engine
import entity_factories
...

    ...
    map_width = 80
    map_height = 45
    map_height = 43

    room_max_size = 10
    ...

    ...
    engine.update_fov()

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

    with tcod.context.new_terminal(
        ...

Feel free to experiment with different window and map sizes, if you like.

Run the project, and you should see the welcome message.

Part 7 - Welcome Message

Now that we’ve confirmed our message log accepts and displays messages, we’ll need to replace all of our previous print statements to push messages to the log instead.

Let’s start with our attack action, in actions.py:

...
from typing import Optional, Tuple, TYPE_CHECKING

+import color

if TYPE_CHECKING:
    ...

        ...
        damage = self.entity.fighter.power - target.fighter.defense

        attack_desc = f"{self.entity.name.capitalize()} attacks {target.name}"
+       if self.entity is self.engine.player:
+           attack_color = color.player_atk
+       else:
+           attack_color = color.enemy_atk        

        if damage > 0:
-           print(f"{attack_desc} for {damage} hit points.")
+           self.engine.message_log.add_message(
+               f"{attack_desc} for {damage} hit points.", attack_color
+           )
            target.fighter.hp -= damage
        else:
-           print(f"{attack_desc} but does no damage.")
+           self.engine.message_log.add_message(
+               f"{attack_desc} but does no damage.", attack_color
+           )
...
from typing import Optional, Tuple, TYPE_CHECKING

import color

if TYPE_CHECKING:
    ...

        ...
        damage = self.entity.fighter.power - target.fighter.defense

        attack_desc = f"{self.entity.name.capitalize()} attacks {target.name}"
        if self.entity is self.engine.player:
            attack_color = color.player_atk
        else:
            attack_color = color.enemy_atk

        if damage > 0:
            print(f"{attack_desc} for {damage} hit points.")
            self.engine.message_log.add_message(
                f"{attack_desc} for {damage} hit points.", attack_color
            )
            target.fighter.hp -= damage
        else:
            print(f"{attack_desc} but does no damage.")
            self.engine.message_log.add_message(
                f"{attack_desc} but does no damage.", attack_color
            )

We determine the color based on who is doing the attacking. Other than that, there’s really nothing new here, we’re just pushing those messages to the log rather than printing them.

Now we just need to update our death messages. Open up fighter.py and modify it like this:

from __future__ import annotations

from typing import TYPE_CHECKING

+import color
from components.base_component import BaseComponent
...

    ...
    def die(self) -> None:
        if self.engine.player is self.entity:
            death_message = "You died!"
+           death_message_color = color.player_die
            self.engine.event_handler = GameOverEventHandler(self.engine)
        else:
            death_message = f"{self.entity.name} is dead!"
+           death_message_color = color.enemy_die

        self.entity.char = "%"
        self.entity.color = (191, 0, 0)
        self.entity.blocks_movement = False
        self.entity.ai = None
        self.entity.name = f"remains of {self.entity.name}"
        self.entity.render_order = RenderOrder.CORPSE

-       print(death_message)
+       self.engine.message_log.add_message(death_message, death_message_color)
from __future__ import annotations

from typing import TYPE_CHECKING

import color
from components.base_component import BaseComponent
...

    ...
    def die(self) -> None:
        if self.engine.player is self.entity:
            death_message = "You died!"
            death_message_color = color.player_die
            self.engine.event_handler = GameOverEventHandler(self.engine)
        else:
            death_message = f"{self.entity.name} is dead!"
            death_message_color = color.enemy_die

        self.entity.char = "%"
        self.entity.color = (191, 0, 0)
        self.entity.blocks_movement = False
        self.entity.ai = None
        self.entity.name = f"remains of {self.entity.name}"
        self.entity.render_order = RenderOrder.CORPSE

        print(death_message)
        self.engine.message_log.add_message(death_message, death_message_color)

Run the project now. You should see messages for both attacks and deaths!

Part 7 - Death Messages

What next? One thing that would be nice is to see the names of the different entities. This will become useful later on if you decide to add more enemy types. It’s easy enough to remember “Orc” and “Troll”, but most roguelikes have a wide variety of enemies, so it’s helpful to know what each letter on the screen means.

We can accomplish this by displaying the names of the entities that are currently under the player’s mouse. We’ll need to make a few changes to our project to capture the mouse’s current position, however.

Edit main.py like this:

        root_console = tcod.Console(screen_width, screen_height, order="F")
        while True:
+           root_console.clear()
+           engine.event_handler.on_render(console=root_console)
+           context.present(root_console)
-           engine.render(console=root_console, context=context)
 
+           engine.event_handler.handle_events(context)
-           engine.event_handler.handle_events()
        root_console = tcod.Console(screen_width, screen_height, order="F")
        while True:
            root_console.clear()
            engine.event_handler.on_render(console=root_console)
            context.present(root_console)
            engine.render(console=root_console, context=context)
 
            engine.event_handler.handle_events(context)
            engine.event_handler.handle_events()

We’re adding the console’s clear back to main, as well as the context’s present. Also, we’re calling a method that we haven’t defined yet: on_render, but don’t worry, we’ll define it in a moment. Basically, this method tells the engine to render.

We’re also passing the context to handle_events now, because we need to call an extra method on it to capture the mouse input.

Now let’s modify input_handlers.py to contain the methods we’re calling in main.py:

class EventHandler(tcod.event.EventDispatch[Action]):
    def __init__(self, engine: Engine):
        self.engine = engine

-   def handle_events(self) -> None:
-       raise NotImplementedError()

+   def handle_events(self, context: tcod.context.Context) -> None:
+       for event in tcod.event.wait():
+           context.convert_event(event)
+           self.dispatch(event)

    def ev_quit(self, event: tcod.event.Quit) -> Optional[Action]:
        raise SystemExit()

+   def on_render(self, console: tcod.Console) -> None:
+       self.engine.render(console)


class MainGameEventHandler(EventHandler):
-   def handle_events(self) -> None:
+   def handle_events(self, context: tcod.context.Context) -> None:
        for event in tcod.event.wait():
+           context.convert_event(event)

            action = self.dispatch(event)
            ...


class GameOverEventHandler(EventHandler):
-   def handle_events(self) -> None:
+   def handle_events(self, context: tcod.context.Context) -> None:
        ...
class EventHandler(tcod.event.EventDispatch[Action]):
    def __init__(self, engine: Engine):
        self.engine = engine

    def handle_events(self) -> None:
        raise NotImplementedError()

    def handle_events(self, context: tcod.context.Context) -> None:
        for event in tcod.event.wait():
            context.convert_event(event)
            self.dispatch(event)

    def ev_quit(self, event: tcod.event.Quit) -> Optional[Action]:
        raise SystemExit()

    def on_render(self, console: tcod.Console) -> None:
        self.engine.render(console)


class MainGameEventHandler(EventHandler):
    def handle_events(self) -> None:
    def handle_events(self, context: tcod.context.Context) -> None:
        for event in tcod.event.wait():
            context.convert_event(event)

            action = self.dispatch(event)
            ...


class GameOverEventHandler(EventHandler):
    def handle_events(self) -> None:
    def handle_events(self, context: tcod.context.Context) -> None:
        ...

We’re modifying the handle_events method in EventHandler to actually have an implementation. It iterates through the events, and uses context.convert_event to give the event knowledge on the mouse position. It then dispatches that event, to be handled like normal.

on_render just tells the Engine class to call its render method, using the given console.

MainGameEventHandler and GameOverEventHandler have small changes to their handle_events methods to match the signature of EventHandler, and MainGameEventHandler also uses context.convert_event.

We’re no longer passing the context to the Engine class’s render method, so let’s change the method now:

...
from typing import TYPE_CHECKING

-from tcod.context import Context
from tcod.console import Console
...

class Engine:
    ...

-   def render(self, console: Console, context: Context) -> None:
+   def render(self, console: Console) -> None:
        self.game_map.render(console)

        self.message_log.render(console=console, x=21, y=45, width=40, height=5)

        render_bar(
            console=console,
            current_value=self.player.fighter.hp,
            maximum_value=self.player.fighter.max_hp,
            total_width=20,
        )

-       context.present(console)

-       console.clear()
...
from typing import TYPE_CHECKING

from tcod.context import Context
from tcod.console import Console
...

class Engine:
    ...

    def render(self, console: Console, context: Context) -> None:
    def render(self, console: Console) -> None:
        self.game_map.render(console)

        self.message_log.render(console=console, x=21, y=45, width=40, height=5)

        render_bar(
            console=console,
            current_value=self.player.fighter.hp,
            maximum_value=self.player.fighter.max_hp,
            total_width=20,
        )

        context.present(console)

        console.clear()

We’ve also removed the console.clear call, as that’s being handled by main.py.

So we’re passing the context around to different classes and converting the events to capture the mouse location. But where does that information actually get stored? Let’s add a data point on to the Engine class to hold that information. Add the following to engine.py:

class Engine:
    game_map: GameMap

    def __init__(self, player: Actor):
        self.event_handler: EventHandler = MainGameEventHandler(self)
        self.message_log = MessageLog()
+       self.mouse_location = (0, 0)
        self.player = player
class Engine:
    game_map: GameMap

    def __init__(self, player: Actor):
        self.event_handler: EventHandler = MainGameEventHandler(self)
        self.message_log = MessageLog()
        self.mouse_location = (0, 0)
        self.player = player

Okay, so we’ve got a place to store the mouse location, but where do we actually get that information?

There’s an easy way: by overriding a method in EventHandler, which is called ev_mousemotion. By doing that, we can write the mouse location to the engine for access later. Here’s how that looks:

class EventHandler(tcod.event.EventDispatch[Action]):
    def __init__(self, engine: Engine):
        self.engine = engine

    def handle_events(self, context: tcod.context.Context) -> None:
        for event in tcod.event.wait():
            context.convert_event(event)
            self.dispatch(event)

+   def ev_mousemotion(self, event: tcod.event.MouseMotion) -> None:
+       if self.engine.game_map.in_bounds(event.tile.x, event.tile.y):
+           self.engine.mouse_location = event.tile.x, event.tile.y

    def ev_quit(self, event: tcod.event.Quit) -> Optional[Action]:
        raise SystemExit()
class EventHandler(tcod.event.EventDispatch[Action]):
    def __init__(self, engine: Engine):
        self.engine = engine

    def handle_events(self, context: tcod.context.Context) -> None:
        for event in tcod.event.wait():
            context.convert_event(event)
            self.dispatch(event)

    def ev_mousemotion(self, event: tcod.event.MouseMotion) -> None:
        if self.engine.game_map.in_bounds(event.tile.x, event.tile.y):
            self.engine.mouse_location = event.tile.x, event.tile.y

    def ev_quit(self, event: tcod.event.Quit) -> Optional[Action]:
        raise SystemExit()

Great! Now we’re saving the mouse’s location, so it’s time to actually make use of it. Our original goal was to display the entity names that are in the mouse’s current position. The hard part is already done, now all we need to do is check which entities are in the given location, get their names, and print them out to the screen.

Since this has to do with rendering, let’s put these new functions in render_functions.py:

from __future__ import annotations

from typing import TYPE_CHECKING

import color

if TYPE_CHECKING:
    from tcod import Console
+   from engine import Engine
+   from game_map import GameMap


+def get_names_at_location(x: int, y: int, game_map: GameMap) -> str:
+   if not game_map.in_bounds(x, y) or not game_map.visible[x, y]:
+       return ""

+   names = ", ".join(
+       entity.name for entity in game_map.entities if entity.x == x and entity.y == y
+   )

+   return names.capitalize()


def render_bar(
    console: Console, current_value: int, maximum_value: int, total_width: int
) -> None:
    bar_width = int(float(current_value) / maximum_value * total_width)

    console.draw_rect(x=0, y=45, width=20, height=1, ch=1, bg=color.bar_empty)

    if bar_width > 0:
        console.draw_rect(
            x=0, y=45, width=bar_width, height=1, ch=1, bg=color.bar_filled
        )

    console.print(
        x=1, y=45, string=f"HP: {current_value}/{maximum_value}", fg=color.bar_text
    )


+def render_names_at_mouse_location(
+   console: Console, x: int, y: int, engine: Engine
+) -> None:
+   mouse_x, mouse_y = engine.mouse_location

+   names_at_mouse_location = get_names_at_location(
+       x=mouse_x, y=mouse_y, game_map=engine.game_map
+   )

+   console.print(x=x, y=y, string=names_at_mouse_location)
from __future__ import annotations

from typing import TYPE_CHECKING

import color

if TYPE_CHECKING:
    from tcod import Console
    from engine import Engine
    from game_map import GameMap


def get_names_at_location(x: int, y: int, game_map: GameMap) -> str:
    if not game_map.in_bounds(x, y) or not game_map.visible[x, y]:
        return ""

    names = ", ".join(
        entity.name for entity in game_map.entities if entity.x == x and entity.y == y
    )

    return names.capitalize()


def render_bar(
    console: Console, current_value: int, maximum_value: int, total_width: int
) -> None:
    bar_width = int(float(current_value) / maximum_value * total_width)

    console.draw_rect(x=0, y=45, width=20, height=1, ch=1, bg=color.bar_empty)

    if bar_width > 0:
        console.draw_rect(
            x=0, y=45, width=bar_width, height=1, ch=1, bg=color.bar_filled
        )

    console.print(
        x=1, y=45, string=f"HP: {current_value}/{maximum_value}", fg=color.bar_text
    )


def render_names_at_mouse_location(
    console: Console, x: int, y: int, engine: Engine
) -> None:
    mouse_x, mouse_y = engine.mouse_location

    names_at_mouse_location = get_names_at_location(
        x=mouse_x, y=mouse_y, game_map=engine.game_map
    )

    console.print(x=x, y=y, string=names_at_mouse_location)

We’ve added two new functions, render_names_at_mouse_location and get_names_at_location. Let’s discuss what each one does.

render_names_at_mouse_location takes the console, x and y coordinates (the location to draw the names), and the engine. From the engine, it grabs the mouse’s current x and y positions, and passes them to get_names_at_location, which we can assume for the moment will return the list of entity names we want. Once we have these entity names as a string, we can print that string to the given x and y location on the screen, with console.print.

get_names_at_location also takes “x” and “y” variables, though these represent a spot on the map. We first check that the x and y coordinates are within the map, and are currently visible to the player. If they are, then we create a string of the entity names at that spot, separated by a comma. We then return that string, adding capitalize to make sure the first letter in the string is capitalized.

Now all we need to do is modify engine.py to import these functions and utilize them in the render method. Make the following modifications:

...
from message_log import MessageLog
-from render_functions import render_bar
+from render_functions import render_bar, render_names_at_mouse_location

if TYPE_CHECKING:
    ...

    ...
    def render(self, console: Console) -> None:
        self.game_map.render(console)

        self.message_log.render(console=console, x=21, y=45, width=40, height=5)

        render_bar(
            console=console,
            current_value=self.player.fighter.hp,
            maximum_value=self.player.fighter.max_hp,
            total_width=20,
        )

+       render_names_at_mouse_location(console=console, x=21, y=44, engine=self)
...
from message_log import MessageLog
from render_functions import render_bar
from render_functions import render_bar, render_names_at_mouse_location

if TYPE_CHECKING:
    ...
    
    ...
    def render(self, console: Console) -> None:
        self.game_map.render(console)

        self.message_log.render(console=console, x=21, y=45, width=40, height=5)

        render_bar(
            console=console,
            current_value=self.player.fighter.hp,
            maximum_value=self.player.fighter.max_hp,
            total_width=20,
        )

        render_names_at_mouse_location(console=console, x=21, y=44, engine=self)

Now if you hover your mouse over an entity, you’ll see its name. If you stack a few corpses up, you’ll notice that it prints a list of the names.

We’re almost finished with this chapter. Before we wrap up, let’s revisit our message log for a moment. One issue with it is that we can’t see messages that are too far back. However, HexDecimal was kind enough to provide a method for viewing the whole log, with the ability to scroll.

Add the following to input_handlers.py:

class GameOverEventHandler(EventHandler):
    ...


+CURSOR_Y_KEYS = {
+   tcod.event.K_UP: -1,
+   tcod.event.K_DOWN: 1,
+   tcod.event.K_PAGEUP: -10,
+   tcod.event.K_PAGEDOWN: 10,
+}


+class HistoryViewer(EventHandler):
+   """Print the history on a larger window which can be navigated."""

+   def __init__(self, engine: Engine):
+       super().__init__(engine)
+       self.log_length = len(engine.message_log.messages)
+       self.cursor = self.log_length - 1

+   def on_render(self, console: tcod.Console) -> None:
+       super().on_render(console)  # Draw the main state as the background.

+       log_console = tcod.Console(console.width - 6, console.height - 6)

+       # Draw a frame with a custom banner title.
+       log_console.draw_frame(0, 0, log_console.width, log_console.height)
+       log_console.print_box(
+           0, 0, log_console.width, 1, "┤Message history├", alignment=tcod.CENTER
+       )

+       # Render the message log using the cursor parameter.
+       self.engine.message_log.render_messages(
+           log_console,
+           1,
+           1,
+           log_console.width - 2,
+           log_console.height - 2,
+           self.engine.message_log.messages[: self.cursor + 1],
+       )
+       log_console.blit(console, 3, 3)

+   def ev_keydown(self, event: tcod.event.KeyDown) -> None:
+       # Fancy conditional movement to make it feel right.
+       if event.sym in CURSOR_Y_KEYS:
+           adjust = CURSOR_Y_KEYS[event.sym]
+           if adjust < 0 and self.cursor == 0:
+               # Only move from the top to the bottom when you're on the edge.
+               self.cursor = self.log_length - 1
+           elif adjust > 0 and self.cursor == self.log_length - 1:
+               # Same with bottom to top movement.
+               self.cursor = 0
+           else:
+               # Otherwise move while staying clamped to the bounds of the history log.
+               self.cursor = max(0, min(self.cursor + adjust, self.log_length - 1))
+       elif event.sym == tcod.event.K_HOME:
+           self.cursor = 0  # Move directly to the top message.
+       elif event.sym == tcod.event.K_END:
+           self.cursor = self.log_length - 1  # Move directly to the last message.
+       else:  # Any other key moves back to the main game state.
+           self.engine.event_handler = MainGameEventHandler(self.engine)
class GameOverEventHandler(EventHandler):
    ...


CURSOR_Y_KEYS = {
    tcod.event.K_UP: -1,
    tcod.event.K_DOWN: 1,
    tcod.event.K_PAGEUP: -10,
    tcod.event.K_PAGEDOWN: 10,
}


class HistoryViewer(EventHandler):
    """Print the history on a larger window which can be navigated."""

    def __init__(self, engine: Engine):
        super().__init__(engine)
        self.log_length = len(engine.message_log.messages)
        self.cursor = self.log_length - 1

    def on_render(self, console: tcod.Console) -> None:
        super().on_render(console)  # Draw the main state as the background.

        log_console = tcod.Console(console.width - 6, console.height - 6)

        # Draw a frame with a custom banner title.
        log_console.draw_frame(0, 0, log_console.width, log_console.height)
        log_console.print_box(
            0, 0, log_console.width, 1, "┤Message history├", alignment=tcod.CENTER
        )

        # Render the message log using the cursor parameter.
        self.engine.message_log.render_messages(
            log_console,
            1,
            1,
            log_console.width - 2,
            log_console.height - 2,
            self.engine.message_log.messages[: self.cursor + 1],
        )
        log_console.blit(console, 3, 3)

    def ev_keydown(self, event: tcod.event.KeyDown) -> None:
        # Fancy conditional movement to make it feel right.
        if event.sym in CURSOR_Y_KEYS:
            adjust = CURSOR_Y_KEYS[event.sym]
            if adjust < 0 and self.cursor == 0:
                # Only move from the top to the bottom when you're on the edge.
                self.cursor = self.log_length - 1
            elif adjust > 0 and self.cursor == self.log_length - 1:
                # Same with bottom to top movement.
                self.cursor = 0
            else:
                # Otherwise move while staying clamped to the bounds of the history log.
                self.cursor = max(0, min(self.cursor + adjust, self.log_length - 1))
        elif event.sym == tcod.event.K_HOME:
            self.cursor = 0  # Move directly to the top message.
        elif event.sym == tcod.event.K_END:
            self.cursor = self.log_length - 1  # Move directly to the last message.
        else:  # Any other key moves back to the main game state.
            self.engine.event_handler = MainGameEventHandler(self.engine)

To show this new view, all we need to do is this, in MainGameEventHandler:

        ...
        elif key == tcod.event.K_ESCAPE:
            action = EscapeAction(player)
+       elif key == tcod.event.K_v:
+           self.engine.event_handler = HistoryViewer(self.engine)
        ...
        elif key == tcod.event.K_ESCAPE:
            action = EscapeAction(player)
        elif key == tcod.event.K_v:
            self.engine.event_handler = HistoryViewer(self.engine)

Now all the player has to do is press the “v” key to see a log of all past messages. By using the up and down keys, you can scroll through the log.

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.