Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

This is a C++20 monorepo containing games built on a shared custom ECS (Entity Component System) engine and built with CMake + vcpkg. Games may be terminal-based (FTXUI) or graphical (raylib).

Projects

ProjectDescriptionStatus
ecs_engineShared ECS core — entity management, sparse-set registry, thread-safe event queueStable
Path of GuTurn-based roguelike dungeon crawler with Gu worm cultivationComplete
Tic Tac ToeClassic two-player terminal gameComplete
Leah’s VillageGraphical real-time village builder (raylib)In development

Monorepo Structure

Each game is an independent CMake target. ecs_engine is a static library (libecs_engine.a) that any game can link against. The dependency graph is flat — games depend on ecs_engine, but not on each other.

ecs_engine  ←  path_of_gu
            ←  leah_village
            ←  (future games)

tic_tac_toe        (standalone, no ECS)

Dependencies

LibraryPurpose
fmtString formatting
spdlogLogging
FTXUITUI rendering (path_of_gu)
raylibGraphical rendering (leah_village)
Catch2Unit testing

ECS Engine

The ecs_engine is a lightweight Entity Component System shared across all games. It provides entity lifecycle management, a cache-friendly component registry, a DAG-based parallel scheduler, deferred command buffering, and a thread-safe event queue.

Including It

#include "ecs.hpp"  // entity, registry, system, command_buffer, event_queue, engine

All headers are flat under ecs_engine/ — there is no detail/ subdirectory.

Linking (CMake)

target_link_libraries(my_game PRIVATE ecs_engine)

The ecs_engine target is a static library that propagates its include directory, so no extra target_include_directories is needed.


Entity

An entity is just a uint16_t ID — a lightweight handle with no data of its own. Components are associated with it externally via the registry.

using Entity = uint16_t;
const Entity ENTITY_LIMIT = 65535;

EntityManager

Manages creation and recycling of entity IDs.

EntityManager em;

Entity e = em.createEntity();   // returns next ID, or recycles a destroyed one
em.destroyEntity(e);            // queues e for reuse on the next createEntity()
  • Throws std::runtime_error if the entity limit is exceeded.
  • Recycling is lazy — IDs are reused in FIFO order via an internal queue.

EntityComponentRegistry

The registry stores components in typed sparse sets. Each component type gets its own ComponentStore<T> with a packed dense array.

API

EntityComponentRegistry reg;

// Add or overwrite a component
reg.addComponent(entity, Health{100, 100});

// Get a pointer to the component (nullptr if absent)
Health* hp = reg.getComponent<Health>(entity);

// Remove (no-op if absent)
reg.removeComponent<Health>(entity);

// Get a snapshot of all entities that have component T
std::vector<Entity> alive = reg.view<Health>();

Storage Layout

ComponentStore<T>
  sparse[entity_id]  →  index into dense (or INVALID)
  dense[]            →  packed T values  ← cache-friendly iteration
  entities[]         →  parallel entity IDs
  • O(1) add, get, and remove (array index, no hashing on the hot path).
  • view() returns a copy of the entity list — safe to call removeComponent while iterating.
  • Removal swaps the target with the last element so dense stays packed with no holes.
  • Thread safetygetComponent and view use a read-only map lookup (find) and are safe to call from concurrent systems. addComponent and removeComponent are not thread-safe; use a CommandBuffer to defer mutations from within a system.

Systems

ISystem

All ECS systems inherit from ISystem and declare which component types they read and write. The scheduler uses these declarations to determine which systems can safely run in parallel.

struct ISystem {
    std::vector<ComponentType> reads;   // components read but not written
    std::vector<ComponentType> writes;  // components written (implies read)
    virtual ~ISystem() = default;
    virtual void update(EntityComponentRegistry& reg, CommandBuffer& cmd) = 0;
};

ComponentType is std::type_index. Declare types in the constructor:

struct VelocitySystem : ISystem {
    VelocitySystem() {
        reads  = { ComponentType(typeid(Velocity)) };
        writes = { ComponentType(typeid(Position)) };
    }
    void update(EntityComponentRegistry& reg, CommandBuffer&) override {
        for (Entity e : reg.view<Velocity>()) {
            auto* v = reg.getComponent<Velocity>(e);
            auto* p = reg.getComponent<Position>(e);
            if (v && p) { p->x += v->dx; p->y += v->dy; }
        }
    }
};

