Make the Field Configurable

So far, the game field has been a simple rectangle. That works—but it’s a bit too predictable. There’s nowhere to hide, and the game feels linear.

Let’s fix that by making the field configurable.

A simple way to add variety is by introducing rooms—rectangular sections that make up the walkable areas of the map. These rooms are defined in the configuration and merged into the playfield to build more interesting layouts.

We use a section list in the configuration to define individual rooms:

configuration.elcl
 1*[field.room]
 2x: 3
 3y: 3
 4width: 8
 5height: 8
 6
 7*[field.room]
 8x: 16
 9y: 3
10width: 8
11height: 8
12
13*[field.room]
14x: 10
15y: 8
16width: 8
17height: 2

Updates to the Game Framework

Extending the Geometry Classes

First, we expand our geometry module with a new Rectangle class, which encapsulates the size and position of a room in a single value. We also add a few helper methods to simplify merging rooms and checking which tiles are inside or outside of a rectangle.

Changes in robot-escape/src/Geometry.hpp
 1#pragma once
 2
 3#include <functional>
 4#include <array>
 5#include <random>
 6#include <thread>
 7
 8inline auto randomInt(int minimum, int maximum) noexcept -> int {
 9    thread_local std::mt19937 rng(std::random_device{}());
10    return std::uniform_int_distribution<int>{minimum, maximum}(rng);
11}
12
13struct Position {
14    int x{};
15    int y{};
16
17    Position() = default;
18    constexpr Position(int x, int y) noexcept : x{x}, y{y} {}
19    auto operator==(const Position &other) const noexcept -> bool = default;
20    auto operator!=(const Position &other) const noexcept -> bool = default;
21    auto operator+(const Position &other) const noexcept -> Position { return {x + other.x, y + other.y}; }
22    auto operator-(const Position &other) const noexcept -> Position { return {x - other.x, y - other.y}; }
23    [[nodiscard]] auto distanceTo(Position other) const noexcept -> int {
24        return std::abs(x - other.x) + std::abs(y - other.y);
25    }
26    [[nodiscard]] auto componentMax(Position other) const noexcept -> Position {
27        return {std::max(x, other.x), std::max(y, other.y)};
28    }
29    [[nodiscard]] auto componentMin(Position other) const noexcept -> Position {
30        return {std::min(x, other.x), std::min(y, other.y)};
31    }
32};
33
34constexpr static std::array<Position, 4> cPosDelta4 = {
35    {{1, 0}, {0, 1}, {-1, 0}, {0, -1}}};
36
37struct Size {
38    int width{};
39    int height{};
40
41    Size() = default;
42    constexpr Size(int width, int height) noexcept : width{width}, height{height} {}
43    constexpr Size(Position pos1, Position pos2) noexcept : width{pos2.x - pos1.x}, height{pos2.y - pos1.y} {}
44    [[nodiscard]] auto fitsInto(Size other) const noexcept -> bool {
45        return width <= other.width && height <= other.height;
46    }
47    [[nodiscard]] auto area() const noexcept -> int { return width * height; }
48    [[nodiscard]] auto center() const noexcept -> Position {
49        return {width / 2, height / 2};
50    }
51    [[nodiscard]] auto componentMax(Size other) const noexcept -> Size {
52        return {std::max(width, other.width), std::max(height, other.height)};
53    }
54    [[nodiscard]] auto contains(const Position& pos) const noexcept -> bool {
55        return pos.x >= 0 && pos.y >= 0 && pos.x < width && pos.y < height;
56    }
57    [[nodiscard]] auto index(const Position& pos) const noexcept -> int {
58        return pos.y * width + pos.x;
59    }
60};
61
62struct Rectangle {
63    Position pos{};
64    Size size{};
65
66    Rectangle() = default;
67    constexpr Rectangle(int x, int y, int width, int height) noexcept : pos{x, y}, size{width, height} {}
68    constexpr Rectangle(Position pos, Size size) noexcept : pos{pos}, size{size} {}
69    constexpr auto x2() const noexcept -> int { return pos.x + size.width; }
70    constexpr auto y2() const noexcept -> int { return pos.y + size.height; }
71    constexpr auto bottomRight() const noexcept -> Position { return {x2(), y2()}; }
72    [[nodiscard]] auto center() const noexcept -> Position {
73        return {size.width / 2 + pos.x, size.height / 2 + pos.y};
74    }
75    auto operator|=(const Rectangle &other) noexcept -> Rectangle& {
76        auto newPos1 = pos.componentMin(other.pos);
77        auto newPos2 = bottomRight().componentMax(other.bottomRight());
78        pos = newPos1;
79        size = Size{newPos1, newPos2};
80        return *this;
81    }
82    [[nodiscard]] auto padded(int paddingX, int paddingY) const noexcept -> Rectangle {
83        return Rectangle{pos.x - paddingX, pos.y - paddingY, size.width + paddingX * 2, size.height + paddingY * 2};
84    }
85    [[nodiscard]] auto contains(const Position& testedPosition) const noexcept -> bool {
86        return testedPosition.x >= pos.x && testedPosition.y >= pos.y
87            && testedPosition.x < x2() && testedPosition.y < y2();
88    }
89    template<typename Fn> void forEach(Fn fn) const {
90        for (int y = 0; y < size.height; ++y) {
91            for (int x = 0; x < size.width; ++x) {
92                fn(Position{x, y} + pos);
93            }
94        }
95    }
96};

