2 - Start anywhere

May 31, 2021 tdd rust srl | Prev | Next | Code

This post is part of the series Making a Roguelike - A test-driven approach.

Here is our initial TODO list:

- [ ] Render tiles
- [ ] Move player
- [ ] Run in terminal

Of the three items, only the last one will be a user-facing feature. The first two will be test-facing, so to speak. That’s OK. We have to start somewhere.

As a reminder, this is the TDD sequence in a nutshell:

Should we do design before writing the first test? Yes, we should. I personally like to get away with as little as possible.

Render tiles

A Roguelike typically renders a 2-dimensional matrix of tiles. A tile can have different things on it. For now let’s assume that all we have is walls, enemies, the player and the floor.
The first test will construct a game with a tiny tile matrix:

#[test]
fn renders_walls_enemies_and_player() {
    let mut game = Game::new();
    game.add_enemies(&vec![(1, 1), (4, 2), (2, 3)]);
    game.add_walls(&vec![(1, 0), (2, 0), (3, 1), (1, 2)]);
    game.set_player_position(3, 2);
    let mut renderer = RenderingSpy::new(5, 4);
    game.render(&mut renderer);
    renderer.assert_frame(vec![
        ". # # . .",
        ". E . # .",
        ". # . @ E",
        ". . E . ."
    ])
}

This test assumes the following:

Let’s start with Game::render. It takes a trait Renderer:

pub fn render<T: Renderer>(&self, renderer: &mut T) { 
    // does nothing yet
}

The Renderer trait looks like this:

pub enum ObjectType {
    Wall,
    Player,
    Enemy,
}

pub trait Renderer {
    fn render_at(&mut self, x: u32, y: u32, object_type: ObjectType);
}

So that’s the design out of the way - Game depends on an abstraction, oblivious of any concrete implementations that will follow.

(Traits are Rust’s sole notion of an interface. There will be an implementation of Renderer for testing purposes and one “real” implementation for production. And since Game::render is generic, the trait implementation can be resolved at compile time. So no runtime penalty looking up vtables and such.)

Next, we need the RenderingSpy. For testing purposes, it will intercept the game code whenever the game wants to render a tile. For that to work, it has to implement the Renderer trait:

impl Renderer for RenderingSpy {
    fn render_at(&mut self, x: u32, y: u32, object_type: ObjectType) {
        let ch = match object_type {
            Wall => { '#' }
            Player => { '@' }
            Enemy => { 'E' }
        };

        self.frame[y as usize][x as usize] = ch;
    }
}

This simply translates the different object types into characters and keeps them in a two-dimensional array, for the assert_frame function to use (via frame_as_string):

pub fn assert_frame(&self, expected: Vec<&str>) {
    assert_eq!(self.frame_as_string(), expected.join("\n").replace(" ", ""));
}

This does two quality-of-life things:

(By the way, RenderingSpy was implemented using unit tests. It’s good to have some confidence in your test helpers.)

Now that we can make a proper assertion, stubbing out an empty Game will give us the test failure we want:

<Click to see difference>

thread '_tests::feature::rendering_objects_test::renders_walls_enemies_and_player' panicked at 'assertion failed: `(left == right)`
left: `".....\n.....\n.....\n....."`,
right: `".##..\n.E.#.\n.#.@E\n..E.."`', src/_tests/helpers/rendering_spy.rs:31:9

And, after we “click to see the difference”:

This fails because RenderingSpy has initialized all tiles as empty (verified by a test, of course), and Game::render does nothing.

We can make the test pass with very little work. Assuming the different objects are stored in vectors of tuples, Game::render looks like this:

pub fn render<T: Renderer>(&self, renderer: &mut T) {
    for (x, y) in &self.enemies {
        renderer.render_at(*x, *y, Enemy);
    }

    for (x, y) in &self.walls {
        renderer.render_at(*x, *y, Wall);
    }

    renderer.render_at(self.player.0, self.player.1, Player);
}    

And the test passes. Nothing more to see here.

We tick off the first item in the TODO list:

- [X] Render tiles
- [ ] Move player
- [ ] Run in terminal

And that’s it for this episode. Until next time!