SystemsScheduler

Builds a dependency DAG from system read/write declarations and sorts it into parallel waves using Kahn’s algorithm. Systems in the same wave have no shared writer and can run concurrently.

Conflict rules:

  • write → read on the same type: the writer must run first.
  • write → write on the same type: the earlier-registered system runs first.
  • read → read: never a conflict; both can run in the same wave.
SystemsScheduler sched;
sched.add_system(sysA);  // registration order breaks ties
sched.add_system(sysB);
sched.build();            // must call before get_waves()

for (const auto& wave : sched.get_waves()) {
    // each wave is a std::vector<int> of system indices
}

CommandBuffer

Systems must not structurally modify the registry (add/remove components, destroy entities) while other systems may be reading it. Instead they queue mutations into a CommandBuffer which is flushed by World between waves.

void MySystem::update(EntityComponentRegistry& reg, CommandBuffer& cmd) {
    for (Entity e : reg.view<Dying>()) {
        cmd.destroy_entity(e);                      // deferred
        cmd.remove_component<Dying>(e);             // deferred
        cmd.add_component(e, Loot{...});            // deferred
    }
}

Available operations:

MethodEffect (applied at flush)
cmd.add_component(e, T{...})Adds or overwrites component T on entity e
cmd.remove_component<T>(e)Removes component T from entity e
cmd.destroy_entity(e)Destroys entity e via EntityManager

Flush happens automatically — do not call flush yourself when using World.


Engine

Engine is the top-level orchestrator. It owns the EntityManager, EntityComponentRegistry, SystemsScheduler, ThreadPool, and all system instances.

Setup

Engine engine;

// Register systems — Engine takes ownership
auto& vel_sys = engine.add_system<VelocitySystem>();
auto& render  = engine.add_system<RenderSystem>(window);

engine.build();  // builds the scheduler; call once after all add_system() calls

add_system<T>(args...) constructs T in-place and returns a non-owning reference, useful for holding a pointer to read per-system output after each tick.

Accessing the Registry

Entity e = engine.entities().createEntity();
engine.registry().addComponent(e, Position{0, 0});

Both entities() and registry() have const overloads, so Engine can be held by a const reference where mutation is not needed.

Ticking

engine.tick();

Each tick() call:

  1. For each scheduler wave: a. Submits every system in the wave to the thread pool. b. Waits for all systems in the wave to finish. c. Flushes each system’s CommandBuffer sequentially.
  2. Returns when all waves and flushes are complete.

Systems in the same wave run concurrently — correctness is guaranteed by the scheduler’s conflict analysis. Structural mutations (add/remove component, destroy entity) must always go through CommandBuffer.


EventQueue

A thread-safe FIFO queue, typically used to pass commands between an input thread and the game loop.

EventQueue<PlayerCommand> queue;

// Producer thread
queue.push(MoveCommand{"north"});

// Consumer thread — blocks until an event is available
PlayerCommand cmd = queue.pop_blocking();

// Consumer thread — returns immediately
std::optional<PlayerCommand> cmd = queue.try_pop();

bool empty = queue.empty();

Backed by std::mutex + std::condition_variable. pop_blocking() sleeps with zero CPU usage until push() wakes it.

Path of Gu

A turn-based roguelike dungeon crawler set in a cultivation fantasy world. You play as Fang Yuan, a Gu Master navigating a 7-level Grotto-Heaven, collecting and deploying Gu worms to defeat enemies and reach the exit.

How to Play

Move with arrow keys or type commands:

InputAction
Arrow keysMove north/south/east/west
attack <slot>Activate an offensive worm (slot 0–2)
heal <slot>Activate a recovery worm on yourself
pickup <index>Pick up a dropped worm from the ground
drop <index>Drop a worm from your aperture
skip / SpacePass your turn (restores essence out of combat)
quitExit the game