Download the updated Geometry.hpp

Minor Update to the Canvas

The Canvas now uses the new Rectangle class to render room shapes.

Changes in robot-escape/src/Canvas.hpp
 1#pragma once
 2
 3#include "Geometry.hpp"
 4
 5#include <vector>
 6#include <iostream>
 7#include <numeric>
 8#include <ranges>
 9#include <algorithm>
10#include <cstdint>
11
12enum class Block : uint8_t {
13    Empty,
14    Wall,
15    Room,
16    Exit,
17    Player,
18    PlayerTrail,
19    Robot,
20    RobotTrail,
21};
22
23struct Canvas {
24    Size size;
25    std::vector<Block> data;
26    Position topLeft;
27
28    constexpr static auto cDefaultSize = Size{40, 20};
29
30    explicit Canvas(Size size = cDefaultSize) noexcept : size{size}, data(size.area(), Block::Empty) {}
31    void setTopLeft(Position pos) noexcept { topLeft = pos; }
32    void setBlock(Block block, Position pos) noexcept {
33        if (!size.contains(pos + topLeft)) { return; }
34        data[size.index(pos + topLeft)] = block;
35    }
36    template<typename... PosArgs>
37    void setBlocks(Block block, PosArgs... pos) noexcept {
38        (setBlock(block, pos), ...);
39    }
40    void fillRect(Rectangle rect, Block block) noexcept {
41        rect.forEach([&](Position p) { setBlock(block, p); });
42    }
43    [[nodiscard]] auto blockAt(Position pos) const noexcept -> Block {
44        return blockFromOrigin(pos + topLeft);
45    }
46    [[nodiscard]] auto blockFromOrigin(Position pos) const noexcept -> Block {
47        if (!size.contains(pos)) { return Block::Empty; }
48        return data[size.index(pos)];
49    }
50    void renderToConsole() const noexcept {
51        std::cout << "\x1b[0m";
52        auto lastBlock = Block::Player;
53        for (int y = 0; y < size.height; ++y) {
54            for (int x = 0; x < size.width; ++x) {
55                renderBlockAt({x, y}, lastBlock);
56            }
57            std::cout << '\n';
58        }
59        std::cout << "\x1b[0m\n";
60    }
61    void renderBlockAt(Position pos, Block &lastBlock) const noexcept {
62        static std::array<std::string_view, 16> walls = {
63            "■", "╺", "╻", "┏", "╸", "━", "┓", "┳", "╹", "┗", "┃", "┣", "┛", "┻", "┫", "╋"
64        };
65        auto block = blockFromOrigin(pos);
66        if (block != lastBlock) {
67            switch (block) {
68            case Block::Empty: std::cout << "\x1b[90m"; break;
69            case Block::Wall: std::cout << "\x1b[32m"; break;
70            case Block::Room: std::cout << "\x1b[0m"; break;;
71            case Block::Exit: std::cout << "\x1b[92m"; break;
72            case Block::Player: case Block::PlayerTrail: std::cout << "\x1b[93m"; break;
73            case Block::Robot: case Block::RobotTrail: std::cout << "\x1b[91m"; break;
74            }
75        }
76        switch (block) {
77        case Block::Empty: std::cout << "░"; break;
78        case Block::Wall: std::cout << walls[matchBlocks4(pos, Block::Wall)]; break;
79        case Block::Room: std::cout << " "; break;
80        case Block::Exit: std::cout << "⚑"; break;
81        case Block::Player: std::cout << "☻"; break;
82        case Block::Robot: std::cout << "♟"; break;
83        case Block::PlayerTrail: case Block::RobotTrail: std::cout << "∙"; break;
84        }
85        lastBlock = block;
86    }
87    [[nodiscard]] auto matchBlocks4(Position pos, Block block) const noexcept -> uint32_t {
88        return std::accumulate(cPosDelta4.rbegin(), cPosDelta4.rend(), 0U, [&](uint32_t acc, Position delta) {
89            return (acc << 1) | (blockFromOrigin(pos + delta) == block ? 1 : 0);
90        });
91    }
92};

