John McCardle Solders & Hacks

Now that we can move our little ‘@’ symbol around, we need to give it something to move around in. We already have our Entity class from Part 1, but we need to enhance it to work with a proper game map.

First, let’s update our Entity class to support being placed on a map and interacting with other entities. Update game/entity.py:

from future import annotations

from typing import TYPE_CHECKING, Optional, Tuple

if TYPE_CHECKING: import game.game_map

class Entity: “”” A generic object to represent players, enemies, items, etc. “””

gamemap: game.game_map.GameMap

def __init__(
    self,
    gamemap: Optional[game.game_map.GameMap] = None,
    x: int = 0,
    y: int = 0,
    char: str = "?",
    color: Tuple[int, int, int] = (255, 255, 255),
):
    self.x = x
    self.y = y
    self.char = char
    self.color = color
    if gamemap:
        # If gamemap isn't provided now then it will be set later.
        self.gamemap = gamemap
        gamemap.entities.add(self)

def place(self, x: int, y: int, gamemap: Optional[game.game_map.GameMap] = None) -> None:
    """Place this entity at a new location. Handles moving across GameMaps."""
    self.x = x
    self.y = y
    if gamemap:
        if hasattr(self, "gamemap"):  # Possibly uninitialized.
            self.gamemap.entities.remove(self)
        self.gamemap = gamemap
        gamemap.entities.add(self)

def move(self, dx: int, dy: int) -> None:
    # Move the entity by a given amount
    self.x += dx
    self.y += dy

The key changes to our Entity class:

This bidirectional relationship between entities and the map will be essential for checking collisions, rendering, and game logic.

Now let’s update our main.py to create entities using this enhanced approach:

#!/usr/bin/env python3 import tcod

from game.engine import Engine from game.entity import Entity +from game.game_map import GameMap from game.input_handlers import BaseEventHandler, MainGameEventHandler

def main() -> None: screen_width = 80 screen_height = 50

#!/usr/bin/env python3
import tcod

from game.engine import Engine
from game.entity import Entity
from game.game_map import GameMap
from game.input_handlers import BaseEventHandler, MainGameEventHandler


def main() -> None:
    screen_width = 80
    screen_height = 50

    map_width = 80
    map_height = 45

    tileset = tcod.tileset.load_tilesheet("data/dejavu10x10_gs_tc.png", 32, 8, tcod.tileset.CHARMAP_TCOD)

    player = Entity(x=int(screen_width / 2), y=int(screen_height / 2), char="@", color=(255, 255, 255))
    
    engine = Engine(player=player)
    engine = Engine(player=Entity())

    engine.game_map = GameMap(engine, map_width, map_height)

    # Create player and place in map
    engine.player.place(int(screen_width / 2), int(screen_height / 2), engine.game_map)
    engine.player.char = "@"
    engine.player.color = (255, 255, 255)

    # Create an NPC
    npc = Entity()
    npc.place(int(screen_width / 2 - 5), int(screen_height / 2), engine.game_map)
    npc.char = "@"
    npc.color = (255, 255, 0)

    handler: BaseEventHandler = MainGameEventHandler(engine)

We’re creating the engine first with a basic entity as the player, then creating our GameMap (which we’ll define shortly). We use the place method to position entities on the map, which automatically registers them with the map’s entity tracking. Notice how we can set the entity properties after creation - this flexible approach will be useful when we generate entities procedurally.

Before we can run this, we need to create our GameMap class. But first, let’s update our Engine class from Part 1 to work with the game map. Update game/engine.py:

from __future__ import annotations

from typing import TYPE_CHECKING

import tcod

from game.entity import Entity

if TYPE_CHECKING:
    import game.game_map


class Engine:
    game_map: game.game_map.GameMap

    def __init__(self, player: Entity):
        self.player = player

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

Our updated Engine is much simpler - it just holds the player reference and delegates rendering to the game map. The game_map attribute is declared but not set in __init__ - we’ll set it right after creating the engine, as you saw in the main.py changes above. This pattern gives us flexibility in how we initialize our game state.

Now we need to update our actions to work with this new structure. The actions need to get the engine through the entity-gamemap relationship. Update game/actions.py:

from __future__ import annotations

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    import game.engine
    import game.entity


class Action:
    def __init__(self, entity: game.entity.Entity) -> None:
        super().__init__()
        self.entity = entity

    @property
    def engine(self) -> game.engine.Engine:
        """Return the engine this action belongs to."""
        return self.entity.gamemap.engine

    def perform(self) -> None:
        """Perform this action with the objects needed to determine its scope.

        `self.engine` is the scope this action is being performed in.

        `self.entity` is the object performing the action.

        This method must be overridden by Action subclasses.
        """
        raise NotImplementedError()


class EscapeAction(Action):
    def perform(self) -> None:
        raise SystemExit()


