Files
deepMiner/guide/SDL2_GUI_EXTENSION_GUIDE.md
T
fegger 2a79d42bd6 World updates
Update World API and implementation:
rename size fields to sizeX_/sizeY_/sizeZ_, add helpers such as
mineAllPositive, positiveAverage, topPositiveSum and a sort helper,
and fix related surface/mine semantics for dynamic columns.
2026-04-26 17:48:28 +02:00

26 KiB
Raw Blame History

SDL2 GUI Extension Guide: Parallel Deep Miner

Scope: optional SDL2/SDL2_ttf renderer for the parallel Deep Miner project. This guide assumes the core implementation from PARALLEL_DEEP_MINER_GUIDE.md.

The GUI is intentionally separated from the terminal/threading guide so the base project remains buildable without SDL2.


Table of Contents

  1. Purpose
  2. Design Goals
  3. Prerequisites
  4. Renderer Architecture
  5. Renderer Public Interface
  6. Implementation Notes
  7. Integrating Renderer with Game
  8. GUI CMake Target
  9. GUI Entry Point
  10. Common Pitfalls
  11. Appendix A: Complete GUI Source Code

1. Purpose

The SDL2 renderer provides a live window beside the terminal output. It visualizes:

  • the 5×5 grid
  • surface values and effects
  • column depth bars
  • all 510 robots, not just two robots
  • live HP and scores
  • recent game messages
  • optional step-by-step or auto-advance playback

This guide replaces the old two-robot GUI design that referenced player_, computer_, autoMode_, and play(Robot&, bool). The parallel architecture uses std::vector<std::unique_ptr<Robot>>, robotLoop(idx), and a complete-turn mutex.


2. Design Goals

  1. Optional dependency: the terminal build must not require SDL2 headers or libraries.
  2. Parallel-compatible: rendering works with the robot vector and does not assume exactly two robots.
  3. No ownership confusion: Game owns the robots; Renderer only observes them.
  4. Stable snapshots: render calls happen while the game state is stable.
  5. Simple build split: deep_miner remains terminal-only; deep_miner_gui links SDL2.

3. Prerequisites

Arch Linux

sudo pacman -S sdl2 sdl2_ttf

Ubuntu / Debian

sudo apt-get install libsdl2-dev libsdl2-ttf-dev

Fedora

sudo dnf install SDL2-devel SDL2_ttf-devel

The code can probe common system font paths at runtime. Do not bundle font files unless your project explicitly permits it.


4. Renderer Architecture

Use the pimpl idiom so SDL2 headers stay out of normal project headers.

include/Renderer.h                  src/Renderer.cpp
------------------                  ----------------
class Renderer {                    #include <SDL2/SDL.h>
    struct Impl;                    #include <SDL2/SDL_ttf.h>
    std::unique_ptr<Impl> impl_;    struct Renderer::Impl { ... };
};

Only src/Renderer.cpp includes SDL2 headers. Game.h can forward-declare Renderer.


5. Renderer Public Interface

// include/Renderer.h
#pragma once

#include <memory>
#include <string>
#include <vector>

class World;
class Robot;

class Renderer {
public:
    Renderer();
    ~Renderer();

    Renderer(const Renderer&) = delete;
    Renderer& operator=(const Renderer&) = delete;

    bool init();

    void render(const World& world,
                const std::vector<const Robot*>& robots,
                int round,
                const std::vector<std::string>& log);

    bool waitForStep();
    void setAutoAdvanceMs(int ms);
    bool isOpen() const;

private:
    struct Impl;
    std::unique_ptr<Impl> impl_;
};

Why pass std::vector<const Robot*>?

Game owns robots as std::unique_ptr<Robot>. The renderer should not know or care about ownership. A vector of raw const Robot* is a lightweight read-only view.


6. Implementation Notes

Layout

A practical 900×680 window layout:

+-------------------------------+----------------------------+
| grid title / round            | scores and HP              |
|                               |                            |
| 5×5 grid                      | legend                     |
| each cell shows surface value | message log                |
| and robot markers             | controls                   |
+-------------------------------+----------------------------+