Download the updated Canvas.hpp

Adding Rooms to the Field

In World.hpp, we introduce a new class, Room, and update the Field class so it can build the playfield based on a list of configured rooms.

Changes in robot-escape/src/World.hpp
  1#pragma once
  2
  3#include "Geometry.hpp"
  4#include "Canvas.hpp"
  5
  6#include <ranges>
  7#include <algorithm>
  8#include <vector>
  9
 10template<Block tElementBlock, Block tTrailBlock = tElementBlock>
 11struct ElementWithPos {
 12    Position pos{};
 13    std::vector<Position> trail;
 14    std::string name;
 15
 16    void moveTo(Position newPos) noexcept {
 17        trail.push_back(pos);
 18        if (trail.size() > 3) { trail.erase(trail.begin()); }
 19        pos = newPos;
 20    }
 21
 22    void render(Canvas &canvas) const noexcept {
 23        canvas.setBlock(tElementBlock, pos);
 24        for (auto trailPos : trail) {
 25            auto currentBlock = canvas.blockAt(trailPos);
 26            if (currentBlock == Block::Room || currentBlock == Block::RobotTrail) { canvas.setBlock(tTrailBlock, trailPos); }
 27        }
 28    }
 29};
 30
 31using Player = ElementWithPos<Block::Player, Block::PlayerTrail>;
 32using Robot = ElementWithPos<Block::Robot, Block::RobotTrail>;
 33using Exit = ElementWithPos<Block::Exit>;
 34
 35struct Room {
 36    Rectangle rect;
 37};
 38
 39struct Field {
 40    std::vector<Room> rooms;
 41    Rectangle rect;
 42
 43    void updatePosAndSize() {
 44        rect = rooms.front().rect;
 45        for (std::size_t i = 1; i < rooms.size(); ++i) {
 46            rect |= rooms[i].rect;
 47        }
 48    }
 49    void addRoom(Rectangle roomRect) {
 50        rooms.emplace_back(Room{roomRect});
 51        updatePosAndSize();
 52    }
 53    void render(Canvas &canvas) const noexcept {
 54        for (const auto &room : rooms) { canvas.fillRect(room.rect.padded(1, 1), Block::Wall); }
 55        for (const auto &room : rooms) { canvas.fillRect(room.rect, Block::Room); }
 56    }
 57    [[nodiscard]] auto contains(Position pos) const noexcept -> bool {
 58        return std::ranges::any_of(rooms, [&](const auto &room) -> bool { return room.rect.contains(pos); });
 59    }
 60    template<typename Fn> auto filterPositions(Fn fn) const -> std::vector<Position> {
 61        std::vector<Position> result;
 62        result.reserve(rect.size.area());
 63        rect.forEach([&](const Position& pos) {
 64            if (contains(pos) && fn(pos)) { result.push_back(pos); }
 65        });
 66        return result;
 67    }
 68};
 69
 70struct World {
 71    Field field;
 72    Player player;
 73    std::vector<Robot> robots;
 74    std::vector<Exit> exits;
 75
 76    World() = default;
 77    template<typename T>
 78    [[nodiscard]] static auto tooNear(Position pos, int distance, const std::vector<T> &elements) {
 79        return std::any_of(elements.begin(), elements.end(), [&](const auto &element) {
 80            return element.pos.distanceTo(pos) <= distance;
 81        });
 82    }
 83    [[nodiscard]] auto isValidPlayerMovement(Position pos) const noexcept -> bool {
 84        return field.contains(pos);
 85    }
 86    [[nodiscard]] auto isValidRobotMovement(Position pos) const noexcept -> bool {
 87        return field.contains(pos) && !tooNear(pos, 0, robots);
 88    }
 89    [[nodiscard]] auto isPlayerOnExit() const noexcept -> bool {
 90        return std::ranges::any_of(exits, [&](const Exit &exit) -> bool { return exit.pos == player.pos; });
 91    }
 92    [[nodiscard]] auto isRobotOnPlayer() const noexcept -> bool {
 93        return std::ranges::any_of(robots, [&](const Robot &robot) -> bool { return robot.pos == player.pos; });
 94    }
 95    template<typename Fn>
 96    auto randomValidFieldPosition(Fn fn) const -> Position {
 97        auto validPositions = field.filterPositions(fn);
 98        if (validPositions.empty()) {
 99            throw std::logic_error{"Could not find a valid position"};
100        }
101        return validPositions[randomInt(0, validPositions.size() - 1)];
102    }
103    void addExitAtRandomPosition() {
104        auto exit = Exit{randomValidFieldPosition([](Position pos){return true;})};
105        exits.emplace_back(std::move(exit));
106    }
107    void setPlayerToRandomPosition() {
108        player.pos = randomValidFieldPosition([&](Position pos) {
109            return !tooNear(pos, 3, exits);
110        });
111    }
112    void addRobotAtRandomPosition() {
113        auto robot = Robot{randomValidFieldPosition([&](Position pos) {
114            if (player.pos.distanceTo(pos) <= 4) { return false; }
115            return !tooNear(pos, 4, exits) && !tooNear(pos, 1, robots);
116        })};
117        robot.name = std::format("Robot {}", robots.size() + 1);
118        robots.emplace_back(std::move(robot));
119    }
120    void render(Canvas &canvas) const noexcept {
121        canvas.setTopLeft(canvas.size.center() - field.rect.center());
122        field.render(canvas);
123        for (const auto &exit : exits) {
124            exit.render(canvas);
125        }
126        player.render(canvas);
127        for (const auto &robot : robots) {
128            robot.render(canvas);
129        }
130    }
131};

