Roguelike Tutorial 2020: Part 2 - Entities, Rendering, Map

In the previous part we got a single ‘@’ sign moving around the screen. The player character was represented by a coordinate stored directly in the game state. In this part, we’ll define a generic “entity” type, of which the player character is merely one instance. The rendering logic will be generalized to draw arbitrary game entities. Finally, we’ll use the generic entity type to define map components - namely walls and floor tiles.

By the end of this part, the game will look like this: screenshot-end

This part is loosely based on this part of the python tcod tutorial.

Reference implementation branch for starting point: part-1-end

In this post:

Refactor

First, a minor refactor to split the code into 3 files. This won’t change any logic - it will just make future changes easier to talk about.

A new file game.rs will contain all the game-specific logic, mostly agnostic of the fact that it is a chargrid application. For now this will look a lot like the AppData type. Note the player_coord method. For now this will just return the value in the player_coord field but this will change later in this part.

// game.rs

use coord_2d::{Coord, Size};
use direction::CardinalDirection;

pub struct GameState {
    screen_size: Size,
    player_coord: Coord,
}

impl GameState {
    pub fn new(screen_size: Size) -> Self {
        ...
    }
    pub fn maybe_move_player(&mut self, direction: CardinalDirection) {
        ...
    }
    pub fn player_coord(&self) -> Coord {
        self.player_coord
    }
}

A new file app.rs will contain the definitions of AppData, AppView, and App. AppData is now a wrapper of the GameState type from game.rs. Keep the handle_input method here, interpreting chargrid::input::Inputs and calling the appropriate method of GameState (currently just maybe_move_player). GameState doesn’t expose its player_coord field, so update the implementation of chargrid::render::View to call the player_coord method instead.

// app.rs
...
use crate::game::GameState;

struct AppData {
    game_state: GameState,
}

impl AppData {
    fn new(screen_size: Size) -> Self {
        Self {
            game_state: GameState::new(screen_size),
        }
    }
    fn handle_input(&mut self, input: Input) {
        match input {
            Input::Keyboard(key) => match key {
                KeyboardInput::Left => {
                    self.game_state.maybe_move_player(CardinalDirection::West)
                }
                ...
            },
            _ => (),
        }
    }
}

...

impl<'a> View<&'a AppData> for AppView {
    fn view<F: Frame, C: ColModify>(
        &mut self,
        data: &'a AppData,
        context: ViewContext<C>,
        frame: &mut F,
    ) {
        let view_cell = ViewCell::new()
            .with_character('@')
            .with_foreground(Rgb24::new_grey(255));
        frame.set_cell_relative(data.game_state.player_coord(), 0, view_cell, context);
    }
}

After this change, main.rs will contain the main function and nothing else. Also note the lack of chargrid::... qualifiers on types from the chargrid library. All the types from chargrid are now explicitly imported at the top of each file.

Here’s how the code should look after the refactor: part-2.0

Generic Entities

At the moment the player is represented by a Coord in a field of the game state. Eventually we will want to add other characters to the game, as well as other game objects such as walls, doors, and items. To this end, we need a generic way of representing a game entity.

The general idea is this: For each property that an entity can have, we’ll make a table associating an entity’s id (just a number) with the property value. Each entity’s data will be “spread out” over a number of tables corresponding to all the properties which the entity has. This is based on the idea of “components” from Entity Component Systems. From now on the term “component” will refer to a property of a game entity.

Add a dependency to help represent game state as a collection of component tables:

# Cargo.toml
...
[dependencies]
entity_table = "0.2"

Now we need to think about which components we will need. So far, the only entity is the player. We can think of the player as having a location and a tile. The Coord type is suitable for representing a location. As for tiles, let’s start with this:

// game.rs

#[derive(Clone, Copy, Debug)]
pub enum Tile {
    Player,
}

Rather than storing how to draw the player (symbol, colour, etc), store an abstract value in the game state, and let the rendering logic decide how to draw the player.

Now to define our components:

entity_table::declare_entity_module! {
    components {
        coord: Coord,
        tile: Tile,
    }
}

use components::Components;

This invocation of the declare_entity_module macro generates code resembling:

mod components {
    pub struct Components {
        pub coord: ComponentTable<Coord>,
        pub tile: ComponentTable<Tile>,
    }
}

