XP Calculation, JSON Export & Damage Reports Over Entities
Adding a new virtual method (`toJson()`, `combatScore()`, `minimapIcon()`) to `WorldEntity` on every tool request bloats entity classes with unrelated responsibilities and triggers full-engine rebuilds. Conversely, writing external `dynamic_cast` cascades is brittle, slow, and prone to silent failures when new types appear.
HeroEntity is bloated with combat math, JSON serialization formatting, and minimap UI rendering code that have nothing to do with core hero gameplay logic.
Represent an operation to be performed on the elements of an object structure, letting you define a new operation without changing the classes of the elements on which it operates.
We use Double Dispatch. WorldEntity declares one pure virtual method: virtual void accept(EntityVisitor& visitor) const = 0;. Each concrete entity implements accept in one line: visitor.visit(*this);. Because the type of this is statically known at compile time, the compiler dispatches to the correct overloaded visit(const HeroEntity&) method. Now, each new operation (CombatScoreVisitor, JsonExportVisitor, MinimapRenderVisitor) is encapsulated in its own separate class. Entities are never touched again when adding new operations.
In Sprint 6 (C6 Β· Bridge), we decoupled high-level scene entities from rendering implementations, enabling clean cross-platform visuals across Vulkan, OpenGL, DX12, and Headless CI servers. With rendering modularized, separate teams now need to run multiple diverse passes across world entities.
We need to run distinct analytical and tooling operations across our closed set of world entities (HeroEntity, MonsterEntity, TrapEntity, LootChestEntity): combat balance scoring, structured JSON savegame export, and minimap UI icon generation.
Adding a new virtual method (toJson(), combatScore(), minimapIcon()) to WorldEntity on every tool request bloats entity classes with unrelated responsibilities and triggers full-engine rebuilds. Conversely, writing external dynamic_cast cascades is brittle, slow, and prone to silent failures when new types appear.
The Visitor Pattern (and modern std::variant + std::visit) adds operations non-invasively via double dispatch (accept(EntityVisitor&)), keeping entities pristine and closed for modification. Next in Sprint 8 (C8 Β· Interpreter), where quest designers want to write data-driven trigger strings ("level >= 10 AND HAS_ITEM DragonKey") and dynamic formulas in config files without C++ code changes.
The product ticket
Our RPG world contains a fixed, closed set of core world entities: HeroEntity, MonsterEntity, TrapEntity, and LootChestEntity. Multiple development teams need to execute new operations across these entities: balance designers need a Combat Threat Score Calculator, tools programmers need a JSON Save Exporter, and UI developers need a Minimap Icon Renderer.
- β’
Execute diverse operations across heterogeneous collections of world entities (
std::vector<std::unique_ptr<WorldEntity>>). - β’
Calculate aggregate combat threat scores: Heroes sum level + gear stats, Monsters compute HP + rage attack power, Traps contribute trigger damage, Chests contribute zero (unless mimics).
- β’
Export structured JSON representations of all entities for savegame files.
- β’
Support adding new operations (e.g. XP reward calculator or telemetry logging) without modifying any existing entity classes.
Next sprint, the telemetry team wants a Heatmap Death Analytics Exporter. In the naive design, adding an operation requires adding a new virtual method to WorldEntity and touching all four entity classes, creating frequent merge conflicts and triggering engine-wide recompiles.
Step 1 β The naive code & why it breaks
The naive approach adds a new virtual method to WorldEntity for every new operation (calculateCombatScore(), toJson(), minimapIcon()), forcing every entity subclass to implement every operation.
#include <format>
#include <memory>
#include <print>
#include <string>
#include <vector>
// Naive design: Every new operation (combat score, JSON export, minimap rendering)
// forces us to add a new virtual method to the base class and touch EVERY entity!
class NaiveWorldEntity {
public:
virtual ~NaiveWorldEntity() = default;
virtual int calculateCombatScore() const = 0;
virtual std::string toJson() const = 0;
virtual std::string minimapIcon() const = 0;
// Next sprint: virtual int calculateXpReward() = 0; // Forces editing all 4 subclasses!
};
class HeroEntity final : public NaiveWorldEntity {
std::string name_;
int level_;
int attack_;
int defense_;
public:
HeroEntity(std::string name, int level, int attack, int defense)
: name_(std::move(name)), level_(level), attack_(attack), defense_(defense) {}
int calculateCombatScore() const override {
return (level_ * 50) + (attack_ * 2) + defense_;
}
std::string toJson() const override {
return std::format(R"({{"type":"Hero","name":"{}","level":{},"score":{}}})",
name_, level_, calculateCombatScore());
}
std::string minimapIcon() const override { return "[H]"; }
};
class MonsterEntity final : public NaiveWorldEntity {
std::string name_;
int hp_;
int attack_;
bool isBoss_;
public:
MonsterEntity(std::string name, int hp, int attack, bool isBoss)
: name_(std::move(name)), hp_(hp), attack_(attack), isBoss_(isBoss) {}
int calculateCombatScore() const override {
return (hp_ / 2) + (attack_ * (isBoss_ ? 4 : 2));
}
std::string toJson() const override {
return std::format(R"({{"type":"Monster","name":"{}","hp":{},"boss":{}}})",
name_, hp_, isBoss_);
}
std::string minimapIcon() const override { return isBoss_ ? "[B]" : "[M]"; }
};
class TrapEntity final : public NaiveWorldEntity {
std::string type_;
int damage_;
bool disarmed_;
public:
TrapEntity(std::string type, int damage, bool disarmed)
: type_(std::move(type)), damage_(damage), disarmed_(disarmed) {}
int calculateCombatScore() const override {
return disarmed_ ? 0 : damage_;
}
std::string toJson() const override {
return std::format(R"({{"type":"Trap","hazard":"{}","dmg":{},"disarmed":{}}})",
type_, damage_, disarmed_);
}
std::string minimapIcon() const override { return disarmed_ ? "[.]" : "[X]"; }
};
int main() {
std::vector<std::unique_ptr<NaiveWorldEntity>> world;
world.push_back(std::make_unique<HeroEntity>("Valeria", 10, 30, 20));
world.push_back(std::make_unique<MonsterEntity>("Fire Drake", 400, 45, true));
world.push_back(std::make_unique<TrapEntity>("Poison Spikes", 80, false));
int totalScore = 0;
std::println("--- Naive Entity Methods ---");
for (const auto& entity : world) {
totalScore += entity->calculateCombatScore();
std::println("Icon: {} | JSON: {}", entity->minimapIcon(), entity->toJson());
}
std::println("Total Combat Threat Score: {}", totalScore);
return 0;
}HeroEntity is bloated with combat math, JSON serialization formatting, and minimap UI rendering code that have nothing to do with core hero gameplay logic.
Adding a single new operation (like calculateXpReward()) forces you to modify and recompile WorldEntity, HeroEntity, MonsterEntity, TrapEntity, and LootChestEntity.
Teams avoiding entity modification write external loops with if (auto* h = dynamic_cast<HeroEntity*>(e)) chains. Missing a type fails silently at runtime with zero compiler warnings.
Step 2 β The fix: Visitor Pattern
Represent an operation to be performed on the elements of an object structure, letting you define a new operation without changing the classes of the elements on which it operates.
We use Double Dispatch. WorldEntity declares one pure virtual method: virtual void accept(EntityVisitor& visitor) const = 0;. Each concrete entity implements accept in one line: visitor.visit(*this);. Because the type of this is statically known at compile time, the compiler dispatches to the correct overloaded visit(const HeroEntity&) method. Now, each new operation (CombatScoreVisitor, JsonExportVisitor, MinimapRenderVisitor) is encapsulated in its own separate class. Entities are never touched again when adding new operations.
WorldEntity β declares virtual void accept(EntityVisitor& visitor) const = 0;.
HeroEntity, MonsterEntity, TrapEntity, LootChestEntity β implement accept(visitor) by executing visitor.visit(*this).
EntityVisitor β declares overloaded visit() methods for each concrete element type (visit(const HeroEntity&), visit(const MonsterEntity&), etc.).
CombatScoreVisitor, JsonExportVisitor, MinimapRenderVisitor β encapsulate specific operations and accumulate results as they traverse entities.
Unlock Full UML Diagram & C++23 Solution
The interactive UML architecture diagrams, refactored C++23 code, real-world extensions, and interview Q&A are part of Brute2Best Pro. All 7 foundation chapters are 100% free.
First anchor question per pattern, Part 1 LLD & Module 1 C++ basics are 100% Free forever.