Download the updated World.hpp

Reading the Section List from the Configuration

Next, let’s look at how we read this list of room definitions from the configuration.

Here’s a quick overview of the updated code in Application.hpp:

robot-escape/src/Application.hpp
  1#pragma once
  2
  3#include "Canvas.hpp"
  4#include "Logic.hpp"
  5#include "World.hpp"
  6
  7#include <erbsland/all_conf.hpp>
  8
  9#include <filesystem>
 10#include <iostream>
 11
 12using namespace el::conf;
 13
 14struct Application {
 15    std::filesystem::path configPath;
 16    DocumentPtr config;
 17
 18    constexpr static auto cMinimumFieldSize = Size{8, 8};
 19    constexpr static auto cMaximumFieldSize = Size{80, 40};
 20    constexpr static auto cMinimumCanvasSize = Size{32, 16};
 21
 22    void parseArgs(int argc, char **argv) {
 23        if (argc < 2) {
 24            std::cout << "Usage: " << argv[0] << " <config-file>\n";
 25            exit(1);
 26        }
 27        configPath = std::filesystem::path{argv[1]};
 28    }
 29
 30    void readConfiguration() {
 31        try {
 32            Parser parser;
 33            const auto source = Source::fromFile(configPath);
 34            config = parser.parseOrThrow(source);
 35        } catch (const Error &error) {
 36            std::cerr << error.toText().toCharString() << "\n";
 37            exit(1);
 38        }
 39    }
 40
 41    [[nodiscard]] auto buildWorld() const -> World {
 42        try {
 43            World world;
 44            for (const auto &roomValue : *config->getSectionListOrThrow("field.room")) {
 45                const auto roomRect = Rectangle{
 46                    static_cast<int>(roomValue->getIntegerOrThrow(u8"x")),
 47                    static_cast<int>(roomValue->getIntegerOrThrow(u8"y")),
 48                    static_cast<int>(roomValue->getIntegerOrThrow(u8"width")),
 49                    static_cast<int>(roomValue->getIntegerOrThrow(u8"height")),
 50                };
 51                world.field.addRoom(roomRect);
 52            }
 53            if (!cMinimumFieldSize.fitsInto(world.field.rect.size)) {
 54                std::cerr << std::format("Field size must be at least {}x{}\n", cMinimumFieldSize.width, cMinimumFieldSize.height);
 55                exit(1);
 56            }
 57            if (!world.field.rect.size.fitsInto(cMaximumFieldSize)) {
 58                std::cerr << std::format("Field size must be at most {}x{}\n", cMaximumFieldSize.width, cMaximumFieldSize.height);
 59                exit(1);
 60            }
 61            world.addExitAtRandomPosition();
 62            world.setPlayerToRandomPosition();
 63            for (int i = 0; i < 3; ++i) {
 64                world.addRobotAtRandomPosition();
 65            }
 66            return world;
 67        } catch (const Error &error) {
 68            std::cerr << error.toText().toCharString() << "\n";
 69            exit(1);
 70        }
 71    }
 72
 73    void renderLogic(const Logic &logic) {
 74        auto canvasSize = logic.world.field.rect.padded(2, 1).size.componentMax(cMinimumCanvasSize);
 75        Canvas canvas{canvasSize};
 76        logic.render(canvas);
 77        canvas.renderToConsole();
 78    }
 79
 80    void run() {
 81        auto initialWorld = buildWorld();
 82        std::cout << "----------------------------==[ ROBOT ESCAPE ]==-----------------------------\n";
 83        std::cout << "Welcome to Robot Escape!\n";
 84        std::cout << "You (☻) must run to the exit (⚑) before any robot (♟) catches you.\n\n";
 85        auto logic = Logic{std::move(initialWorld)};
 86        renderLogic(logic);
 87        auto state = logic.gameState();
 88        while (state == GameState::Running) {
 89            auto playerInput = inputFromConsole();
 90            if (playerInput == cQuitInput) {
 91                std::cout << "Goodbye!\n";
 92                return;
 93            }
 94            logic.advance(playerInput);
 95            renderLogic(logic);
 96            state = logic.gameState();
 97        }
 98        if (state == GameState::PlayerWon) {
 99            std::cout << "You won!\n";
100        } else {
101            std::cout << "You lost!\n";
102        }
103    }
104};

