Part 2 - Entities#

Note

This tutorial is still a work-in-progress. The resources being used are tracked here. Feel free to discuss this tutorial or share your progress on the Github Discussions forum.

In part 2 entities will be added and a new state will be created to handle them. This part will also begin to split logic into multiple Python modules using a namespace called game.

Entities will be handled with an ECS implementation, in this case: tcod-ecs. tcod-ecs is a standalone package and is installed separately from tcod. Use pip install tcod-ecs to install this package.

Namespace package#

Create a new folder called game and inside the folder create a new python file named __init__.py. game/__init__.py only needs a docstring describing that it is a namespace package:

"""Game namespace package."""

This package will be used to organize new modules.

Organizing globals#

There are a few variables which will need to be accessible from multiple modules. Any global variables which might be assigned from other modules will need to a tracked and handled with care.

Create a new module: g.py [1]. This module is exceptional and will be placed at the top-level instead of in the game folder.

In g.py import tcod.context and tcod.ecs.

context from main.py will now be annotated in g.py by adding the line context: tcod.context.Context by itself. Notice that is this only a type-hinted name and nothing is assigned to it. This means that type-checking will assume the variable always exists but using it before it is assigned will crash at run-time.

main.py should add import g and replace the variables named context with g.context.

Then add the world: tcod.ecs.Registry global to hold the ECS scope.

It is important to document all variables placed in this module with docstrings.

"""This module stores globally mutable variables used by this program."""
from __future__ import annotations

import tcod.context
import tcod.ecs

context: tcod.context.Context
"""The window managed by tcod."""

world: tcod.ecs.Registry
"""The active ECS registry and current session."""

Ideally you should not overuse this module for too many things. When a variable can either be taken as a function parameter or accessed as a global then passing as a parameter is always preferable.

ECS tags#

Create game/tags.py. This will hold some sentinel values to be used as tags for tcod-ecs. These tags can be anything that’s both unique and unchanging, in this case Python strings are used.

For example IsPlayer: Final = "IsPlayer" will tag an object as being controlled by the player. The name is IsPlayer and string is the same as the name. The Final annotation clarifies that this a constant. Sentinel values for tcod-ecs are named like classes, similar to names like None or False.

Repeat this for IsActor and IsItem tags. The game/tags.py module should look like this:

"""Collection of common tags."""
from __future__ import annotations

from typing import Final

IsPlayer: Final = "IsPlayer"
"""Entity is the player."""

IsActor: Final = "IsActor"
"""Entity is an actor."""

IsItem: Final = "IsItem"
"""Entity is an item."""

ECS components#

Next is a new game/components.py module. This will hold the components for the graphics and position of entities.

Start by adding an import for attrs. The ability to easily design small classes which are frozen/immutable is important for working with tcod-ecs.

The first component will be a Position class. This class will be decorated with @attrs.define(frozen=True). For attributes this class will have x: int and y: int.

It will be common to add vectors to a Position with code such as new_pos: Position = Position(0, 0) + (0, 1). Create the dunder method def __add__(self, direction: tuple[int, int]) -> Self: to allow this syntax. Unpack the input with x, y = direction. self.__class__ is the current class so self.__class__(self.x + x, self.y + y) will create a new instance with the direction added to the previous values.

The new class will look like this:

@attrs.define(frozen=True)
class Position:
    """An entities position."""

    x: int
    y: int

    def __add__(self, direction: tuple[int, int]) -> Self:
        """Add a vector to this position."""
        x, y = direction
        return self.__class__(self.x + x, self.y + y)

Because Position is immutable, tcod-ecs is able to reliably track changes to this component. Normally you can only query entities by which components they have. A callback can be registered with tcod-ecs to mirror component values as tags. This allows querying an entity by its exact position.

Add import tcod.ecs.callbacks and from tcod.ecs import Entity. Then create the new function def on_position_changed(entity: Entity, old: Position | None, new: Position | None) -> None: decorated with @tcod.ecs.callbacks.register_component_changed(component=Position). This function is called when the Position component is either added, removed, or modified by assignment. The goal of this function is to mirror the current position to the set-like attribute entity.tags.