The ComponentTable<T> type is defined in the entity_table crate, and associates entity ids with values of type T. The Entity type from entity_table is an entity id, and contains no data of its own.

Here’s part of the interface exposed by ComponentTable relevant to this post. Read more in entity_table’s documentation.

impl<T> ComponentTable<T> {
    // set the component value of an entity
    pub fn insert(&mut self, entity: Entity, data: T) -> Option<T> { ... }

    // look up the component value of an entity
    pub fn get(&self, entity: Entity) -> Option<&T> { ... }

    // obtain a mutable reference to the component value of an entity
    pub fn get_mut(&mut self, entity: Entity) -> Option<&mut T> { ... }

    // returns an iterator over entities and their component values
    pub fn iter(&self) -> impl Iterator<Item = (Entity, T)> { ... }
    ...
}

Update the GameState type to store a Components and the Entity representing the player character:

pub struct GameState {
    screen_size: Size,
    components: Components,
    player_entity: Entity,
}

Add a method to GameState for spawning the player character, which adds entries to component tables such that the player character is inserted at a specified location. Then add a second method which populates the map, which for now will just spawn the player (we’ll add more to this method shortly).

impl GameState {
    fn spawn_player(&mut self, coord: Coord) {
        self.components.coord.insert(self.player_entity, coord);
        self.components
            .tile
            .insert(self.player_entity, Tile::Player);
    }
    fn populate(&mut self, player_coord: Coord) {
        self.spawn_player(player_coord);
    }
    ...
}

The existing methods of GameState need to be updated to match its new definition.

So far we haven’t talked about how new values of Entity are created. They are effectively just numbers, but their numerical representation is opaque. They must be created with an EntityAllocator - another type defined in entity_table which allows entity ids to be created and destroyed. Update GameState::new to create a Components, and use an EntityAllocator to allocate an Entity representing the player. Then populate the GameState by calling the populate method we just defined.

impl GameState {
    ...
    pub fn new(screen_size: Size) -> Self {
        let mut entity_allocator = EntityAllocator::default();
        let components = Components::default();
        let player_entity = entity_allocator.alloc();
        let mut game_state = Self {
            screen_size,
            components,
            player_entity,
        };
        game_state.populate(screen_size.to_coord().unwrap() / 2);
        game_state
    }
    ...
}

The logic for moving the player must be updated to operate on the coord component table rather than a single coord value. It obtains a mutable reference to the player’s current position, panicking if the player has no current position. This mut ref is used to both check whether the movement is valid, and update the player’s position.

impl GameState {
    ...
    pub fn maybe_move_player(&mut self, direction: CardinalDirection) {
        let player_coord = self
            .components
            .coord
            .get_mut(self.player_entity)
            .expect("player has no coord component");
        let new_player_coord = *player_coord + direction.coord();
        if new_player_coord.is_valid(self.screen_size) {
            *player_coord = new_player_coord;
        }
    }
    ...
}

Finally, the player_coord method, which returns the coordinate of the player character, must be updated to work with the coord component, rather than just returning the value of the player_coord field (now removed):

impl GameState {
    ...
    pub fn player_coord(&self) -> Coord {
        *self
            .components
            .coord
            .get(self.player_entity)
            .expect("player has no coord component")
    }
}

Note that we didn’t change the public api of the GameState type, so no code outside of this file is affected by changing the representation of game state.

Reference implementation branch: part-2.1

General Rendering

The rendering logic in app.rs is still hard-coded to only render the player character at the location returned by player_coord. Before we can add additional entities to the game, rendering logic needs to be generalized to render all entities which can be rendered.

Start by providing a way for GameState to tell the renderer what needs to be rendered. Define a new type in game.rs:

// game.rs
...
pub struct EntityToRender {
    pub tile: Tile,
    pub coord: Coord,
}

This tells the renderer to draw a given tile at a given position on the screen.

Add a method to GameState which returns an Iterator over EntityToRenders for all the entities which need to be rendered. For now, let’s say that an entity needs to be rendered if it has both a coord and a tiile component.

impl GameState {
    ...
    pub fn entities_to_render<'a>(&'a self) -> impl 'a + Iterator<Item = EntityToRender> {
        let tile_component = &self.components.tile;
        let coord_component = &self.components.coord;
        tile_component.iter().filter_map(move |(entity, &tile)| {
            let coord = coord_component.get(entity).cloned()?;
            Some(EntityToRender { tile, coord })
        })
    }
}