Download the updated Application.hpp

To read the section list, we use:

*config->getSectionListOrThrow("field.room")

This line checks for the key field.room, confirms it contains a section list, and returns it. The * operator dereferences the result so it can be used directly in a for() loop.

Here’s the key part of the loop:

            for (const auto &roomValue : *config->getSectionListOrThrow("field.room")) {
                const auto roomRect = Rectangle{
                    static_cast<int>(roomValue->getIntegerOrThrow(u8"x")),
                    static_cast<int>(roomValue->getIntegerOrThrow(u8"y")),
                    static_cast<int>(roomValue->getIntegerOrThrow(u8"width")),
                    static_cast<int>(roomValue->getIntegerOrThrow(u8"height")),
                };
                world.field.addRoom(roomRect);
            }

Inside the loop, we access each section’s individual values using relative paths. For instance:

roomValue->getIntegerOrThrow(u8"x")

This retrieves the x value from the room’s section. If it’s the first room in the configuration, the full path to that value would be field.room[0].x.

Compile and Run the Updated Game

Let’s compile the updated game and try out the new configurable room layout.

$ cmake --build cmake-build
$ ./cmake-build/robot-escape/robot-escape configuration.elcl
----------------------------==[ ROBOT ESCAPE ]==-----------------------------
Welcome to Robot Escape!
You (☻) must run to the exit (⚑) before any robot (♟) catches you.