Recommended constants:

static constexpr int WIN_W      = 900;
static constexpr int WIN_H      = 680;
static constexpr int HDR_H      = 56;
static constexpr int GRID_OFF_X = 32;
static constexpr int GRID_OFF_Y = 82;
static constexpr int CELL_W     = 96;
static constexpr int CELL_H     = 86;
static constexpr int PANEL_X    = 536;
static constexpr int PANEL_PAD  = 10;

Drawing multiple robots in one cell

A single cell may contain several robots. Draw small indexed markers instead of assuming one P and one C.

std::vector<int> robotsInCell;
for (int i = 0; i < static_cast<int>(robots.size()); ++i) {
    if (robots[i]->isAlive()
        && robots[i]->getX() == x
        && robots[i]->getY() == y) {
        robotsInCell.push_back(i);
    }
}

for (int k = 0; k < static_cast<int>(robotsInCell.size()); ++k) {
    const int robotIndex = robotsInCell[k];
    const int cx = cellX + 18 + (k % 3) * 22;
    const int cy = cellY + 20 + (k / 3) * 22;

    fillCircle(cx, cy, 9, robotColor(robotIndex));
    drawText(std::to_string(robotIndex + 1), cx - 4, cy - 7, white, false);
}

Controls

Key / action Effect
SPACE / ENTER advance immediately
mouse click advance immediately
ESC close the GUI window
window close button close the GUI window
auto-advance continue after configured milliseconds

waitForStep()

bool Renderer::waitForStep() {
    const Uint32 start = SDL_GetTicks();

    while (impl_->open) {
        SDL_Event event;
        while (SDL_PollEvent(&event)) {
            if (event.type == SDL_QUIT) {
                impl_->open = false;
                return false;
            }

            if (event.type == SDL_KEYDOWN) {
                const auto key = event.key.keysym.sym;
                if (key == SDLK_ESCAPE) {
                    impl_->open = false;
                    return false;
                }
                if (key == SDLK_SPACE || key == SDLK_RETURN) {
                    return true;
                }
            }

            if (event.type == SDL_MOUSEBUTTONDOWN) {
                return true;
            }
        }

        if (impl_->autoAdvanceMs > 0
            && static_cast<int>(SDL_GetTicks() - start) >= impl_->autoAdvanceMs) {
            return true;
        }

        SDL_Delay(8);
    }

    return false;
}

7. Integrating Renderer with Game

Game.h additions

// include/Game.h
class Renderer;

class Game {
public:
    Game();
    void run();

#ifdef WITH_SDL2_GUI
    void setRenderer(Renderer* renderer) { renderer_ = renderer; }
#endif

private:
    // existing members

#ifdef WITH_SDL2_GUI
    Renderer* renderer_ = nullptr;                  // non-owning
    std::vector<std::string> msgLog_;               // capped message buffer
    int currentRound_ = 0;

    void pushMsg(const std::string& msg);
    void renderFrameLocked();
#endif
};

The pointer is non-owning. main_gui.cpp creates the renderer and passes its address to Game.

Game.cpp includes

#ifdef WITH_SDL2_GUI
#include "../include/Renderer.h"
#endif

Message logging

void Game::log(const std::string& msg) {
    std::cout << msg << '\n';

#ifdef WITH_SDL2_GUI
    if (renderer_) {
        msgLog_.push_back(msg);
        if (msgLog_.size() > 120) {
            msgLog_.erase(msgLog_.begin());
        }
    }
#endif
}

If you prefer to keep log() terminal-only, use a separate pushMsg() helper. The important point is that the GUI log must be updated from the same serialized turn path.

Build a read-only robot view

#ifdef WITH_SDL2_GUI
std::vector<const Robot*> Game::robotView() const {
    std::vector<const Robot*> view;
    view.reserve(robots_.size());

    for (const auto& robot : robots_) {
        view.push_back(robot.get());
    }

    return view;
}
#endif