if old == new: then a position was assigned its own value or an equivalent value. The cost of discarding and adding the same value can sometimes be high so this case should be guarded and ignored. if old is not None: then the value tracked by entity.tags is outdated and must be removed. if new is not None: then new is the up-to-date value to be tracked by entity.tags.

The function should look like this:

@tcod.ecs.callbacks.register_component_changed(component=Position)
def on_position_changed(entity: Entity, old: Position | None, new: Position | None) -> None:
    """Mirror position components as a tag."""
    if old == new:  # New position is equivalent to its previous value
        return  # Ignore and return
    if old is not None:  # Position component removed or changed
        entity.tags.discard(old)  # Remove old position from tags
    if new is not None:  # Position component added or changed
        entity.tags.add(new)  # Add new position to tags

Next is the Graphic component. This will have the attributes ch: int = ord("!") and fg: tuple[int, int, int] = (255, 255, 255). By default all new components should be marked as frozen.

@attrs.define(frozen=True)
class Graphic:
    """An entities icon and color."""

    ch: int = ord("!")
    fg: tuple[int, int, int] = (255, 255, 255)

One last component: Gold. Define this as Gold: Final = ("Gold", int). (name, type) is tcod-ecs specific syntax to handle multiple components sharing the same type.

Gold: Final = ("Gold", int)
"""Amount of gold."""

That was the last component. The game/components.py module should look like this:

"""Collection of common components."""
from __future__ import annotations

from typing import Final, Self

import attrs
import tcod.ecs.callbacks
from tcod.ecs import Entity


@attrs.define(frozen=True)
class Position:
    """An entities position."""

    x: int
    y: int

    def __add__(self, direction: tuple[int, int]) -> Self:
        """Add a vector to this position."""
        x, y = direction
        return self.__class__(self.x + x, self.y + y)


@tcod.ecs.callbacks.register_component_changed(component=Position)
def on_position_changed(entity: Entity, old: Position | None, new: Position | None) -> None:
    """Mirror position components as a tag."""
    if old == new:
        return
    if old is not None:
        entity.tags.discard(old)
    if new is not None:
        entity.tags.add(new)


@attrs.define(frozen=True)
class Graphic:
    """An entities icon and color."""

    ch: int = ord("!")
    fg: tuple[int, int, int] = (255, 255, 255)


Gold: Final = ("Gold", int)
"""Amount of gold."""

ECS entities and registry#

Now it is time to create entities. To do that you need to create the ECS registry.

Make a new script called game/world_tools.py. This module will be used to create the ECS registry.

Random numbers from random will be used. In this case we want to use Random as a component so add from random import Random. Get the registry with from tcod.ecs import Registry. Collect all our components and tags with from game.components import Gold, Graphic, Position and from game.tags import IsActor, IsItem, IsPlayer.

This module will have one function: def new_world() -> Registry:. Think of the ECS registry as containing the world since this is how it will be used. Start this function with world = Registry().

Entities are referenced with the syntax world[unique_id]. If the same unique_id is used then you will access the same entity. new_entity = world[object()] is the syntax to spawn new entities because object() is always unique. Whenever a global entity is needed then world[None] will be used.

Create an instance of Random() and assign it to both world[None].components[Random] and rng. This can done on one line with rng = world[None].components[Random] = Random().

Next create the player entity with player = world[object()]. Assign the following components to the new player entity: player.components[Position] = Position(5, 5), player.components[Graphic] = Graphic(ord("@")), and player.components[Gold] = 0. Then update the players tags with player.tags |= {IsPlayer, IsActor}.

To add some variety we will scatter gold randomly across the world. Start a for-loop with for _ in range(10): then create a gold entity in this loop.

The Random instance rng has access to functions from Python’s random module such as random.randint. Set Position to Position(rng.randint(0, 20), rng.randint(0, 20)). Set Graphic to Graphic(ord("$"), fg=(255, 255, 0)). Set Gold to rng.randint(1, 10). Then add IsItem as a tag.

Once the for-loop exits then return world. Make sure return has the correct indentation and is not part of the for-loop or else you will only spawn one gold.