Now in app.rs, update the view method of AppView to call entities_to_render instead of player_coord:

// app.rs
use crate::game::{GameState, Tile};
...
impl<'a> View<&'a AppData> for AppView {
    fn view<F: Frame, C: ColModify>(
        &mut self,
        data: &'a AppData,
        context: ViewContext<C>,
        frame: &mut F,
    ) {
        for entity_to_render in data.game_state.entities_to_render() {
            let view_cell = match entity_to_render.tile {
                Tile::Player => ViewCell::new()
                    .with_character('@')
                    .with_foreground(Rgb24::new_grey(255)),
            };
            frame.set_cell_relative(entity_to_render.coord, 0, view_cell, context);
        }
    }
}

Note that the ViewCell which is rendered is obtained by matching on entity_to_render.tile. As we add new Tile variants, we’ll update this match statement to tell the renderer how to draw the new Tiles.

Now remove the player_coord method from GameState as it’s no longer needed.

Reference implementation branch: part-2.2

Spatial Table

Shortly we’ll be adding walls to the game. Walls are solid; we’ll soon need a way of determining whether the player is trying to move through a wall and prevent their movement. The game engine will need to answer queries of the form “is there a solid entity at a given location?”. One approach would be to add a new component solid, set to true on solid entities (such as walls). The problem with this is in order to determine whether a solid object exists at a location, the engine would need to cross reference the solid and coord components. Since ComponentTables are indexed by Entity (rather than, say, Coord), this cross reference will be expensive, as it will involve iterating over all entities that are solid, and checking whether their coord is the one we’re interested in.

It would be useful to have a separate data structure which could be indexed by a Coord, and tell us which entities are currently at the specified location. The crate spatial_table defines a type SpatialTable which is similar to a ComponentTable<Coord> in that it associates Entitys with a Coord, but it also provides the reverse association - a mapping from Coord to Entity indicating which entities are at a given Coord.

Add a dependency on spatial_table:

# Cargo.toml
...
[dependencies]
spatial_table = "0.3"

Multiple entities may share a single location (e.g. a floor entity and a character entity may co-exist in the same cell). To represent this fact, a SpatialTable associates each coordinate with a collection of Entitys where each entity is on a separate “layer”. At each coordinate, there are a fixed number of layers, and each layer may contain one or zero entities. It might help to visualize a SpatialTable as a 2D array of a Layers type defined as:

struct Layers {
    floor: Option<Entity>,
    character: Option<Entity>,
    feature: Option<Entity>,
}

In this scenario, every cell of the 2D grid may contain a floor, a character, and a feature (walls, doors, furniture, etc). SpatialTable doesn’t care which entities are stored in a layer. When adding or updating an entity’s location, you may also set which layer it is on. SpatialTable doesn’t allow you to update the coordinate or layer of an entity if its destination coordinate and layer is already occupied.

SpatialTable doesn’t assume anything about which layers you will use. Start by defining which layers we will be using for our game:

// game.rs
...
spatial_table::declare_layers_module! {
    layers {
        floor: Floor,
        character: Character,
        feature: Feature,
    }
}

pub use layers::Layer;
type SpatialTable = spatial_table::SpatialTable<layers::Layers>;
pub type Location = spatial_table::Location<Layer>;

The declare_layers_module macro produces code resembling:

mod layers {
    pub struct Layers {
        pub floor: Option<Entity>,
        pub character: Option<Entity>,
        pub feature: Option<Entity>,
    }

    pub enum Layer {
        Floor,
        Character,
        Feature,
    }

    impl spatial_table::Layers for Layers {
        type Layer = Layer;
        ...
    }
}

The Layers type represents which entities are on which layer. A SpatialTable will contain one Layers for each cell in its grid. The Layer type lets you refer to layers dynamically, for example when inserting an enity into a SpatialTable at a specified coordinate and layer.

After the macro invocation, create type aliases to make it convenient to work with SpatialTable for our specified set of layers. The Location type is a Coord plus a Layer. It’s defined as:

struct Location<L> {
    pub coord: Coord,
    pub layer: Option<L>,
}

Note that the layer field is an Option - an Entity doesn’t need to be associated with a layer. Only entities associated with layers will be returned when querying which entities are at a given coordinate.

Here’s the relevant part of the SpatialTable interface. Note that it’s generic over the type of layers in each cell. The full interface is specified in spatial_table’s documentation.