If you add this helper, declare it in Game.h under the same #ifdef.

renderFrameLocked()

#ifdef WITH_SDL2_GUI
void Game::renderFrameLocked() {
    if (!renderer_ || !renderer_->isOpen()) return;

    std::vector<const Robot*> view;
    view.reserve(robots_.size());
    for (const auto& robot : robots_) {
        view.push_back(robot.get());
    }

    renderer_->render(world_, view, currentRound_, msgLog_);
    renderer_->waitForStep();
}
#endif

The name renderFrameLocked() is deliberate: call it only while turnMutex_ is held. This guarantees the renderer sees a consistent snapshot. In GUI step mode, the game intentionally pauses while the user views the frame.

Calling the renderer from robotLoop()

void Game::robotLoop(int idx) {
    ScopedTimer threadTimer(threadTimes_[idx]);
    Robot& robot = *robots_[idx];

    while (true) {
        {
            std::lock_guard<std::mutex> lk(turnMutex_);

            if (!robot.isAlive()) {
                recordDeath(idx, "killed earlier");
#ifdef WITH_SDL2_GUI
                renderFrameLocked();
#endif
                break;
            }

            if (isGameOver()) {
                break;
            }

            ++currentRound_;

            const int dir = robot.decideNextMove(world_);
            robot.move(dir, world_);

            const int effect = world_.checkEffects(robot.getX(), robot.getY());
            if (effect < 0) applyEffect(robot, effect);

            if (!robot.isAlive()) {
                recordDeath(idx, "killed by effect");
#ifdef WITH_SDL2_GUI
                renderFrameLocked();
#endif
                break;
            }

            fightNearby(robot);

            if (effect != -1 && robot.isAlive()) {
                const int mined = robot.mine(world_);
                log(robot.getName() + " mined " + std::to_string(mined) + " points.");
            }

            checkRearrange(robot);

#ifdef WITH_SDL2_GUI
            renderFrameLocked();
#endif
        }

        std::this_thread::sleep_for(std::chrono::milliseconds(10));
    }
}

This preserves the base thread model. The GUI mode slows the simulation because it pauses after turns, but it does not reintroduce player_, computer_, or a sequential play() loop.

Initial and final frames

Render once after setup and once after all threads join. Both should happen while no robot thread is mutating the game state.

void Game::run() {
    setup();

#ifdef WITH_SDL2_GUI
    {
        std::lock_guard<std::mutex> lk(turnMutex_);
        renderFrameLocked();
    }
#endif

    // launch and join threads

#ifdef WITH_SDL2_GUI
    if (renderer_ && renderer_->isOpen()) {
        renderer_->setAutoAdvanceMs(0);
        std::lock_guard<std::mutex> lk(turnMutex_);
        log("=== GAME OVER ===");
        renderFrameLocked();
    }
#endif
}

8. GUI CMake Target

Keep the terminal target independent from SDL2.

option(BUILD_GUI "Build SDL2 GUI target" OFF)

if (BUILD_GUI)
    find_package(SDL2 REQUIRED)
    find_package(SDL2_ttf REQUIRED)

    add_executable(deep_miner_gui
        ${SOURCES}
        src/Renderer.cpp
        main_gui.cpp
    )

    target_compile_definitions(deep_miner_gui PRIVATE WITH_SDL2_GUI)
    target_include_directories(deep_miner_gui PRIVATE ${SDL2_INCLUDE_DIRS})
    target_link_libraries(deep_miner_gui
        Threads::Threads
        SDL2::SDL2
        SDL2_ttf::SDL2_ttf
    )
endif()

Depending on your platform's CMake package files, the imported targets may be named differently. If SDL2::SDL2 or SDL2_ttf::SDL2_ttf is unavailable, use the variables exported by your SDL2 CMake package.

Build:

mkdir build
cd build
cmake -DBUILD_GUI=ON ..
cmake --build .
./deep_miner_gui

9. GUI Entry Point

// main_gui.cpp
#include "include/Game.h"
#include "include/Renderer.h"

