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
| Project | Description | Status |
|---|---|---|
| ecs_engine | Shared ECS core — entity management, sparse-set registry, thread-safe event queue | Stable |
| Path of Gu | Turn-based roguelike dungeon crawler with Gu worm cultivation | Complete |
| Tic Tac Toe | Classic two-player terminal game | Complete |
| Leah’s Village | Graphical 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
| Library | Purpose |
|---|---|
| fmt | String formatting |
| spdlog | Logging |
| FTXUI | TUI rendering (path_of_gu) |
| raylib | Graphical rendering (leah_village) |
| Catch2 | Unit 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_errorif 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
removeComponentwhile iterating. - Removal swaps the target with the last element so
densestays packed with no holes. - Thread safety —
getComponentandviewuse a read-only map lookup (find) and are safe to call from concurrent systems.addComponentandremoveComponentare not thread-safe; use aCommandBufferto 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:
| Method | Effect (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:
- 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
CommandBuffersequentially. - 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:
| Input | Action |
|---|---|
| Arrow keys | Move 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 / Space | Pass your turn (restores essence out of combat) |
quit | Exit 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
| Stat | Starting Value | Notes |
|---|---|---|
| HP | 100 / 100 | Reaches 0 → instant loss |
| Primeval Essence | 60 / 60 | Resource for activating worms |
| Cultivation Rank | 1 | Determines aperture capacity (rank × 3 slots) |
| Aperture capacity | 3 slots | Holds 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:
| Type | Effect | Range |
|---|---|---|
| Offensive | Deals damage to target | ≥ 1 |
| Defensive | Applies armor/damage reduction | ≥ 1 |
| Recovery | Heals HP | 0 (self only) |
| Support | Drains target’s essence | ≥ 1 |
Range is Chebyshev distance — 1 means adjacent (including diagonals), 0 means self-targeting only.
Notable Worms
| Worm | Type | Cost | Effect | Range |
|---|---|---|---|---|
| Strength Gu | Offensive | — | Raw damage | 1 |
| Iron Skin Gu | Defensive | — | Armor | 1 |
| Lightning Gu | Offensive | — | High damage | — |
| Vital Gu | Recovery | — | Heals HP | 0 |
| Thunder Stomp Gu | Offensive | — | AoE-style | — |
| Moonlight Gu | — | — | Cache room reward | — |
| Fixed Immortal Gu | — | — | Boss drop | — |
Enemies
Behavior Types
| Type | Style | Essence | Attack Range |
|---|---|---|---|
| Wild | Attacks on sight, no tactics | 30 | 1 |
| Schemer | Uses worms tactically; prefers defense when hurt | 50 | 3 |
| Guardian | 30% chance of double-damage power strike | 80 | 2 |
Enemy Roster
| Name | Map | HP | Behavior | Notable Drops |
|---|---|---|---|---|
| Wild Strength Gu | 1, 2 | 18 | Wild | Strength Gu (80%) |
| Wild Iron Skin Worm | 2 | 22 | Wild | Iron Skin Gu (60%) |
| Demonic Gu Master - Wei | 3 | 45 | Schemer | Lightning Gu (70%), Iron Skin Gu (50%) |
| Demonic Gu Master - Liu | 4 | 50 | Schemer | Jade Skin Gu (70%), Vital Gu (40%) |
| Corrupted Worm Construct | 4 | 28 | Wild | Strength Gu (50%) |
| Iron Guardian Construct | 6 | 65 | Guardian | Rock Skin Gu (100%), Thunder Stomp Gu (50%) |
| Immortal’s Guardian | 7 | 120 | Guardian | Boiling 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():
| Wave | Systems (run in parallel within wave) | What happens |
|---|---|---|
| 1 | AiTickSystem | Each enemy decides its action; stamps MoveIntentComponent or attack-effect components |
| 2 | MoveTickSystem, SelfEffectTickSystem | Movement intents resolved + self-heals/buffs applied concurrently |
| 3 | AttackEffectTickSystem | Damage 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
| Component | Purpose |
|---|---|
Health | HP pool |
Position | Map ID + x/y coordinates |
Stats | base_attack, base_defense, attack_range |
PrimevalEssence | Essence pool + depletion counter |
CultivationRank | Rank + refinement points |
Aperture | Equipped worm slots |
AIBehavior | Behavior type (Wild / Schemer / Guardian) |
Name | Display name |
Loot | Drop table |
MoveIntentComponent | Transient — pending movement this tick |
AttackEffectComponent | Transient — pending attack effect this tick |
SelfEffectComponent | Transient — 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
| Function | Signature | Description |
|---|---|---|
initial_state | () → GameState | Empty board, X starts |
make_move | (GameState, int) → optional<GameState> | Returns new state or nullopt if move is invalid |
check_winner | (Board) → char | Returns winning player or ' ' |
is_draw | (Board) → bool | True if full board and no winner |
is_game_over | (Board) → bool | True if winner or draw |
next_player | (char) → char | Toggles between 'X' and 'O' |
render_board | (Board) → string | ASCII 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
| System | Responsibility |
|---|---|
ProduceSystem | Ticks resource producers, fills storage |
ConstructionSystem | Advances build/upgrade timers, fires completion |
BoostSystem | Applies time-limited production multipliers |
LevelUpSystem | Awards level-ups when pending XP thresholds are met |
ExtractSystem | Processes obstacle-clearing timers |
Key Components
| Component | Data |
|---|---|
Building | type enum, level |
Construction | kind (build/upgrade), remaining time |
ResourceProducer | rate, output resource type |
ResourceStorage | gold/elixir amounts and caps |
Obstacle | gold/elixir reward, clear time |
MapLocation | map ID, tile coordinates |
Position | viewport position (follows cursor) |
Selected | marker — 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.