Write an Initial Game Version

Now that we have a working foundation, let’s implement the first version of our game!

Note

You don’t need to type everything by hand. After each code listing, there’s a link to download the full source file.

Geometry Classes

We’ll start with some basic data structures that serve as building blocks for the game.

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};
27
28constexpr static std::array<Position, 4> cPosDelta4 = {
29    {{1, 0}, {0, 1}, {-1, 0}, {0, -1}}};
30
31struct Size {
32    int width{};
33    int height{};
34
35    Size() = default;
36    constexpr Size(int width, int height) noexcept : width{width}, height{height} {}
37    [[nodiscard]] auto fitsInto(Size other) const noexcept -> bool {
38        return width <= other.width && height <= other.height;
39    }
40    [[nodiscard]] auto area() const noexcept -> int { return width * height; }
41    [[nodiscard]] auto center() const noexcept -> Position {
42        return {width / 2, height / 2};
43    }
44    [[nodiscard]] auto contains(const Position& pos) const noexcept -> bool {
45        return pos.x >= 0 && pos.y >= 0 && pos.x < width && pos.y < height;
46    }
47    [[nodiscard]] auto index(const Position& pos) const noexcept -> int {
48        return pos.y * width + pos.x;
49    }
50    [[nodiscard]] auto randomPosition() const noexcept -> Position {
51        return {randomInt(0, width - 1), randomInt(0, height - 1)};
52    }
53    template<typename Fn> void forEach(Fn fn) const {
54        for (int y = 0; y < height; ++y) {
55            for (int x = 0; x < width; ++x) {
56                fn(Position{x, y});
57            }
58        }
59    }
60    template<typename Fn> auto filterPositions(Fn fn) const -> std::vector<Position> {
61        std::vector<Position> result;
62        result.reserve(area());
63        forEach([&result, &fn](const Position& pos) {
64            if (fn(pos)) { result.push_back(pos); }
65        });
66        return result;
67    }
68};

Download Geometry.hpp

In this file, we define the classes Position and Size with a minimal set of utility methods. These will be used throughout the game code.

Additionally, we define the function randomInt() and the directional offset array cPosDelta4, which will help with movement logic.

The Canvas