class ActionWithDirection(Action):
    def __init__(self, entity: game.entity.Entity, dx: int, dy: int):
        super().__init__(entity)

        self.dx = dx
        self.dy = dy

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


class MovementAction(ActionWithDirection):
    def perform(self) -> None:
        dest_x = self.entity.x + self.dx
        dest_y = self.entity.y + self.dy

        if not self.engine.game_map.in_bounds(dest_x, dest_y):
            return  # Destination is out of bounds.
        if not self.engine.game_map.tiles["walkable"][dest_x, dest_y]:
            return  # Destination is blocked by a tile.
        if self.engine.game_map.get_blocking_entity_at_location(dest_x, dest_y):
            return  # Destination is blocked by an entity.

        self.entity.move(self.dx, self.dy)

Notice how actions now:

With our entities and actions ready, let’s create the map system. We need to define tiles first, then the game map itself.

We can represent the map with a new class, called GameMap. The map itself will be made up of tiles, which will contain certain data about if the tile is “walkable” (True if it’s a floor, False if its a wall), “transparency” (again, True for floors, False for walls), and how to render the tile to the screen.

We’ll create the tiles first. Create a new file called game/tiles.py and fill it with the following contents:

from typing import Tuple

from numpy.typing import NDArray
import numpy as np

# Tile graphics structured type compatible with Console.tiles_rgb.
graphic_dt = np.dtype(
    [
        ("ch", np.int32),  # Unicode codepoint.
        ("fg", "3B"),  # 3 unsigned bytes, for RGB colors.
        ("bg", "3B"),
    ]
)

# Tile struct used for statically defined tile data.
tile_dt = np.dtype(
    [
        ("walkable", bool),  # True if this tile can be walked over.
        ("transparent", bool),  # True if this tile doesn't block FOV.
        ("dark", graphic_dt),  # Graphics for when this tile is not in FOV.
        ("light", graphic_dt),  # Graphics for when the tile is in FOV.
    ]
)


def new_tile(
    *,  # Enforce the use of keywords, so that parameter order doesn't matter.
    walkable: int,
    transparent: int,
    dark: Tuple[int, Tuple[int, int, int], Tuple[int, int, int]],
    light: Tuple[int, Tuple[int, int, int], Tuple[int, int, int]],
) -> NDArray[np.void]:
    """Helper function for defining individual tile types"""
    return np.array((walkable, transparent, dark, light), dtype=tile_dt)


# SHROUD represents unexplored, unseen tiles
SHROUD = np.array((ord(" "), (255, 255, 255), (0, 0, 0)), dtype=graphic_dt)

floor = new_tile(
    walkable=True,
    transparent=True,
    dark=(ord(" "), (255, 255, 255), (50, 50, 150)),
    light=(ord(" "), (255, 255, 255), (200, 180, 50)),
)
wall = new_tile(
    walkable=False,
    transparent=False,
    dark=(ord(" "), (255, 255, 255), (0, 0, 100)),
    light=(ord(" "), (255, 255, 255), (130, 110, 50)),
)

That’s quite a lot to take in all at once. Let’s go through it.

# Tile graphics structured type compatible with Console.tiles_rgb.
graphic_dt = np.dtype(
    [
        ("ch", np.int32),  # Unicode codepoint.
        ("fg", "3B"),  # 3 unsigned bytes, for RGB colors.
        ("bg", "3B"),
    ]
)

dtype creates a data type which Numpy can use, which behaves similarly to a struct in a language like C. Our data type is made up of three parts:

We take this new data type and use it in the next bit:

# Tile struct used for statically defined tile data.
tile_dt = np.dtype(
    [
        ("walkable", bool),  # True if this tile can be walked over.
        ("transparent", bool),  # True if this tile doesn't block FOV.
        ("dark", graphic_dt),  # Graphics for when this tile is not in FOV.
        ("light", graphic_dt),  # Graphics for when the tile is in FOV.
    ]
)

This is yet another dtype, which we’ll use in the actual tile itself. It’s made up of four parts:

def new_tile(
    *,  # Enforce the use of keywords, so that parameter order doesn't matter.
    walkable: int,
    transparent: int,
    dark: Tuple[int, Tuple[int, int, int], Tuple[int, int, int]],
    light: Tuple[int, Tuple[int, int, int], Tuple[int, int, int]],
) -> NDArray[np.void]:
    """Helper function for defining individual tile types"""
    return np.array((walkable, transparent, dark, light), dtype=tile_dt)

This is a helper function, that we’ll use in the next section to define our tile types. It takes the parameters walkable, transparent, dark, and light, which should look familiar, since they’re the same data points we used in tile_dt. It creates a Numpy array of just the one tile_dt element, and returns it.

