Part 1 - Moving a player around the screen

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 1 you will become familiar with the initialization, rendering, and event system of tcod. This will be done as a series of small implementations. It is recommend to save your progress after each section is finished and tested.

Initial script

First start with a modern top-level script. You should have main.py script from Part 0 - Setting up a project:

from __future__ import annotations


def main() -> None:
    ...


if __name__ == "__main__":
    main()

You will replace body of the main function in the following section.

Loading a tileset and opening a window

From here it is time to setup a tcod program. Download Alloy_curses_12x12.png [1] and place this file in your projects data/ directory. This tileset is from the Dwarf Fortress tileset repository. These kinds of tilesets are always loaded with columns=16, rows=16, charmap=tcod.tileset.CHARMAP_CP437. Use the string "data/Alloy_curses_12x12.png" to refer to the path of the tileset. [2]

Load the tileset with tcod.tileset.load_tilesheet. Pass the tileset to tcod.tileset.procedural_block_elements which will fill in most Block Elements missing from Code Page 437. Then pass the tileset to tcod.context.new, you only need to provide the tileset parameter.

tcod.context.new returns a Context which will be used with Python’s with statement. We want to keep the name of the context, so use the syntax: with tcod.context.new(tileset=tileset) as context:. The new block can’t be empty, so add pass to the with statement body.

These functions are part of modules which have not been imported yet, so new imports for tcod.context and tcod.tileset must be added to the top of the script.

from __future__ import annotations

import tcod.context  # Add these imports
import tcod.tileset


def main() -> None:
    """Load a tileset and open a window using it, this window will immediately close."""
    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)
    with tcod.context.new(tileset=tileset) as context:
        pass  # The window will stay open for the duration of this block


if __name__ == "__main__":
    main()

If an import fails that means you do not have tcod installed on the Python environment you just used to run the script. If you use an IDE then make sure the Python environment it is using is correct and then run pip install tcod from the shell terminal within that IDE.

There is no game loop, so if you run this script now then a window will open and then immediately close. If that happens without seeing a traceback in your terminal then the script is correct.

Configuring an event loop

The next step is to keep the window open until the user closes it.

Since nothing is displayed yet a Console should be created with "Hello World" printed to it. The size of the console can be used as a reference to create the context by adding the console to tcod.context.new. [3]

Begin the main game loop with a while True: statement.

To actually display the console to the window the Context.present method must be called with the console as a parameter. Do this first in the game loop before handing events.

Events are checked by iterating over all pending events with tcod.event.wait. Use the code for event in tcod.event.wait(): to begin handing events.

In the event loop start with the line print(event) so that all events can be viewed from the program output. Then test if an event is for closing the window with if isinstance(event, tcod.event.Quit):. If this is True then you should exit the function with raise SystemExit(). [4]

from __future__ import annotations

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


def main() -> None:
    """Show "Hello World" until the window is closed."""
    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)
    console.print(0, 0, "Hello World")  # Test text by printing "Hello World" to the console
    with tcod.context.new(console=console, tileset=tileset) as context:
        while True:  # Main loop
            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)
                if isinstance(event, tcod.event.Quit):
                    raise SystemExit()


if __name__ == "__main__":
    main()

If you run this then you get a window saying "Hello World". The window can be resized and the console will be stretched to fit the new resolution. When you do anything such as press a key or interact with the window the event for that action will be printed to the program output.

An example game state

What exists now is not very interactive. The next step is to change state based on user input.

Like tcod you’ll need to install attrs with Pip, such as with pip install attrs.

Start by adding an attrs class called ExampleState. This a normal class with the @attrs.define(eq=False) decorator added.

This class should hold coordinates for the player. It should also have a on_draw method which takes tcod.console.Console as a parameter and marks the player position on it. The parameters for on_draw are self because this is an instance method and console: tcod.console.Console. on_draw returns nothing, so be sure to add -> None.

Console.print is the simplest way to draw the player because other options would require bounds-checking. Call this method using the players current coordinates and the "@" character.

from __future__ import annotations

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


@attrs.define(eq=False)
class ExampleState:
    """Example state with a hard-coded player position."""

    player_x: int
    """Player X position, left-most position is zero."""
    player_y: int
    """Player Y position, top-most position is zero."""

    def on_draw(self, console: tcod.console.Console) -> None:
        """Draw the player glyph."""
        console.print(self.player_x, self.player_y, "@")

...

Now remove the console.print(0, 0, "Hello World") line from main.

Before the context is made create a new ExampleState with player coordinates on the screen. Each Console has .width and .height attributes which you can divide by 2 to get a centered coordinate for the player. Use Python’s floor division operator // so that the resulting type is int.

Modify the drawing routine so that the console is cleared, then passed to ExampleState.on_draw, then passed to Context.present.

...
def main() -> None:
    """Run ExampleState."""
    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 = ExampleState(player_x=console.width // 2, player_y=console.height // 2)
    with tcod.context.new(console=console, tileset=tileset) as context:
        while True:
            console.clear()  # Clear the console before any drawing
            state.on_draw(console)  # Draw the current state
            context.present(console)  # Display the console on the window
            for event in tcod.event.wait():
                print(event)
                if isinstance(event, tcod.event.Quit):
                    raise SystemExit()


if __name__ == "__main__":
    main()

Now if you run the script you’ll see @.

The next step is to move the player on events. A new method will be added to the ExampleState for this called on_event. on_event takes a self and a tcod.event.Event parameter and returns nothing.

Events are best handled using Python’s Structural Pattern Matching. Consider reading Python’s Structural Pattern Matching Tutorial.

Begin matching with match event:. The equivalent to if isinstance(event, tcod.event.Quit): is case tcod.event.Quit():. Keyboard keys can be checked with case tcod.event.KeyDown(sym=tcod.event.KeySym.LEFT):. Make a case for each arrow key: LEFT RIGHT UP DOWN and move the player in the direction of that key. Since events are printed you can check the KeySym of a key by pressing that key and looking at the printed output. See KeySym for a list of all keys.

Finally replace the event handling code in main to defer to the states on_event method. The full script so far is:

from __future__ import annotations

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


@attrs.define(eq=False)
class ExampleState:
    """Example state with a hard-coded player position."""

    player_x: int
    """Player X position, left-most position is zero."""
    player_y: int
    """Player Y position, top-most position is zero."""

    def on_draw(self, console: tcod.console.Console) -> None:
        """Draw the player glyph."""
        console.print(self.player_x, self.player_y, "@")

    def on_event(self, event: tcod.event.Event) -> None:
        """Move the player on events and handle exiting. Movement is hard-coded."""
        match event:
            case tcod.event.Quit():
                raise SystemExit()
            case tcod.event.KeyDown(sym=tcod.event.KeySym.LEFT):
                self.player_x -= 1
            case tcod.event.KeyDown(sym=tcod.event.KeySym.RIGHT):
                self.player_x += 1
            case tcod.event.KeyDown(sym=tcod.event.KeySym.UP):
                self.player_y -= 1
            case tcod.event.KeyDown(sym=tcod.event.KeySym.DOWN):
                self.player_y += 1


def main() -> None:
    """Run ExampleState."""
    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 = ExampleState(player_x=console.width // 2, player_y=console.height // 2)
    with tcod.context.new(console=console, tileset=tileset) as context:
        while True:
            console.clear()
            state.on_draw(console)
            context.present(console)
            for event in tcod.event.wait():
                print(event)
                state.on_event(event)  # Pass events to the state


if __name__ == "__main__":
    main()

Now when you run this script you have a player character you can move around with the arrow keys before closing the window.

You can review the part-1 source code here.

Footnotes