#include <exception>
#include <iostream>

int main() {
    try {
        Renderer renderer;
        if (!renderer.init()) {
            std::cerr << "Could not initialise SDL2 renderer.\n";
            return 1;
        }

        renderer.setAutoAdvanceMs(900);

        Game game;
        game.setRenderer(&renderer);
        game.run();

        return 0;
    } catch (const std::exception& e) {
        std::cerr << "Fatal error: " << e.what() << "\n";
        return 1;
    }
}

The normal terminal main.cpp remains unchanged and does not include Renderer.h.


10. Common Pitfalls

Pitfall 1: Including SDL2 in Game.h

Do not include <SDL2/SDL.h> in Game.h. Forward-declare Renderer and include SDL2 only in Renderer.cpp.

Pitfall 2: Rendering two hard-coded robots

Do not use player_, computer_, P, or C as architectural concepts. The game has 510 robots. Render indexed markers or abbreviated robot names.

Pitfall 3: Waiting for input without a stable snapshot

If the renderer waits while other threads mutate the world, the displayed state can become inconsistent. The simple solution is to call renderFrameLocked() while turnMutex_ is held. For a more advanced solution, copy a lightweight snapshot under the lock and let the renderer wait after the lock is released.

Pitfall 4: Linking SDL2 into the terminal target

Only deep_miner_gui should link SDL2. The base deep_miner target should link only Threads::Threads.

Pitfall 5: GUI timing changes thread measurements

Step mode and auto-advance delays are part of the measured runtime if you call the renderer inside robotLoop(). This is acceptable for visualization. For benchmark runs, use the terminal target.


Minimal Project Additions