Instead of building a complex graphical interface, we use a simple Canvas class that renders the game world as colored text in the console.

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(Position rectTopLeft, Size rectSize, Block block) noexcept {
41        rectSize.forEach([&](Position p) { setBlock(block, p + rectTopLeft); });
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 Canvas.hpp

The World

Next, we define the data structures that represent the game world.

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 Field {
 36    Size size{};
 37    void render(Canvas &canvas) const noexcept {
 38        canvas.fillRect(Position{-1, -1}, Size{size.width + 2, size.height + 2}, Block::Wall);
 39        canvas.fillRect(Position{0, 0}, size, Block::Room);
 40    }
 41};
 42
 43struct World {
 44    Field field;
 45    Player player;
 46    std::vector<Robot> robots;
 47    std::vector<Exit> exits;
 48
 49    explicit World(Size fieldSize) : field{fieldSize} {}
 50    template<typename T>
 51    [[nodiscard]] static auto tooNear(Position pos, int distance, const std::vector<T> &elements) {
 52        return std::any_of(elements.begin(), elements.end(), [&](const auto &element) {
 53            return element.pos.distanceTo(pos) <= distance;
 54        });
 55    }
 56    [[nodiscard]] auto isValidPlayerMovement(Position pos) const noexcept -> bool {
 57        return field.size.contains(pos);
 58    }
 59    [[nodiscard]] auto isValidRobotMovement(Position pos) const noexcept -> bool {
 60        return field.size.contains(pos) && !tooNear(pos, 0, robots);
 61    }
 62    [[nodiscard]] auto isPlayerOnExit() const noexcept -> bool {
 63        return std::ranges::any_of(exits, [&](const Exit &exit) -> bool { return exit.pos == player.pos; });
 64    }
 65    [[nodiscard]] auto isRobotOnPlayer() const noexcept -> bool {
 66        return std::ranges::any_of(robots, [&](const Robot &robot) -> bool { return robot.pos == player.pos; });
 67    }
 68    template<typename Fn>
 69    auto randomValidFieldPosition(Fn fn) const -> Position {
 70        auto validPositions = field.size.filterPositions(fn);
 71        if (validPositions.empty()) {
 72            throw std::logic_error{"Could not find a valid position"};
 73        }
 74        return validPositions[randomInt(0, validPositions.size() - 1)];
 75    }
 76    void addExitAtRandomPosition() {
 77        auto exit = Exit{randomValidFieldPosition([](Position pos){return true;})};
 78        exits.emplace_back(std::move(exit));
 79    }
 80    void setPlayerToRandomPosition() {
 81        player.pos = randomValidFieldPosition([&](Position pos) {
 82            return !tooNear(pos, 3, exits);
 83        });
 84    }
 85    void addRobotAtRandomPosition() {
 86        auto robot = Robot{randomValidFieldPosition([&](Position pos) {
 87            if (player.pos.distanceTo(pos) <= 4) { return false; }
 88            return !tooNear(pos, 4, exits) && !tooNear(pos, 1, robots);
 89        })};
 90        robot.name = std::format("Robot {}", robots.size() + 1);
 91        robots.emplace_back(std::move(robot));
 92    }
 93    void render(Canvas &canvas) const noexcept {
 94        canvas.setTopLeft(canvas.size.center() - field.size.center());
 95        field.render(canvas);
 96        for (const auto &exit : exits) {
 97            exit.render(canvas);
 98        }
 99        player.render(canvas);
100        for (const auto &robot : robots) {
101            robot.render(canvas);
102        }
103    }
104};

Download World.hpp

This file introduces the main game elements: Player, Robot, and Exit. These are placed within the Field class, which defines the map the game is played on. All of these are grouped into the World structure—a complete model of the game’s state.

This abstraction allows us to easily render the game state and apply game logic to it.

Game Logic

So far, we’ve only set up static structures. Now it’s time to make things dynamic.

robot-escape/src/Logic.hpp
  1#pragma once
  2
  3#include "Canvas.hpp"
  4#include "World.hpp"
  5
  6#include <iostream>
  7#include <map>
  8#include <string>
  9#include <vector>
 10#include <limits>
 11
 12struct PlayerInput {
 13    Position movement;
 14
 15    auto operator==(const PlayerInput &other) const noexcept -> bool = default;
 16    auto operator!=(const PlayerInput &other) const noexcept -> bool = default;
 17};
 18
 19constexpr auto cQuitInput = PlayerInput{Position{99, 99}};
 20
 21[[nodiscard]] inline auto inputFromConsole() noexcept -> PlayerInput {
 22    static const auto validInputs = std::map<std::string, PlayerInput>{
 23        {"e", {Position{1, 0}}},
 24        {"s", {Position{0, 1}}},
 25        {"w", {Position{-1, 0}}},
 26        {"n", {Position{0, -1}}},
 27        {"q", cQuitInput},
 28    };
 29    PlayerInput playerInput;
 30    while (playerInput == PlayerInput{}) {
 31        std::cout << "Enter your move (n/e/s/w/q=quit): ";
 32        std::cout.flush();
 33        std::string input;
 34        std::getline(std::cin, input);
 35        auto it = validInputs.find(input);
 36        if (it != validInputs.end()) {
 37            playerInput = it->second;
 38        } else {
 39            std::cout << "Invalid input. Please try again.\n";
 40        }
 41    }
 42    return playerInput;
 43}
 44
 45enum class GameState {
 46    Running,
 47    PlayerWon,
 48    RobotsWon,
 49};
 50
 51struct PlayerLogic {
 52    void advance(PlayerInput input, World &world) {
 53        auto newPlayerPos = world.player.pos + input.movement;
 54        if (world.isValidPlayerMovement(newPlayerPos)) {
 55            world.player.moveTo(newPlayerPos);
 56        } else {
 57            std::cout << "Could not move in this direction. You lost one move.\n";
 58        }
 59    }
 60};
 61
 62struct RobotLogic {
 63    void advance(Robot &robot, World &world) {
 64        if (robot.pos == world.player.pos) { return; }
 65        int bestDistance = std::numeric_limits<int>::max();
 66        std::vector<Position> bestMoves;
 67        for (auto delta : cPosDelta4) {
 68            auto pos = robot.pos + delta;
 69            if (!world.isValidRobotMovement(pos)) continue;
 70            auto dist = pos.distanceTo(world.player.pos);
 71            if (dist < bestDistance) { bestMoves = {pos}; bestDistance = dist; }
 72            else if (dist == bestDistance) { bestMoves.push_back(pos); }
 73        }
 74        if (bestMoves.empty()) return;
 75        robot.moveTo(bestMoves[randomInt(0, bestMoves.size() - 1)]);
 76    }
 77};
 78
 79struct Logic {
 80    World world;
 81    PlayerLogic playerLogic;
 82    RobotLogic robotLogic;
 83
 84    explicit Logic(World &&initialWorld) : world{std::move(initialWorld)} {}
 85
 86    void advance(PlayerInput input) {
 87        playerLogic.advance(input, world);
 88        for (auto &robot : world.robots) {
 89            robotLogic.advance(robot, world);
 90        }
 91    }
 92
 93    [[nodiscard]] auto gameState() const noexcept -> GameState {
 94        if (world.isPlayerOnExit()) { return GameState::PlayerWon; }
 95        if (world.isRobotOnPlayer()) { return GameState::RobotsWon; }
 96        return GameState::Running;
 97    }
 98
 99    void render(Canvas &canvas) const {
100        world.render(canvas);
101    }
102};

Download Logic.hpp

The game logic includes:

  • inputFromConsole() — handles user input.

  • PlayerLogic and RobotLogic — define movement behavior for players and robots.

  • Logic — combines everything and drives the game forward by updating the world based on player actions.

Using the Game Classes

We’re ready to glue everything together.

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    Size canvasSize = Canvas::cDefaultSize;
18
19    constexpr static auto cMinimumSize = Size{10, 10};
20
21    void parseArgs(int argc, char **argv) {
22        if (argc < 2) {
23            std::cout << "Usage: " << argv[0] << " <config-file>\n";
24            exit(1);
25        }
26        configPath = std::filesystem::path{argv[1]};
27    }
28
29    void readConfiguration() {
30        try {
31            Parser parser;
32            const auto source = Source::fromFile(configPath);
33            config = parser.parseOrThrow(source);
34        } catch (const Error &error) {
35            std::cerr << error.toText().toCharString() << "\n";
36            exit(1);
37        }
38    }
39
40    [[nodiscard]] auto buildWorld() const -> World {
41        try {
42            auto fieldSize = Size{
43                static_cast<int>(config->getIntegerOrThrow(u8"field.width")),
44                static_cast<int>(config->getIntegerOrThrow(u8"field.height"))};
45            if (!cMinimumSize.fitsInto(fieldSize)) {
46                std::cerr << std::format("Field size must be at least {}x{}\n", cMinimumSize.width, cMinimumSize.height);
47                exit(1);
48            }
49            if (!fieldSize.fitsInto(canvasSize)) {
50                std::cerr << std::format("Field size must be at most {}x{}\n", canvasSize.width, canvasSize.height);
51                exit(1);
52            }
53            World world{fieldSize};
54            world.addExitAtRandomPosition();
55            world.setPlayerToRandomPosition();
56            for (int i = 0; i < 3; ++i) {
57                world.addRobotAtRandomPosition();
58            }
59            return world;
60        } catch (const Error &error) {
61            std::cerr << error.toText().toCharString() << "\n";
62            exit(1);
63        }
64    }
65
66    void renderLogic(const Logic &logic) {
67        Canvas canvas{canvasSize};
68        logic.render(canvas);
69        canvas.renderToConsole();
70    }
71
72    void run() {
73        auto initialWorld = buildWorld();
74        std::cout << "----------------------------==[ ROBOT ESCAPE ]==-----------------------------\n";
75        std::cout << "Welcome to Robot Escape!\n";
76        std::cout << "You (☻) must run to the exit (⚑) before any robot (♟) catches you.\n\n";
77        auto logic = Logic{std::move(initialWorld)};
78        renderLogic(logic);
79        auto state = logic.gameState();
80        while (state == GameState::Running) {
81            auto playerInput = inputFromConsole();
82            if (playerInput == cQuitInput) {
83                std::cout << "Goodbye!\n";
84                return;
85            }
86            logic.advance(playerInput);
87            renderLogic(logic);
88            state = logic.gameState();
89        }
90        if (state == GameState::PlayerWon) {
91            std::cout << "You won!\n";
92        } else {
93            std::cout << "You lost!\n";
94        }
95    }
96};