impl<L: spatial_table::Layers> SpatialTable<L> {
    // Creates a new SpatialTable<L> with given dimensions
    pub fn new(size: Size) -> Self { ... }

    // Returns the location (coord and layer) of a given entity
    pub fn location_of(&self, entity: Entity) -> Option<&Location<L::Layer>> { ... }

    // Returns the coord of a given entity
    pub fn coord_of(&self, entity: Entity) -> Option<Coord> { ... }

    // Returns the layers at a given coord, panicking if coord is out of bounds
    pub fn layers_at_checked(&self, coord: Coord) -> &L { ... }

    // Update the location (coord and layer) associated with an entity
    pub fn update(&mut self, entity: Entity, location: Location<L::Layer>)
        -> Result<(), UpdateError> {

    // Update the coord associated with an entity
    pub fn update_coord(&mut self, entity: Entity, coord: Coord)
        -> Result<(), UpdateError> { ... }
}

pub enum UpdateError {
    OccupiedBy(Entity),
    DestinationOutOfBounds,
}

Remove the coord component table. It will be replaced with a SpatialTable.

entity_table::declare_entity_module! {
    components {
        tile: Tile,
    }
}

Add a SpatialTable to GameState.

pub struct GameState {
    screen_size: Size,
    components: Components,
    spatial_table: SpatialTable,
    player_entity: Entity,
}
...
impl GameState {
    pub fn new(screen_size: Size) -> Self {
        let mut entity_allocator = EntityAllocator::default();
        let components = Components::default();
        let spatial_table = SpatialTable::new(screen_size);
        let player_entity = entity_allocator.alloc();
        let mut game_state = Self {
            screen_size,
            components,
            spatial_table,
            player_entity,
        };
        game_state.populate(screen_size.to_coord().unwrap() / 2);
        game_state
    }
    ...
}

Update spawn_player to add the player Entity to the SpatialTable.

impl GameState {
    fn spawn_player(&mut self, coord: Coord) {
        self.spatial_table
            .update(
                self.player_entity,
                Location {
                    coord,
                    layer: Some(Layer::Character),
                },
            )
            .unwrap();
        self.components
            .tile
            .insert(self.player_entity, Tile::Player);
    }
    ...
}

Update maybe_move_player to update the SpatialTable.

impl GameState {
    ...
    pub fn maybe_move_player(&mut self, direction: CardinalDirection) {
        let player_coord = self
            .spatial_table
            .coord_of(self.player_entity)
            .expect("player has no coord");
        let new_player_coord = player_coord + direction.coord();
        if new_player_coord.is_valid(self.screen_size) {
            self.spatial_table
                .update_coord(self.player_entity, new_player_coord)
                .unwrap();
        }
    }
    ...
}

And update entities_to_render to use the SpatialTable. Replace the coord: Coord field of EntityToRender with a location: Location field so the render knows which layer each entity is on. This will help later on when we need to render a scene with multiple entities at a single coordinate and use layers to determine draw order.

pub struct EntityToRender {
    pub tile: Tile,
    pub location: Location,
}

impl GameState {
    ...
    pub fn entities_to_render<'a>(&'a self) -> impl 'a + Iterator<Item = EntityToRender> {
        let tile_component = &self.components.tile;
        let spatial_table = &self.spatial_table;
        tile_component.iter().filter_map(move |(entity, &tile)| {
            let &location = spatial_table.location_of(entity)?;
            Some(EntityToRender { tile, location })
        })
    }
    ...
}

Finally, update the rendering logic in app.rs to understand the new location field of EntityToRender.

// app.rs

impl<'a> View<&'a AppData> for AppView {
    fn view<F: Frame, C: ColModify>(
        &mut self,
        data: &'a AppData,
        context: ViewContext<C>,
        frame: &mut F,
    ) {
        for entity_to_render in data.game_state.entities_to_render() {
            let view_cell = ...;
            frame.set_cell_relative(entity_to_render.location.coord, 0, view_cell, context);
        }
    }
}

Reference implementation branch: part-2.3

Walls and Floors

Let’s add walls and floors, and make it so the player can’t walk through walls.

Add Tiles for walls and floors:

// game.rs

pub enum Tile {
    Player,
    Floor,
    Wall,
}

So that we can spawn new entities in addition to the player, add the EntityAllocator created in GameState::new to GameState:

