7 - Incremental delegation

Jun 24, 2021 tdd rust srl | Prev | Next | Code

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

In this post, I would like to showcase a technique I call “incremental delegation”.
I decided to move the handling of walls, enemies and the player to a struct called Dungeon.
Instead of doing this all at once, I switched between adding new code and integrating it:

In the interest of brevity, I will not show the Dungeon code here. It is not super interesting, anyway. For the curious, these are the relevant revisions:

Instead, I will focus on how the incremental development of Dungeon affects the Game struct.

Step 1: Walls and enemies

Step one is to extract the handling of walls and enemies to Dungeon. Using the new API incurs the following changes in Game:

pub struct Game {
    player: (u32, u32),
    dungeon: Dungeon
}

impl Game {
    pub fn new(_config: GameConfig) -> Self {
        Self {
            dungeon: Dungeon::new(),
            player: (0, 0),
        }
    }

    pub fn add_enemies(&mut self, positions: &Vec<(u32, u32)>) {
        self.dungeon.add_enemies(positions);
    }

    pub fn add_walls(&mut self, positions: &Vec<(u32, u32)>) {
        self.dungeon.add_walls(positions);
    }

    pub fn set_player_position(&mut self, x: u32, y: u32) {
        // Unchanged
    }

    pub fn tick<T: Input>(&mut self, input: &T) {
        // Unchanged
    }

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

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

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

    fn can_move_to(&self, x_offset: i32, y_offset: i32) -> bool {
        let new_pos = ((self.player.0 as i32 + x_offset) as u32, 
                       (self.player.1 as i32 + y_offset) as u32);

        !self.dungeon.get_walls().contains(&new_pos) && 
        !self.dungeon.get_enemies().contains(&new_pos)
    }
}

Work is split up now. Walls and enemies are handled by the new Dungeon instance. Player position handling and collision are still done in Game. All tests pass.

Step 2: Player position

Next we delegate storing and retrieving the player position:

pub struct Game {
    dungeon: Dungeon
}

impl Game {
    pub fn new(_config: GameConfig) -> Self {
        Self {
            dungeon: Dungeon::new(),
        }
    }

    pub fn set_player_position(&mut self, x: u32, y: u32) {
        self.dungeon.set_player_position(x, y);
    }

    pub fn tick<T: Input>(&mut self, input: &T) {
        let (mut player_x, mut player_y) = self.dungeon.get_player_position();
        if input.move_left() && self.can_move_to(-1, 0) {
            player_x -= 1;
        }
        // other directions go here
        self.dungeon.set_player_position(player_x, player_y);
    }

    pub fn render<T: Renderer>(&self, renderer: &mut T) {
        // wall/enemies rendering unchangesd
        let (player_x, player_y) = self.dungeon.get_player_position();
        renderer.render_at(player_x, player_y, Player);
    }

    fn can_move_to(&self, x_offset: i32, y_offset: i32) -> bool {
        let (player_x, player_y) = self.dungeon.get_player_position();
        let new_pos = ((player_x as i32 + x_offset) as u32, 
                       (player_y as i32 + y_offset) as u32);

        !self.dungeon.get_walls().contains(&new_pos) && 
        !self.dungeon.get_enemies().contains(&new_pos)
    }
}

This time, Game::tick is changed as well, using the Dungeon instance to get and set the player position. With feature tests green, we can move on to the next step.

Step 3: Player movement and collision

Finally, let’s extract player movement and collision as well. Only tick is affected now:

pub fn tick<T: Input>(&mut self, input: &T) {
    if input.move_left() { self.dungeon.move_player_left() }

    if input.move_right() { self.dungeon.move_player_right() }

    if input.move_up() { self.dungeon.move_player_up() }

    if input.move_down() { self.dungeon.move_player_down() }
}

This time, collision detection and position modification is moved in one fell swoop.
And that’s it. Except for querying input, all decisions about moving the player are now delegated.

Step 4: A small refactoring

After extraction of Dungeon, there was the decision to collapse walls and enemies into a single vector. A minor detail that requires only a small change in Game::render:

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

    let (player_x, player_y) = self.dungeon.get_player_position();
    renderer.render_at(player_x, player_y, Player);
}

And that’s that. Some refactoring inside Dungeon followed, but the camera pans to the ceiling. This no longer concerns any Game code, which is what we want.

Final musings

Incremental delegation (or extraction) is a heuristic I use very often.
If the increment is small enough, it’s cheap enough to revert to a working state. When things get hairy, it is often more beneficial to revert a few minutes worth of changes, instead of fiddling with unexpected complications.

There are no hard and fast rules to this, of course. If you feel comfortable with a bigger change, there is no reason not to attempt it. On the other hand, if it seems hard to define the next increment, this might call for some design thinking. The resulting design will have the sole purpose of making the next change easy.

Enough with the fancy talk! Next time, we will deal with randomness for the first time in this project.