Download Application.hpp

The Application class handles the full game flow:

  • Parses command-line arguments.

  • Loads the configuration file.

  • Builds the initial World state.

  • Starts and runs the game loop.

The loop displays instructions, initializes the game logic, renders the world, waits for input, processes the input, and repeats—until either the player or the robots win.

Replacing the Old Main Function

With the new Application class in place, we can simplify our main function dramatically:

robot-escape/src/main.cpp
1#include "Application.hpp"
2
3int main(int argc, char **argv) {
4    Application app;
5    app.parseArgs(argc, argv);
6    app.readConfiguration();
7    app.run();
8}

Download main.cpp

Adding the New Files to CMake

Finally, we have to add the new source files to our CMake file.

robot-escape/CMakeLists.txt
cmake_minimum_required(VERSION 3.25)
project(RobotEscapeApp)
add_executable(robot-escape
        src/Canvas.hpp
        src/World.hpp
        src/Geometry.hpp
        src/Logic.hpp
        src/main.cpp)
target_compile_features(robot-escape PRIVATE cxx_std_20)
target_link_libraries(robot-escape PRIVATE erbsland-configuration-parser)

Recap

That was quite a bit of code! But with it, we now have a functioning game loop and a flexible foundation you can build on.

We’ve kept things as simple as possible while still providing enough structure to be engaging and fun to extend. Try tweaking parts of the game and see what happens—you’re in full control!

Compile and Run our Game →