floor = new_tile(
    walkable=True,
    transparent=True,
    dark=(ord(" "), (255, 255, 255), (50, 50, 150)),
    light=(ord(" "), (255, 255, 255), (200, 180, 50)),
)
wall = new_tile(
    walkable=False,
    transparent=False,
    dark=(ord(" "), (255, 255, 255), (0, 0, 100)),
    light=(ord(" "), (255, 255, 255), (130, 110, 50)),
)

Finally, we arrive to our actual tile types. We’ve got two: floor and wall.

floor is both walkable and transparent. Its dark and light attributes define how it looks out of and in the field of view respectively. The light version has a warmer, yellow-ish tone to show it’s visible.

wall is neither walkable nor transparent, with different colors for its dark and light states. We also define SHROUD for completely unexplored areas, though we won’t use it until Part 4.

Now let’s use our newly created tiles by creating our map class. Create a file called game/game_map.py and fill it with the following:

from __future__ import annotations

from typing import TYPE_CHECKING, Optional, Set

import numpy as np
import tcod

from game.tiles import floor, wall

if TYPE_CHECKING:
    import game.engine
    import game.entity


class GameMap:
    def __init__(self, engine: game.engine.Engine, width: int, height: int):
        self.engine = engine
        self.width, self.height = width, height
        self.entities: Set[game.entity.Entity] = set()
        self.tiles = np.full((width, height), fill_value=floor, order="F")

        # Create a simple test wall
        self.tiles[30:33, 22] = wall

    def get_blocking_entity_at_location(
        self,
        location_x: int,
        location_y: int,
    ) -> Optional[game.entity.Entity]:
        for entity in self.entities:
            if entity.x == location_x and entity.y == location_y:
                return entity

        return None

    def in_bounds(self, x: int, y: int) -> bool:
        """Return True if x and y are inside of the bounds of this map."""
        return 0 <= x < self.width and 0 <= y < self.height

    def render(self, console: tcod.console.Console) -> None:
        """
        Renders the map.

        For now, we'll render all tiles as visible.
        In Part 4 we'll add FOV.
        """
        console.rgb[0 : self.width, 0 : self.height] = self.tiles["light"]

        for entity in self.entities:
            console.print(x=entity.x, y=entity.y, string=entity.char, fg=entity.color)

Let’s break down GameMap a bit:

    def __init__(self, engine: game.engine.Engine, width: int, height: int):
        self.engine = engine
        self.width, self.height = width, height
        self.entities: Set[game.entity.Entity] = set()
        self.tiles = np.full((width, height), fill_value=floor, order="F")

        self.tiles[30:33, 22] = wall

The initializer takes an engine reference (creating that bidirectional relationship), plus width and height integers.

The self.entities set tracks all entities on this map - when entities are placed using their place() method, they’re automatically added here.

The self.tiles line creates a 2D array filled with floor tiles. The order="F" ensures column-major order, matching how we index with [x, y].

self.tiles[30:33, 22] = wall creates a small test wall. We’ll remove this when we add proper dungeon generation in the next part.

    def get_blocking_entity_at_location(
        self,
        location_x: int,
        location_y: int,
    ) -> Optional[game.entity.Entity]:
        for entity in self.entities:
            if entity.x == location_x and entity.y == location_y:
                return entity
        return None

This method checks if any entity is blocking a given location. We’ll use this in movement actions to prevent entities from walking through each other.

    def render(self, console: tcod.console.Console) -> None:
        console.rgb[0 : self.width, 0 : self.height] = self.tiles["light"]
        
        for entity in self.entities:
            console.print(x=entity.x, y=entity.y, string=entity.char, fg=entity.color)

The render method does two things:

  1. Uses console.rgb to quickly render all tiles at once (much faster than printing each individually)
  2. Draws all entities on top of the tiles

Note we’re using tiles["light"] for now - in Part 4 we’ll differentiate between visible and non-visible tiles.

With our GameMap class ready to go, our main.py already sets it up properly. The flow is:

  1. Create the engine with just the player
  2. Create the GameMap with a reference to the engine
  3. Use the place() method to position entities on the map

This creates the bidirectional relationships we need - the engine knows about the map, the map knows about the engine, and entities know which map they’re on.

Also, we need to update our input handler to call perform() without parameters. In game/input_handlers.py, find the handle_action method and update it:

    def handle_action(self, action: Optional[Action]) -> bool:
        """Handle actions returned from event methods.

        Returns True if the action will advance a turn.
        """
        if action is None:
            return False

        action.perform()  # No longer passing self.engine
        return True

If you run the project now, it should look like this:

Part 2 - Both Entities and Map

The darker squares represent the wall, which, if you try to move your character through, should prove to be impenetrable. The player can’t move through the NPC either - our collision detection is working!

The key architectural decisions we’ve made:

With that, Part 2 is now complete! We’ve enhanced our entity system to work with maps, created a tile-based map system, and laid the groundwork for generating dungeons and moving through them, which, as it happens, is what the next part is all about.

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.