Win & Loss Conditions

  • Win — reach the final map (map 7) and defeat the Immortal’s Guardian.
  • Loss (death) — your HP drops to 0.
  • Loss (collapse) — your Primeval Essence hits 0 for 3 consecutive turns; your aperture collapses.

Game World

The dungeon is a linear chain of 7 maps, each a 10×10 grid. Maps connect via door cells (◆). Enemies spawn at random positions on their assigned map.

[Map 1] ─ [Map 2] ─ [Map 3] ─ [Map 4] ─ [Map 5 Cache] ─ [Map 6] ─ [Map 7 Exit]

Map 5 is a safe cache room containing two free Gu worms (Moonlight Gu, Steel Bones Gu) with no enemies.

Player Stats

StatStarting ValueNotes
HP100 / 100Reaches 0 → instant loss
Primeval Essence60 / 60Resource for activating worms
Cultivation Rank1Determines aperture capacity (rank × 3 slots)
Aperture capacity3 slotsHolds equipped Gu worms

Essence regeneration:

  • Successful move: +20% of max (min 1), depleted counter resets
  • Skip (out of combat): +20% of max
  • Depleted for 3 turns in a row → aperture collapses → defeat

Gu Worms

Worms are the core resource. Each has a type, essence cost, effect value, and range:

TypeEffectRange
OffensiveDeals damage to target≥ 1
DefensiveApplies armor/damage reduction≥ 1
RecoveryHeals HP0 (self only)
SupportDrains target’s essence≥ 1

Range is Chebyshev distance — 1 means adjacent (including diagonals), 0 means self-targeting only.

Notable Worms

WormTypeCostEffectRange
Strength GuOffensiveRaw damage1
Iron Skin GuDefensiveArmor1
Lightning GuOffensiveHigh damage
Vital GuRecoveryHeals HP0
Thunder Stomp GuOffensiveAoE-style
Moonlight GuCache room reward
Fixed Immortal GuBoss drop

Enemies

Behavior Types

TypeStyleEssenceAttack Range
WildAttacks on sight, no tactics301
SchemerUses worms tactically; prefers defense when hurt503
Guardian30% chance of double-damage power strike802

Enemy Roster

NameMapHPBehaviorNotable Drops
Wild Strength Gu1, 218WildStrength Gu (80%)
Wild Iron Skin Worm222WildIron Skin Gu (60%)
Demonic Gu Master - Wei345SchemerLightning Gu (70%), Iron Skin Gu (50%)
Demonic Gu Master - Liu450SchemerJade Skin Gu (70%), Vital Gu (40%)
Corrupted Worm Construct428WildStrength Gu (50%)
Iron Guardian Construct665GuardianRock Skin Gu (100%), Thunder Stomp Gu (50%)
Immortal’s Guardian7120GuardianBoiling Blood Gu (100%), Fixed Immortal Gu (60%)

Turn Structure

Every time the player takes an action (attack, heal, or skip), a full tick runs via Engine::tick():

WaveSystems (run in parallel within wave)What happens
1AiTickSystemEach enemy decides its action; stamps MoveIntentComponent or attack-effect components
2MoveTickSystem, SelfEffectTickSystemMovement intents resolved + self-heals/buffs applied concurrently
3AttackEffectTickSystemDamage and essence-drain effects applied

After all waves: dead entities are removed and loot is rolled (cleanup_dead).

Movement (arrow keys) does not trigger a full tick — only the player moves.

Wave 2 is the parallel wave: MoveTickSystem and SelfEffectTickSystem write to disjoint component types (Position/MoveIntentComponent vs Health/SelfEffectComponent) so the scheduler places them in the same wave and the thread pool runs them concurrently.

ECS Components

ComponentPurpose
HealthHP pool
PositionMap ID + x/y coordinates
Statsbase_attack, base_defense, attack_range
PrimevalEssenceEssence pool + depletion counter
CultivationRankRank + refinement points
ApertureEquipped worm slots
AIBehaviorBehavior type (Wild / Schemer / Guardian)
NameDisplay name
LootDrop table
MoveIntentComponentTransient — pending movement this tick
AttackEffectComponentTransient — pending attack effect this tick
SelfEffectComponentTransient — pending self-heal/buff this tick