game/world_tools.py should look like this:

"""Functions for working with worlds."""
from __future__ import annotations

from random import Random

from tcod.ecs import Registry

from game.components import Gold, Graphic, Position
from game.tags import IsActor, IsItem, IsPlayer


def new_world() -> Registry:
    """Return a freshly generated world."""
    world = Registry()

    rng = world[None].components[Random] = Random()

    player = world[object()]
    player.components[Position] = Position(5, 5)
    player.components[Graphic] = Graphic(ord("@"))
    player.components[Gold] = 0
    player.tags |= {IsPlayer, IsActor}

    for _ in range(10):
        gold = world[object()]
        gold.components[Position] = Position(rng.randint(0, 20), rng.randint(0, 20))
        gold.components[Graphic] = Graphic(ord("$"), fg=(255, 255, 0))
        gold.components[Gold] = rng.randint(1, 10)
        gold.tags |= {IsItem}

    return world

New InGame state#

Now there is a new ECS world but the example state does not know how to render it. A new state needs to be made which is aware of the new entities.

Create a new script called game/states.py. states is for derived classes, state is for the abstract class. New states will be created in this module and this module will be allowed to import many first party modules without issues.

Before adding a new state it is time to add a more complete set of directional keys. These will be added as a dictionary and can be reused anytime we want to know how a key translates to a direction. Use from tcod.event import KeySym to make KeySym enums easier to write. Then add the following:

DIRECTION_KEYS: Final = {
    # Arrow keys
    KeySym.LEFT: (-1, 0),
    KeySym.RIGHT: (1, 0),
    KeySym.UP: (0, -1),
    KeySym.DOWN: (0, 1),
    # Arrow key diagonals
    KeySym.HOME: (-1, -1),
    KeySym.END: (-1, 1),
    KeySym.PAGEUP: (1, -1),
    KeySym.PAGEDOWN: (1, 1),
    # Keypad
    KeySym.KP_4: (-1, 0),
    KeySym.KP_6: (1, 0),
    KeySym.KP_8: (0, -1),
    KeySym.KP_2: (0, 1),
    KeySym.KP_7: (-1, -1),
    KeySym.KP_1: (-1, 1),
    KeySym.KP_9: (1, -1),
    KeySym.KP_3: (1, 1),
    # VI keys
    KeySym.h: (-1, 0),
    KeySym.l: (1, 0),
    KeySym.k: (0, -1),
    KeySym.j: (0, 1),
    KeySym.y: (-1, -1),
    KeySym.b: (-1, 1),
    KeySym.u: (1, -1),
    KeySym.n: (1, 1),
}

Create a new class InGame: decorated with @attrs.define(eq=False). States will always use g.world to access the ECS registry.

@attrs.define(eq=False)
class InGame:
    """Primary in-game state."""
    ...

Create an on_event and on_draw method matching the ExampleState class. Copying ExampleState and modifying it should be enough since this wil replace ExampleState.

Now to do an tcod-ecs query to fetch the player entity. In tcod-ecs queries most often start with g.world.Q.all_of(components=[], tags=[]). Which components and tags are asked for will narrow down the returned set of entities to only those matching the requirements. The query to fetch player entities is g.world.Q.all_of(tags=[IsPlayer]). We expect only one player so the result will be unpacked into a single name: (player,) = g.world.Q.all_of(tags=[IsPlayer]).

Next is to handle the event. Handling case tcod.event.Quit(): is the same as before: raise SystemExit().

The case for direction keys will now be done in a single case: case tcod.event.KeyDown(sym=sym) if sym in DIRECTION_KEYS:. sym=sym assigns from the event attribute to a local name. The left side is the event.sym attribute and right side is the local name sym being assigned to. The case also has a condition which must pass for this branch to be taken and in this case we ensure that only keys from the DIRECTION_KEYS dictionary are valid sym’s.

Inside this branch moving the player is simple. Access the (x, y) vector with DIRECTION_KEYS[sym] and use += to add it to the players current Position component. This triggers the earlier written __add__ dunder method and on_position_changed callback.