deep_miner/
|-- main_gui.cpp
|-- include/
|   `-- Renderer.h
`-- src/
    `-- Renderer.cpp

The GUI guide deliberately stays separate from the base programming guide. The core project remains a clean terminal-based C++17 threading assignment, while this extension adds visualization without changing the ownership model or robot architecture.


Appendix A: Complete GUI Source Code

This appendix contains the complete GUI-specific files plus the small Game integration points required by the renderer. The core project files remain in PARALLEL_DEEP_MINER_GUIDE.md.

main_gui.cpp

#include "Game.h"
#include "Renderer.h"

#include <exception>
#include <iostream>

int main() {
    try {
        Renderer renderer;
        if (!renderer.init()) {
            std::cerr << "Could not initialise SDL2 renderer.\n";
            return 1;
        }

        renderer.setAutoAdvanceMs(900);
        Game game;
        game.setRenderer(&renderer);
        game.run();
        return 0;
    } catch (const std::exception& e) {
        std::cerr << "Fatal error: " << e.what() << "\n";
        return 1;
    }
}

include/Renderer.h

#pragma once

#include <memory>
#include <string>
#include <vector>

class World;
class Robot;

class Renderer {
public:
    Renderer();
    ~Renderer();

    bool init();
    void render(const World& world,
                const std::vector<Robot*>& robots,
                int round,
                const std::vector<std::string>& log);

    bool waitForStep();
    void setAutoAdvanceMs(int ms);
    bool isOpen() const;

private:
    struct Impl;
    std::unique_ptr<Impl> impl_;
};

src/Renderer.cpp

#include "Renderer.h"
#include "Robot.h"
#include "World.h"

#include <SDL2/SDL.h>
#include <SDL2/SDL_ttf.h>

#include <algorithm>
#include <cmath>
#include <sstream>
#include <string>
#include <vector>

namespace {
SDL_Color rgba(Uint8 r, Uint8 g, Uint8 b, Uint8 a = 255) { return SDL_Color{r, g, b, a}; }
}

struct Renderer::Impl {
    SDL_Window* window = nullptr;
    SDL_Renderer* sdl = nullptr;
    TTF_Font* fontLg = nullptr;
    TTF_Font* fontSm = nullptr;
    bool open = false;
    int autoAdvanceMs = 900;

    static constexpr int WIN_W = 920;
    static constexpr int WIN_H = 700;
    static constexpr int GRID_X = 32;
    static constexpr int GRID_Y = 84;
    static constexpr int CELL_W = 92;
    static constexpr int CELL_H = 82;
    static constexpr int PANEL_X = 535;

    bool init() {
        if (SDL_Init(SDL_INIT_VIDEO) != 0) return false;
        if (TTF_Init() != 0) return false;

        window = SDL_CreateWindow("Parallel Deep Miner",
                                  SDL_WINDOWPOS_CENTERED,
                                  SDL_WINDOWPOS_CENTERED,
                                  WIN_W, WIN_H,
                                  SDL_WINDOW_SHOWN);
        if (!window) return false;

        sdl = SDL_CreateRenderer(window, -1, SDL_RENDERER_ACCELERATED | SDL_RENDERER_PRESENTVSYNC);
        if (!sdl) return false;

        const char* candidates[] = {
            "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
            "/usr/share/fonts/dejavu/DejaVuSans.ttf",
            "/Library/Fonts/Arial.ttf",
            "C:/Windows/Fonts/arial.ttf"
        };
        for (const char* path : candidates) {
            if (!fontLg) fontLg = TTF_OpenFont(path, 20);
            if (!fontSm) fontSm = TTF_OpenFont(path, 13);
            if (fontLg && fontSm) break;
        }

        open = true;
        return true;
    }

    void shutdown() {
        if (fontLg) TTF_CloseFont(fontLg);
        if (fontSm) TTF_CloseFont(fontSm);
        if (sdl) SDL_DestroyRenderer(sdl);
        if (window) SDL_DestroyWindow(window);
        fontLg = nullptr;
        fontSm = nullptr;
        sdl = nullptr;
        window = nullptr;
        TTF_Quit();
        SDL_Quit();
    }

    void setColor(SDL_Color c) {
        SDL_SetRenderDrawColor(sdl, c.r, c.g, c.b, c.a);
        SDL_SetRenderDrawBlendMode(sdl, c.a < 255 ? SDL_BLENDMODE_BLEND : SDL_BLENDMODE_NONE);
    }

    void fillRect(int x, int y, int w, int h, SDL_Color c) {
        setColor(c);
        SDL_Rect r{x, y, w, h};
        SDL_RenderFillRect(sdl, &r);
    }

    void strokeRect(int x, int y, int w, int h, SDL_Color c) {
        setColor(c);
        SDL_Rect r{x, y, w, h};
        SDL_RenderDrawRect(sdl, &r);
    }

    void fillCircle(int cx, int cy, int radius, SDL_Color c) {
        setColor(c);
        for (int dy = -radius; dy <= radius; ++dy) {
            int dx = static_cast<int>(std::sqrt(radius * radius - dy * dy));
            SDL_RenderDrawLine(sdl, cx - dx, cy + dy, cx + dx, cy + dy);
        }
    }

    void drawText(const std::string& text, int x, int y, SDL_Color color, bool large = false) {
        TTF_Font* font = large ? fontLg : fontSm;
        if (!font || text.empty()) return;
        SDL_Surface* surface = TTF_RenderUTF8_Blended(font, text.c_str(), color);
        if (!surface) return;
        SDL_Texture* texture = SDL_CreateTextureFromSurface(sdl, surface);
        SDL_Rect dst{x, y, surface->w, surface->h};
        SDL_FreeSurface(surface);
        if (!texture) return;
        SDL_RenderCopy(sdl, texture, nullptr, &dst);
        SDL_DestroyTexture(texture);
    }

    SDL_Color cellColor(int value) const {
        if (value == 0) return rgba(28, 31, 35);
        if (value == -1) return rgba(176, 96, 28);
        if (value == -2) return rgba(88, 55, 140);
        if (value == -3) return rgba(170, 40, 55);
        int clamped = std::clamp(value, 1, 9);
        Uint8 g = static_cast<Uint8>(70 + clamped * 15);
        Uint8 r = static_cast<Uint8>(20 + clamped * 22);
        return rgba(r, g, 55);
    }

    void drawGrid(const World& world, const std::vector<Robot*>& robots) {
        for (int y = 0; y < world.getSizeY(); ++y) {
            for (int x = 0; x < world.getSizeX(); ++x) {
                int px = GRID_X + x * CELL_W;
                int py = GRID_Y + y * CELL_H;
                int value = world.getSurfaceValue(x, y);
                fillRect(px, py, CELL_W - 8, CELL_H - 8, cellColor(value));
                strokeRect(px, py, CELL_W - 8, CELL_H - 8, rgba(230, 230, 230, 90));

                std::string label = value == 0 ? "--" : std::to_string(value);
                drawText(label, px + 32, py + 24, rgba(245, 245, 245), true);

                int depth = world.getSurfaceLevel(x, y) + 1;
                int barW = static_cast<int>((CELL_W - 12) * (static_cast<double>(depth) / world.getSizeZ()));
                fillRect(px, py + CELL_H - 15, barW, 5, rgba(240, 240, 240, 150));
            }
        }

        for (std::size_t i = 0; i < robots.size(); ++i) {
            Robot* r = robots[i];
            if (!r || !r->isAlive()) continue;
            int px = GRID_X + r->getX() * CELL_W + 18 + static_cast<int>((i % 3) * 18);
            int py = GRID_Y + r->getY() * CELL_H + 14 + static_cast<int>((i / 3) * 14);
            fillCircle(px, py, 10, rgba(80, 190, 240));
            drawText(std::to_string(i + 1), px - 4, py - 8, rgba(10, 10, 10));
        }
    }

    void drawPanel(const std::vector<Robot*>& robots, int round, const std::vector<std::string>& log) {
        fillRect(PANEL_X, 0, WIN_W - PANEL_X, WIN_H, rgba(20, 22, 26));
        drawText("Round " + std::to_string(round), PANEL_X + 18, 24, rgba(245, 245, 245), true);
        drawText("Robots", PANEL_X + 18, 70, rgba(230, 230, 230), true);

        int y = 105;
        for (std::size_t i = 0; i < robots.size(); ++i) {
            Robot* r = robots[i];
            if (!r) continue;
            std::ostringstream oss;
            oss << (i + 1) << ". " << r->getName() << "  score=" << r->getScore() << "  hp=" << r->getHp();
            drawText(oss.str(), PANEL_X + 18, y, r->isAlive() ? rgba(235, 235, 235) : rgba(160, 160, 160));
            y += 22;
        }

        y += 18;
        drawText("Messages", PANEL_X + 18, y, rgba(230, 230, 230), true);
        y += 34;
        int start = std::max(0, static_cast<int>(log.size()) - 15);
        for (int i = start; i < static_cast<int>(log.size()); ++i) {
            drawText(log[i], PANEL_X + 18, y, rgba(210, 210, 210));
            y += 20;
        }
    }

    void render(const World& world, const std::vector<Robot*>& robots, int round, const std::vector<std::string>& log) {
        if (!open) return;
        setColor(rgba(12, 14, 18));
        SDL_RenderClear(sdl);
        drawText("DEEP MINER", 32, 24, rgba(245, 245, 245), true);
        drawGrid(world, robots);
        drawPanel(robots, round, log);
        drawText("SPACE/click: step   ESC/window close: quit", 32, WIN_H - 36, rgba(210, 210, 210));
        SDL_RenderPresent(sdl);
    }

    bool waitForStep() {
        if (!open) return false;
        Uint32 start = SDL_GetTicks();
        SDL_Event event;
        while (open) {
            while (SDL_PollEvent(&event)) {
                if (event.type == SDL_QUIT) { open = false; return false; }
                if (event.type == SDL_KEYDOWN) {
                    if (event.key.keysym.sym == SDLK_ESCAPE) { open = false; return false; }
                    if (event.key.keysym.sym == SDLK_SPACE || event.key.keysym.sym == SDLK_RETURN) return true;
                }
                if (event.type == SDL_MOUSEBUTTONDOWN) return true;
            }
            if (autoAdvanceMs > 0 && SDL_GetTicks() - start >= static_cast<Uint32>(autoAdvanceMs)) return true;
            SDL_Delay(10);
        }
        return false;
    }
};

Renderer::Renderer() : impl_(std::make_unique<Impl>()) {}
Renderer::~Renderer() { impl_->shutdown(); }
bool Renderer::init() { return impl_->init(); }
void Renderer::render(const World& world, const std::vector<Robot*>& robots, int round, const std::vector<std::string>& log) { impl_->render(world, robots, round, log); }
bool Renderer::waitForStep() { return impl_->waitForStep(); }
void Renderer::setAutoAdvanceMs(int ms) { impl_->autoAdvanceMs = ms; }
bool Renderer::isOpen() const { return impl_->open; }

CMakeLists.txt GUI additions

option(BUILD_GUI "Build SDL2 GUI target" OFF)

if (BUILD_GUI)
    find_package(SDL2 REQUIRED)
    find_package(SDL2_ttf REQUIRED)

    add_executable(deep_miner_gui
        ${SOURCES}
        src/Renderer.cpp
        main_gui.cpp
    )

    target_compile_definitions(deep_miner_gui PRIVATE WITH_SDL2_GUI)
    target_include_directories(deep_miner_gui PRIVATE ${SDL2_INCLUDE_DIRS})
    target_link_libraries(deep_miner_gui
        Threads::Threads
        SDL2::SDL2
        SDL2_ttf::SDL2_ttf
    )
endif()

include/Game.h GUI integration additions

Add a forward declaration before the Game class:

class Renderer;

Add this public method:

#ifdef WITH_SDL2_GUI
void setRenderer(Renderer* renderer);
#endif

Add these private members and helper declarations:

#ifdef WITH_SDL2_GUI
Renderer* renderer_ = nullptr;
std::vector<std::string> guiLog_;
void renderFrameLocked();
std::vector<Robot*> robotPointers() const;
#endif

src/Game.cpp GUI integration additions

Include the renderer only for the GUI build:

#ifdef WITH_SDL2_GUI
#include "Renderer.h"
#endif

Add these methods:

#ifdef WITH_SDL2_GUI
void Game::setRenderer(Renderer* renderer) {
    renderer_ = renderer;
}

std::vector<Robot*> Game::robotPointers() const {
    std::vector<Robot*> out;
    out.reserve(robots_.size());
    for (const auto& robot : robots_) out.push_back(robot.get());
    return out;
}

void Game::renderFrameLocked() {
    if (!renderer_ || !renderer_->isOpen()) return;
    renderer_->render(world_, robotPointers(), round_, guiLog_);
    renderer_->waitForStep();
}
#endif

Modify Game::log so GUI messages are recorded while terminal output remains unchanged:

void Game::log(const std::string& message) const {
    std::cout << message << "\n";
#ifdef WITH_SDL2_GUI
    auto* self = const_cast<Game*>(this);
    self->guiLog_.push_back(message);
    if (self->guiLog_.size() > 120) self->guiLog_.erase(self->guiLog_.begin());
#endif
}

Call renderFrameLocked() after each atomic turn, before releasing the mutex:

// near the end of Game::robotLoop(), still inside the lock_guard scope
checkRearrange(robot);
#ifdef WITH_SDL2_GUI
renderFrameLocked();
#endif

Render an initial and final frame in Game::run():

#ifdef WITH_SDL2_GUI
{
    std::lock_guard<std::mutex> lock(turnMutex_);
    renderFrameLocked();
}
#endif

// launch and join threads here

#ifdef WITH_SDL2_GUI
if (renderer_ && renderer_->isOpen()) {
    renderer_->setAutoAdvanceMs(0);
    std::lock_guard<std::mutex> lock(turnMutex_);
    guiLog_.push_back("=== GAME OVER ===");
    renderFrameLocked();
}
#endif