Code Layout

path_of_gu/
  include/
    systems/        ← ISystem classes (recurring per-tick behavior)
      ai_system.hpp
      movement_system.hpp
      effect_system.hpp
    actions/        ← one-shot player/game commands
      combat.hpp    ← activate_worm()
      loot.hpp      ← pickup_worm(), drop_worm(), process_death()
    rendering/      ← pure read, UI only
      render.hpp    ← render()
    components/     ← plain data structs
    world/          ← Game world (map graph) + Map
    items/          ← GuWorm definitions and database
  src/
    systems/        ← ISystem implementations
    actions/        ← combat and loot implementations
    rendering/      ← FTXUI render implementation
    game.cpp        ← Game class: wires Engine, systems, and input loop

Tic Tac Toe

A classic two-player terminal game. X always goes first. Players alternate placing marks until one wins or the board fills.

How to Play

The board is numbered 0–8, left-to-right, top-to-bottom:

 0 | 1 | 2
-----------
 3 | 4 | 5
-----------
 6 | 7 | 8

Enter the index of the cell you want to mark. Invalid moves (occupied cell, out of bounds) are rejected and the turn does not advance.

Rules

  • Win — place three marks in a row, column, or diagonal.
  • Draw — board is full with no winner.

Architecture

All state is modeled as pure functions over a GameState value type, with no mutation:

struct GameState {
    std::array<char, 9> board;  // ' ', 'X', or 'O'
    char current_player;        // 'X' or 'O'
};

Functions

FunctionSignatureDescription
initial_state() → GameStateEmpty board, X starts
make_move(GameState, int) → optional<GameState>Returns new state or nullopt if move is invalid
check_winner(Board) → charReturns winning player or ' '
is_draw(Board) → boolTrue if full board and no winner
is_game_over(Board) → boolTrue if winner or draw
next_player(char) → charToggles between 'X' and 'O'
render_board(Board) → stringASCII 3×3 grid

The purely functional design makes the game logic trivially testable — every function is deterministic and side-effect-free.

Leah’s Village

Status: In development

A real-time village builder. Place buildings, manage resources, clear obstacles, and expand your settlement.

Architecture

The game is built on ecs_engine for simulation logic and raylib for graphical rendering (1280×720 window).

Layout

┌─────────────────────────────────────────────────────┐
│  HUD bar — gold / elixir / level / XP        [60px] │
├──────────────────────────────┬──────────────────────┤
│                              │                      │
│  Map viewport  800×408       │  Detail panel  480px │
│  (20×12 tiles, 40×34px ea.)  │                      │
│                              │                      │
├──────────────────────────────┴──────────────────────┤
│  Status / message log                       [252px] │
└─────────────────────────────────────────────────────┘

Game Loop

InitWindow → while (!WindowShouldClose())
    handle_input()   ← raylib key polling
    tick(dt)         ← ECS systems update
    BeginDrawing / render() / EndDrawing
CloseWindow

ECS Systems

SystemResponsibility
ProduceSystemTicks resource producers, fills storage
ConstructionSystemAdvances build/upgrade timers, fires completion
BoostSystemApplies time-limited production multipliers
LevelUpSystemAwards level-ups when pending XP thresholds are met
ExtractSystemProcesses obstacle-clearing timers

Key Components

ComponentData
Buildingtype enum, level
Constructionkind (build/upgrade), remaining time
ResourceProducerrate, output resource type
ResourceStoragegold/elixir amounts and caps
Obstaclegold/elixir reward, clear time
MapLocationmap ID, tile coordinates
Positionviewport position (follows cursor)
Selectedmarker — which entity the cursor is on

Persistence

Game state is saved to and loaded from SQLite (leah_village/game.db) via unofficial-sqlite3.

Running in a Devcontainer

The game requires an X11 display. The devcontainer ships a virtual display stack:

Xvfb :99  →  openbox  →  x11vnc (5900)  →  websockify (6080)  →  browser noVNC

Access the game at http://localhost:6080/vnc.html after the container starts. The DISPLAY=:99 and LIBGL_ALWAYS_SOFTWARE=1 env vars are set automatically.