Now that the player has moved it would be a good time to interact with the gold entities. The query to see if the player has stepped on gold is to check for whichever entities have a Gold component, an IsItem tag, and the players current position as a tag. The query for this is g.world.Q.all_of(components=[Gold], tags=[player.components[Position], IsItem]):.

We will iterate over whatever matches this query using a for gold in ...: loop. Add the entities Gold component to the players similar component. Keep in mind that Gold is treated like an int so its usage is predictable.

Format the added and total of gold using a Python f-string: text = f"Picked up {gold.components[Gold]}g, total: {player.components[Gold]}g". Store text globally in the ECS registry with g.world[None].components[("Text", str)] = text. This is done as two lines to avoid creating a line with an excessive length.

Then use gold.clear() at the end to remove all components and tags from the gold entity which will effectively delete it.

...
def on_event(self, event: tcod.event.Event) -> None:
    """Handle events for the in-game state."""
    (player,) = g.world.Q.all_of(tags=[IsPlayer])
    match event:
        case tcod.event.Quit():
            raise SystemExit()
        case tcod.event.KeyDown(sym=sym) if sym in DIRECTION_KEYS:
            player.components[Position] += DIRECTION_KEYS[sym]
            # Auto pickup gold
            for gold in g.world.Q.all_of(components=[Gold], tags=[player.components[Position], IsItem]):
                player.components[Gold] += gold.components[Gold]
                text = f"Picked up {gold.components[Gold]}g, total: {player.components[Gold]}g"
                g.world[None].components[str] = text
                gold.clear()
...

Now start with the on_draw method. Any entity with both a Position and a Graphic is drawable. Iterate over these entities with for entity in g.world.Q.all_of(components=[Position, Graphic]):. Accessing components can be slow in a loop, so assign components to local names before using them (pos = entity.components[Position] and graphic = entity.components[Graphic]).

Check if a components position is in the bounds of the console. 0 <= pos.x < console.width and 0 <= pos.y < console.height tells if the position is in bounds. Instead of nesting this method further, this check should be a guard using if not (...): and continue.

Draw the graphic by assigning it to the consoles Numpy array directly with console.rgb[["ch", "fg"]][pos.y, pos.x] = graphic.ch, graphic.fg. console.rgb is a ch,fg,bg array and [["ch", "fg"]] narrows it down to only ch,fg. The array is in C row-major memory order so you access it with yx (or ij) ordering.

That ends the entity rendering loop. Next is to print the ("Text", str) component if it exists. A normal access will raise KeyError if the component is accessed before being assigned. This case will be handled by the .get method of the Entity.components attribute. g.world[None].components.get(("Text", str)) will return None instead of raising KeyError. Assigning this result to text and then checking if text: will ensure that text within the branch is not None and that the string is not empty. We will not use text outside of the branch, so an assignment expression can be used here to check and assign the name at the same time with if text := g.world[None].components.get(("Text", str)):.

In this branch you will print text to the bottom of the console with a white foreground and black background. The call to do this is console.print(x=0, y=console.height - 1, string=text, fg=(255, 255, 255), bg=(0, 0, 0)).

...
def on_draw(self, console: tcod.console.Console) -> None:
    """Draw the standard screen."""
    for entity in g.world.Q.all_of(components=[Position, Graphic]):
        pos = entity.components[Position]
        if not (0 <= pos.x < console.width and 0 <= pos.y < console.height):
            continue
        graphic = entity.components[Graphic]
        console.rgb[["ch", "fg"]][pos.y, pos.x] = graphic.ch, graphic.fg

    if text := g.world[None].components.get(("Text", str)):
        console.print(x=0, y=console.height - 1, string=text, fg=(255, 255, 255), bg=(0, 0, 0))

Verify the indentation of the if branch is correct. It should be at the same level as the for loop and not inside of it.

game/states.py should now look like this:

"""A collection of game states."""
from __future__ import annotations

from typing import Final

import attrs
import tcod.console
import tcod.event
from tcod.event import KeySym

import g
from game.components import Gold, Graphic, Position
from game.tags import IsItem, IsPlayer