░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
░░░┏━━━━━━━━┓░░░░░░┏━━━━━━━━┓░░░
░░░        ░░░░░░        ░░░
░░░        ░░░░░░       ░░░
░░░        ┗━━━━━━┛        ░░░
░░░                       ░░░
░░░                        ░░░
░░░        ┏━━━━━━┓        ░░░
░░░       ░░░░░░       ░░░
░░░       ░░░░░░        ░░░
░░░┗━━━━━━━━┛░░░░░░┗━━━━━━━━┛░░░
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░

Enter your move (n/e/s/w/q=quit): n
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
░░░┏━━━━━━━━┓░░░░░░┏━━━━━━━━┓░░░
░░░        ░░░░░░        ░░░
░░░        ░░░░░░       ░░░
░░░        ┗━━━━━━┛       ░░░
░░░                       ░░░
░░░                       ░░░
░░░        ┏━━━━━━┓       ░░░
░░░       ░░░░░░       ░░░
░░░      ░░░░░░        ░░░
░░░┗━━━━━━━━┛░░░░░░┗━━━━━━━━┛░░░
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░

Enter your move (n/e/s/w/q=quit): w
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
░░░┏━━━━━━━━┓░░░░░░┏━━━━━━━━┓░░░
░░░        ░░░░░░        ░░░
░░░        ░░░░░░       ░░░
░░░        ┗━━━━━━┛       ░░░
░░░                      ░░░
░░░                      ░░░
░░░        ┏━━━━━━┓      ░░░
░░░      ░░░░░░       ░░░
░░░  ∙∙    ░░░░░░        ░░░
░░░┗━━━━━━━━┛░░░░░░┗━━━━━━━━┛░░░
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░

Enter your move (n/e/s/w/q=quit): w
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
░░░┏━━━━━━━━┓░░░░░░┏━━━━━━━━┓░░░
░░░        ░░░░░░        ░░░
░░░        ░░░░░░       ░░░
░░░        ┗━━━━━━┛       ░░░
░░░                     ░░░
░░░                 ∙∙     ░░░
░░░       ┏━━━━━━┓  ∙∙   ░░░
░░░      ░░░░░░       ░░░
░░░  ∙∙    ░░░░░░        ░░░
░░░┗━━━━━━━━┛░░░░░░┗━━━━━━━━┛░░░
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░

You lost!

But maybe next time… you’ll make it! 😉

Recap

The addition of configurable rooms already makes the game more interesting and challenging. It’s now up to you to experiment—try different room shapes, sizes, and positions to create more exciting layouts!

That said, writing out x, y, width, and height for every room can get tedious. In the next section, we’ll streamline the configuration by introducing value lists—a much more compact and elegant way to describe the geometry of the rooms.

Using Value Lists →