pub struct GameState {
    screen_size: Size,
    entity_allocator: EntityAllocator,
    components: Components,
    spatial_table: SpatialTable,
    player_entity: Entity,
}


impl GameState {
    ...
    pub fn new(screen_size: Size) -> Self {
        let mut entity_allocator = EntityAllocator::default();
        let components = Components::default();
        let spatial_table = SpatialTable::new(screen_size);
        let player_entity = entity_allocator.alloc();
        let mut game_state = Self {
            screen_size,
            entity_allocator,
            components,
            spatial_table,
            player_entity,
        };
        game_state.populate(screen_size.to_coord().unwrap() / 2);
        game_state
    }
    ...
}

Add methods for spawning walls and floors, and update GameState::populate to place floor tiles everywhere, and walls in a few select locations.

impl GameState {
    fn spawn_wall(&mut self, coord: Coord) {
        let entity = self.entity_allocator.alloc();
        self.spatial_table
            .update(
                entity,
                Location {
                    coord,
                    layer: Some(Layer::Feature),
                },
            )
            .unwrap();
        self.components.tile.insert(entity, Tile::Wall);
    }
    fn spawn_floor(&mut self, coord: Coord) {
        let entity = self.entity_allocator.alloc();
        self.spatial_table
            .update(
                entity,
                Location {
                    coord,
                    layer: Some(Layer::Floor),
                },
            )
            .unwrap();
        self.components.tile.insert(entity, Tile::Floor);
    }
    ...
    fn populate(&mut self, player_coord: Coord) {
        self.spawn_player(player_coord);
        for coord in self.screen_size.coord_iter_row_major() {
            self.spawn_floor(coord);
        }
        self.spawn_wall(player_coord + Coord::new(-1, 2));
        self.spawn_wall(player_coord + Coord::new(0, 2));
        self.spawn_wall(player_coord + Coord::new(1, 2));
    }
    ...
}

To prevent the player walking through walls, update GameState::maybe_move_player. For now, treat all cells with a feature or a character as solid. This will change in future parts.

impl GameState {
    ...
    pub fn maybe_move_player(&mut self, direction: CardinalDirection) {
        let player_coord = self
            .spatial_table
            .coord_of(self.player_entity)
            .expect("player has no coord");
        let new_player_coord = player_coord + direction.coord();
        if new_player_coord.is_valid(self.screen_size) {
            let dest_layers = self.spatial_table.layers_at_checked(new_player_coord);
            if dest_layers.character.is_none() && dest_layers.feature.is_none() {
                self.spatial_table
                    .update_coord(self.player_entity, new_player_coord)
                    .unwrap();
            }
        }
    }
    ...
}

Finally, update the rendering logic to render wall and floor tiles. The cell containing the player will also contain a floor. We need to make sure that the floor is drawn “below” the player. The set_cell_relative method (which draws a cell) takes a depth argument. Thus far we’ve been passing 0, but now we’ll derive it from the layer.

// app.rs
...
use crate::game::{GameState, Layer, Tile};

...

impl<'a> View<&'a AppData> for AppView {
    fn view<F: Frame, C: ColModify>(
        &mut self,
        data: &'a AppData,
        context: ViewContext<C>,
        frame: &mut F,
    ) {
        for entity_to_render in data.game_state.entities_to_render() {
            let view_cell = match entity_to_render.tile {
                Tile::Player => ViewCell::new()
                    .with_character('@')
                    .with_foreground(Rgb24::new_grey(255)),
                Tile::Floor => ViewCell::new()
                    .with_character('.')
                    .with_foreground(Rgb24::new_grey(63))
                    .with_background(Rgb24::new(0, 0, 63)),
                Tile::Wall => ViewCell::new()
                    .with_character('#')
                    .with_foreground(Rgb24::new(0, 63, 63))
                    .with_background(Rgb24::new(63, 127, 127)),
            };
            let depth = match entity_to_render.location.layer {
                None => -1,
                Some(Layer::Floor) => 0,
                Some(Layer::Feature) => 1,
                Some(Layer::Character) => 2,
            };
            frame.set_cell_relative(entity_to_render.location.coord, depth, view_cell, context);
        }
    }
}

Now run the game! It should look like this:

screenshot-end

Try to move the player character through the walls (#)!

Reference implementation branch: part-2.4

Click here for the next part!