DIRECTION_KEYS: Final = {
    # Arrow keys
    KeySym.LEFT: (-1, 0),
    KeySym.RIGHT: (1, 0),
    KeySym.UP: (0, -1),
    KeySym.DOWN: (0, 1),
    # Arrow key diagonals
    KeySym.HOME: (-1, -1),
    KeySym.END: (-1, 1),
    KeySym.PAGEUP: (1, -1),
    KeySym.PAGEDOWN: (1, 1),
    # Keypad
    KeySym.KP_4: (-1, 0),
    KeySym.KP_6: (1, 0),
    KeySym.KP_8: (0, -1),
    KeySym.KP_2: (0, 1),
    KeySym.KP_7: (-1, -1),
    KeySym.KP_1: (-1, 1),
    KeySym.KP_9: (1, -1),
    KeySym.KP_3: (1, 1),
    # VI keys
    KeySym.h: (-1, 0),
    KeySym.l: (1, 0),
    KeySym.k: (0, -1),
    KeySym.j: (0, 1),
    KeySym.y: (-1, -1),
    KeySym.b: (-1, 1),
    KeySym.u: (1, -1),
    KeySym.n: (1, 1),
}


@attrs.define(eq=False)
class InGame:
    """Primary in-game state."""

    def on_event(self, event: tcod.event.Event) -> None:
        """Handle events for the in-game state."""
        (player,) = g.world.Q.all_of(tags=[IsPlayer])
        match event:
            case tcod.event.Quit():
                raise SystemExit()
            case tcod.event.KeyDown(sym=sym) if sym in DIRECTION_KEYS:
                player.components[Position] += DIRECTION_KEYS[sym]
                # Auto pickup gold
                for gold in g.world.Q.all_of(components=[Gold], tags=[player.components[Position], IsItem]):
                    player.components[Gold] += gold.components[Gold]
                    text = f"Picked up {gold.components[Gold]}g, total: {player.components[Gold]}g"
                    g.world[None].components[("Text", str)] = text
                    gold.clear()

    def on_draw(self, console: tcod.console.Console) -> None:
        """Draw the standard screen."""
        for entity in g.world.Q.all_of(components=[Position, Graphic]):
            pos = entity.components[Position]
            if not (0 <= pos.x < console.width and 0 <= pos.y < console.height):
                continue
            graphic = entity.components[Graphic]
            console.rgb[["ch", "fg"]][pos.y, pos.x] = graphic.ch, graphic.fg

        if text := g.world[None].components.get(("Text", str)):
            console.print(x=0, y=console.height - 1, string=text, fg=(255, 255, 255), bg=(0, 0, 0))

Main script update#

Back to main.py. At this point you should know to import the modules needed.

The ExampleState class is obsolete and will be removed. state will be created with game.states.InGame() instead.

If you have not replaced context with g.context yet then do it now.

Add g.world = game.world_tools.new_world() before the main loop.

main.py will look like this:

#!/usr/bin/env python3
"""Main entry-point module. This script is used to start the program."""
from __future__ import annotations

import tcod.console
import tcod.context
import tcod.event
import tcod.tileset

import g
import game.states
import game.world_tools


def main() -> None:
    """Entry point function."""
    tileset = tcod.tileset.load_tilesheet(
        "data/Alloy_curses_12x12.png", columns=16, rows=16, charmap=tcod.tileset.CHARMAP_CP437
    )
    tcod.tileset.procedural_block_elements(tileset=tileset)
    console = tcod.console.Console(80, 50)
    state = game.states.InGame()
    g.world = game.world_tools.new_world()
    with tcod.context.new(console=console, tileset=tileset) as g.context:
        while True:  # Main loop
            console.clear()  # Clear the console before any drawing
            state.on_draw(console)  # Draw the current state
            g.context.present(console)  # Render the console to the window and show it
            for event in tcod.event.wait():  # Event loop, blocks until pending events exist
                print(event)
                state.on_event(event)  # Dispatch events to the state


if __name__ == "__main__":
    main()

Now you can play a simple game where you wander around collecting gold.

You can review the part-2 source code here.

Footnotes