A shade cannot cheat the dice it did not roll.
This document describes the technical architecture of The Master's Shadow, a
C++17 / raylib gothic narrative roguelike. It is written for engineers who need to
understand, extend, or audit the codebase. Everything below is grounded in the
source: file and symbol names are real, constants are quoted from
src/core/constants.hpp, and the render numbers come from src/render/viewport.hpp
and src/game/app.cpp.
The guiding principle is a hard separation between what the game is (a pure, deterministic simulation) and how the game looks and sounds (a raylib presentation layer). The Master — the pursuing antagonist — is a pluggable agent bolted onto that simulation through the exact same action API the player uses. He is "restricted just like the player," and the type system enforces it.
The project is deliberately split so that the rules can be tested, replayed, and balanced without ever opening a window. There are three code layers and one content layer.
| Layer | Artifact | raylib? | Purpose |
|---|---|---|---|
| Core simulation | ms_core (static lib) |
No | The deterministic heart: authoritative GameState, RulesEngine, RNG, world gen, magic, AI, agent, save/replay. |
| Presentation | masters_shadow (exe) |
Yes | Screens, HUD, scene rendering, audio, particles, input. Reads state, issues Actions, draws. |
| Agent | inside ms_core + agent_engine/ (Python) |
No | The Master's brain: default deterministic ACO, or an external LLM via a validated, async bridge. |
| Content | data/ (flat records) |
— | Lineages, spells, enemies, events, chaos, endings, items — git-native, human-editable text. |
ms_core is a STATIC library built from src/core, src/world, src/magic,
src/ai, src/agent, and src/save (see CMakeLists.txt). It does not link
raylib and does not include raylib.h. This is a load-bearing constraint: because
the simulation has no graphics dependency, it can be linked directly into headless
test and balance executables that run in CI with no display.
The library exposes exactly one authoritative mutable object — GameState
(src/core/game_state.hpp) — owned by exactly one gatekeeper — RulesEngine
(src/core/rules_engine.hpp). Nothing outside that engine's API is permitted to
mutate simulation state.
The game executable is built from src/main.cpp plus src/render, src/ui,
src/audio, and src/game, and links ms_core and raylib. This layer owns the
window, the fixed-resolution render target, the screen state machine, and all input.
It is a consumer of ms_core: it holds a RulesEngine, reads
engine->state() for display, and prefers submitting Actions via apply() for
anything that should be journaled.
Hybrid mutation model (honest). The pure ideal is "presentation only reads
state and issues Actions." In practice Phase 2/3 still own some gameplay mutations
through RulesEngine::mutable_state() (resource spends, buff decay, camp recovery,
chaos side-effects, combat flag charges). Those paths use the engine RNG when rolls
matter, but they are not appended to the action journal. Consequences:
- The golden guarantee
(seed, lineage, journal) → state_hashis proven for the core journal path (ms_headless --selftest, baseline16425e914e1636ca). - Full player runs are not pure-journal-replayable today.
- Continue therefore writes MSSNAP state snapshots (
src/game/run_save.cpp), not journal replay alone. The older MSSAVE journal format remains for tools and core replay.
The long-term direction is to promote high-churn screen mutations into additive
ActionTypes (see docs/MUTABLE_STATE_INVENTORY.md). Setup / phase entry still
correctly uses mutable_state() for placing units and seeding carryover pools.
Combat is a parallel presentation sim (own HP/shield, side-stream RNG); it only
touches GameState for inventory flags and spell/item effects.
The Master is an IAgent (src/agent/agent.hpp). The default implementation,
ACOMasterAgent, is a deterministic ant-colony-optimization / pheromone brain that
lives entirely inside ms_core. Two alternative implementations connect to an
external LLM — LLMAgent (raw OpenAI-compatible HTTP) and BridgeAgent (the
agent_engine/ Python package that drives LM Studio). All of them are selected by a
factory and are interchangeable behind the same interface. See §6.
Game content is authored as flat key: value records, parsed by ContentDB
(src/core/content_db.hpp). Records within a file are separated by a line
containing only ---, so YAML-frontmatter markdown drops in cleanly. The live
bundle (validate with python tools/okf_validate.py):
data/
├── lineages/ injected bloodline traits (per-mille ints)
├── enemies/ injected bestiary stats
├── chaos/ apex chaos base weights
├── tuning/ global knobs + Phase-1 economy
├── world/ terrain move-cost + encounter base
├── spells/ injected SpellRow catalogue (combat + field)
├── items/ injected ItemRow catalogue (satchel)
├── difficulty/ Normal / Hardcore rows (row 0 = selftest literals)
├── reagents/ tag-synergy alchemy (BrewCombine)
├── traps/ Architect trap table
├── master_spells/ Master counter-alchemy
├── reputation/ authored faction events
├── events/ Phase-2 wild events (journaled RNG in screens)
├── recipes/ app-layer brew recipes
├── strings/ uistring via tr()
├── barks/ Master taunt corpora
├── camp/ night-camp dreams
├── endings/ endings + lineage codas
├── lore/ codex fragments (presentation)
└── achievements/ honours definitions
ContentDB::load_dir recursively loads *.md / *.txt, indexes records by id,
and offers typed accessors (get, geti, getf, getb, getlist) plus lookups
by id and by type. Core-consumed numeric tables are injected as frozen POD
structs (GameTables) before the first apply(); those fields must be integers
(per-mille for former floats). See docs/MODDING.md.
Reproducibility is the spine of this project. The design goal is stated in
RulesEngine's header comment: (seed, lineage, journal) reproduces an identical
GameState — the guarantee that the Master cannot cheat and that any run is
auditable and replayable.
RulesEngine owns a single Rng rng_ (a PCG32 stream, src/core/rng.hpp), seeded
from the game seed in its constructor:
RulesEngine::RulesEngine(uint64_t seed, Lineage lineage)
: state_(GameState::new_game(seed, lineage)), rng_(seed) {}The rule: every player-affecting roll must go through eng()->rng(). Skill
checks, forage yields, trace decay, weather countdowns, chore outcomes — all of them
draw from this one stream inside RulesEngine::dispatch. Because the sequence of
draws is a pure function of the sequence of actions, two engines fed the same actions
in the same order will draw the same numbers and land in the same state. Presentation
code that needs randomness for a decision that changes the simulation is required to
route the resulting change back through an Action, so the draw that matters happens
on the engine stream — not on some ad-hoc rand() in a screen.
Every action that passes the legality gate is appended to a journal:
RulesEngine::ApplyResult RulesEngine::apply(const Action& a) {
if (!is_legal(a)) return ApplyResult{false, false, {}, "illegal"};
ApplyResult res = dispatch(a);
journal_.push_back(a); // ← the audit trail
return res;
}Action (src/core/action.hpp) is intentionally compact and serialisable: a stable
ActionType enum plus four generic ints (a, b, c, d), a string s, and a
by_master flag. The comment on the enum is explicit that "enum values are stable
(save/replay format)" — you may append new action types but must not renumber
existing ones. A full run is therefore just its seed, its lineage, and its ordered
list of actions.
The static replay method reconstructs state from nothing but those three inputs:
GameState RulesEngine::replay(uint64_t seed, Lineage lineage,
const std::vector<Action>& journal) {
RulesEngine e(seed, lineage);
for (const auto& a : journal) e.apply(a);
return e.state();
}Note that replay constructs a fresh RNG from the seed — the RNG state is not serialized, because it is fully determined by the seed and the action sequence.
GameState::state_hash() (src/core/game_state.cpp) folds every field of the state
into a single 64-bit FNV-1a value using the incremental Hasher
(src/core/hashing.hpp). Two properties make this a reliable determinism oracle:
- Fixed fold order. Fields are mixed in a hand-written, order-fixed sequence
(seed, turn, phase, day segment, then player, master, world, and finally flags).
Because
flagsis astd::map<std::string,int>, it iterates in sorted-key order, so even the dynamic flag set hashes deterministically. - Totality. Resources, magic paths, trace/echo/bond/parasite/suspicion, hex positions, skills, reputation, weather, and every flag are included. Nothing that can affect play is left out of the fingerprint.
The headless self-test (§7) runs the same seeded policy twice and asserts the two
state_hash results are byte-identical.
Phase 2 stages a crowd-simulated faction war (Rebels, Loyalists, Trolls) contesting the wood, plus procedural encounter gating. Those systems consume a lot of random numbers, but they do not directly determine the outcome of the escape — they are ambient world simulation and encounter gating.
Rather than pollute the authoritative engine stream with these draws (which would
make the (seed, lineage, actions) → state_hash relationship depend on how many
warband ticks happened to fire), Phase2Screen keeps a dedicated side-stream:
Rng war_rng_{1};
...
war_rng_.reseed(ctx_->seed ^ 0xBA771EULL); // src/game/phase2.cppSeeding it from ctx_->seed ^ 0xBA771E keeps the faction war itself reproducible for
a given seed, while isolating its consumption from the core simulation's RNG. The
faction war and encounter rolls (war_.step(..., war_rng_),
war_rng_.next_double() < encounter_chance(...)) draw only from this side-stream.
The division of labor is the rule:
All player-affecting rolls go through
eng()->rng(). The faction war / encounter gating — and only that — draws fromwar_rng_.
This is why the determinism guarantee is scoped to the core state hash: the engine stream is the source of truth, and the war side-stream is a reproducible-but-separate flourish that cannot perturb it.
The src/ tree divides cleanly along the layer boundary. Everything on the left of
the diagram is ms_core (no raylib); everything on the right is the presentation
executable (links raylib).
THE MASTER'S SHADOW — MODULE MAP
┌───────────────────────────── ms_core (static lib, NO raylib) ─────────────────────────────┐
│ │
│ core/ world/ magic/ ai/ │
│ ┌──────────────────┐ ┌───────────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ game_state │ │ hexmap forestgen │ │ lineage │ │ master_ai (ACO) │ │
│ │ rules_engine ◀───┼──────┤ pathfind flowfield│ │ apex │ │ pursuit_sim │ │
│ │ action rng │ │ bestiary faction_ │ │ (bloodlines, │ │ (headless chase, │ │
│ │ skill_check │ │ war │ │ clash, chaos)│ │ balance) │ │
│ │ content_db │ └───────────────────┘ └──────────────┘ └──────────────────┘ │
│ │ event_bus hashing │ │
│ │ hex types const. │ agent/ save/ │
│ └──────────────────┘ ┌────────────────────────────┐ ┌───────────────┐ │
│ ▲ │ IAgent ACOMasterAgent │ │ save (journal │ │
│ │ │ LLMAgent BridgeAgent ─────┼──▶ │ + state I/O) │ │
│ │ └────────────────────────────┘ └───────────────┘ │
└──────────┼─────────────────────────────────────────────────────────┬──────────────────────┘
│ Actions (submit) state() (read-only) │
│ │ popen / async
┌──────────┼──────────── masters_shadow (exe, links raylib) ─────────┐│ ┌────────────────────┐
│ │ ││ │ agent_engine/ │
│ game/ │ render/ ui/ audio/ │└─▶│ run.py config.py │
│ ┌───────┴──────────┐ ┌────────────────────┐ ┌──────────┐ ┌────┐ │ │ lmstudio_client.py │
│ │ app (loop, RT) │ │ viewport (vw/vh, │ │ hud text │ │audio│ │ │ master_agent.py │
│ │ screen title │ │ virtual_mouse) │ │ widgets │ └────┘ │ │ → LM Studio (local │
│ │ lineage_select │ │ scene assets │ │ palette │ │ │ LLM, OpenAI- │
│ │ phase1/2/3 combat│ │ particles │ └──────────┘ │ │ compatible) │
│ └──────────────────┘ └────────────────────┘ │ └────────────────────┘
│ │ main.cpp → GameApp::run() │
└────────────┼─────────────────────────────────────────────────────┘
│
┌───────┴────────┐ ┌──────────────────────┐ ┌─────────────────────────┐
│ masters_shadow │ │ ms_headless (ms_core)│ │ ms_tests (ms_core + │
│ (game exe) │ │ selftest/balance/apex│ │ doctest, 46 tests) │
└────────────────┘ └──────────────────────┘ └─────────────────────────┘
Core (src/core) — the simulation kernel: GameState, RulesEngine, Action,
the Rng PCG32 stream, skill_check (d20 resolution), ContentDB, the EventBus
(fire-and-forget UI notifications), the FNV-1a Hasher, hex math, shared types,
and centralized constants.
World (src/world) — procedural forest generation, hex map, A* pathfinding and
flow fields, the bestiary, and the crowd-simulated faction_war.
Magic (src/magic) — lineage (the six bloodlines and their signature
mechanics) and apex (the Phase-3 sympathetic-bond clash, nine chaos events, ritual
inversion, and ending determination).
AI (src/ai) — master_ai, the deterministic ACO/pheromone MasterBrain, and
pursuit_sim, a self-contained headless chase used by tests and the balance sweep.
Agent (src/agent) — the IAgent interface and its three implementations,
plus the factory make_master_agent.
Save (src/save) — persistence of the run (seed, lineage, journal, state).
Presentation (src/render, src/ui, src/audio, src/game) — the
fixed-resolution render pipeline (viewport, scene, assets, particles), HUD /
text / widgets / palette, the ElevenLabs-backed audio system, and the game
screen state machine (app, screen, title_screen, lineage_select,
phase1/phase2/phase3, combat).
The entire game changes state through one funnel. Screens do not mutate; they request.
┌──────────┐ Action a ┌──────────────────┐ is_legal(a)? ┌───────────────┐
│ Screen │─────────────▶│ RulesEngine │────────────────▶│ legality gate│
│ (phase1/ │ │ ::apply(a) │ └───────┬───────┘
│ 2/3, …) │◀─────────────│ │ ok / │ reject
└──────────┘ ApplyResult └──────────────────┘ ▼
▲ read-only │ dispatch(a) ┌──────────────────────┐
│ state() ▼ │ GameState mutators │
│ ┌──────────────────┐ clamped only │ advance_clock/adjust_ │
└─────────────────│ EventBus emit │◀───────────────│ trace/echo/bond/… │
(HUD, toasts)└──────────────────┘ + journal.push└──────────────────────┘
- A screen builds an
Actiondescribing intent — e.g.Action::move(dir),Action::end_turn(), or a hand-filledCastSpell/Forage/CounterRitual. RulesEngine::applyvalidates it viais_legal. Illegal actions are rejected without touching state (Moveneeds energy,CastSpellneeds mana ≥ cost, an ended game only acceptsNoop). This is the same gate the Master's actions pass through — the agent literally cannot submit a move the player couldn't.dispatchapplies the effect, drawing any randomness fromrng_and mutatingGameStateonly through its clamped mutators (advance_clock,adjust_trace,adjust_echo,adjust_bond,adjust_parasite,adjust_suspicion,adjust_reputation,set_flag). Every one of these clamps to the ranges inconstants.hpp, so state can never drift out of bounds.- The action is journaled and side effects are announced on the
EventBus(Ev::Moved,Ev::SkillChecked,Ev::ClockAdvanced, …). TheEventBusis a one-way notification channel for the presentation layer — HUD flashes, toasts, audio cues. It carries no authority; it cannot change state. - The screen reads back
engine->state()(const) to redraw, and inspects the returnedApplyResult(ok,had_check,check,message) for immediate feedback such as the die roll on a skill check.
Because the only write path is apply → dispatch → clamped mutators, and the only
read path is const GameState& state(), the invariant holds: nothing mutates
simulation state outside the engine API. This is what makes the replay/hash
guarantee sound — there is no back door for a screen (or an agent) to poke a field.
All presentation is authored against a single virtual canvas so that layout is
pixel-identical on every monitor and DPI. The constants live in
src/render/viewport.hpp:
constexpr int kVW = 1280;
constexpr int kVH = 720;
inline int vw() { return kVW; }
inline int vh() { return kVH; }Every screen, HUD element, and scene lays out in these virtual coordinates via
vw() / vh() — never against the live window size.
GameApp::run (src/game/app.cpp) drives the loop. Once, at startup, it allocates a
RenderTexture2D at the virtual resolution:
RenderTexture2D target = LoadRenderTexture(kVW, kVH);
SetTextureFilter(target.texture, TEXTURE_FILTER_BILINEAR);Each frame:
-
Recompute the transform. From the current window size it derives a uniform scale that fits the 1280×720 canvas inside the window, and centers it (letterbox offsets
ox,oy). These are stored on the process-wideViewportsingleton:float scale = fminf(ww / (float)kVW, wh / (float)kVH); Viewport& vp = viewport(); vp.scale = scale; vp.ox = (ww - kVW * scale) * 0.5f; vp.oy = (wh - kVH * scale) * 0.5f;
-
Render the world into the target at native virtual resolution (
BeginTextureMode(target)→screen_->draw()→ optional pause overlay →EndTextureMode()). -
Present, scaled and letterboxed. The texture is blitted to the backbuffer with
DrawTexturePro, using a source rectangle with a negated height ({0,0,kVW,-kVH}) to flip the bottom-up GL texture right way up, into a destination rectangle offset by the letterbox margins and scaled to fit.
Because rendering is scaled and offset, raw mouse coordinates are meaningless to
virtual-space UI. virtual_mouse() (src/render/viewport.cpp) inverts the transform
so input lands where the pixels are:
Vector2 virtual_mouse() {
Vector2 m = GetMousePosition();
const Viewport& v = viewport();
float s = v.scale > 0 ? v.scale : 1.0f;
return Vector2{(m.x - v.ox) / s, (m.y - v.oy) / s};
}All widget hit-testing uses virtual_mouse(), so a button at virtual (640, 360) is
clickable at the same logical spot regardless of window size, fullscreen, or DPI.
The Assets singleton (src/render/assets.hpp) caches POINT-filtered pixel-art
textures and the three UI fonts (FontRole::Display, Body, Mono), resolving the
assets/ directory relative to wherever the game was launched. Missing textures
return a visible magenta placeholder rather than crashing — deliberately loud, so a
missing asset is impossible to miss during development. The window itself is created
with FLAG_MSAA_4X_HINT | FLAG_VSYNC_HINT | FLAG_WINDOW_RESIZABLE, a 60 FPS target,
and SetExitKey(KEY_NULL) so Escape opens the pause menu instead of killing the run
mid-game.
The Master's decision-making is abstracted behind IAgent (src/agent/agent.hpp).
Whatever brain is plugged in sees only a bounded, honest observation and may choose
only among legal moves.
AgentObshands the agent aMasterViewand the pre-computed list oflegal_moves(walkable neighbours). The header note is blunt about intent: this is "what keeps a plugged-in LLM restricted just like the player."AgentActionis one ofMove(to a target hex),Forage, orRest, plus an optional in-characternarrationline the Master may speak.
Three implementations, one factory (make_master_agent, selected by
MS_MASTER_AGENT):
| Kind | Class | Behavior |
|---|---|---|
aco (default) |
ACOMasterAgent |
Deterministic ACO/pheromone MasterBrain — offline, reproducible, always available. |
openai |
LLMAgent |
Calls an OpenAI-compatible /chat/completions endpoint over HTTP; validates the reply against legal moves; falls back to ACO. Config: MS_LLM_URL, MS_LLM_MODEL, MS_LLM_KEY. |
llm / cosysim |
BridgeAgent |
Drives the agent_engine/ Python bridge (LM Studio) asynchronously. |
Async, validated, fallback-safe. BridgeAgent::decide never blocks the frame: the
ACO brain plays instantly while a background std::async thread asks the local
model for its (possibly slower) decision. When a reply arrives it is re-validated
against the current legal moves before use; anything illegal, empty, or late is
discarded and ACO stands in. The Python side (agent_engine/config.py) is careful
never to force-load a model — it only uses a model LM Studio already has resident,
honoring MS_LLM_MODEL only when that model is loaded (or MS_LLM_FORCE=1), and
reading MS_LLM_URL (default http://127.0.0.1:1234/v1) and MS_LLM_KEY. The net
effect: an LLM can voice and drive the Master, but it can never cheat, never stall
the game, and never crash the run — every path degrades to the deterministic brain.
CMakeLists.txt (top level) defines the library and three executables. It fetches
and statically builds raylib 5.5 via FetchContent, and on MinGW statically
links the runtime (-static -static-libgcc -static-libstdc++) so the shipped
binaries and CI runs need no external DLLs.
| Target | Sources | Links | Role |
|---|---|---|---|
ms_core |
core, world, magic, ai, agent, save |
— | Deterministic simulation static lib (no raylib). |
masters_shadow |
main.cpp + render, ui, audio, game |
ms_core, raylib |
The playable game executable. |
ms_headless |
src/headless_main.cpp |
ms_core |
Determinism / balance / apex harness (no raylib). |
ms_tests |
tests/*.cpp |
ms_core |
46 doctest unit tests; registered with CTest. |
src/headless_main.cpp links only ms_core, so it runs anywhere with no display.
It offers three modes:
-
--selftest— runs a seeded stochastic self-play policy (run_sim) twice and asserts the twostate_hashfingerprints match. This is the determinism guarantee reduced to a single PASS/FAIL:selftest seed=12345 turns=40 h1=… h2=… PASSThe policy exercises chores, puzzles, spells, forage, rest, movement, counter-ritual, and Master research/movement — a broad slice of the action space — and every choice comes from an independent
decideRNG seeded off the game seed, so the whole run is reproducible. -
--balance N[--agent aco|llm|openai]— a Monte-Carlo sweep of the Phase-2 pursuit viasimulate_pursuit, tallying apprentice-escaped / Master-caught / stalemate acrossNseeds. It reports BALANCED only if both escape and capture occur (the design goal: "hard, not unfair" — neither trivially winnable nor impossible). -
--apex N— a sweep of the Phase-3 sympathetic-bond clash for all six bloodlines, faithfully mirroringPhase3Screen::resolve_round(bond contribution, the nine chaos events, ritual inversion, ending determination). It flags any lineage asONE-SIDEDif it can only ever win or only ever lose, proving that after the signature hooks every bloodline can both win and lose the clash — no dominant, no inert lineage.
Plain invocation (no flag) prints a single state_hash for a given
--seed / --lineage / --turns, which is convenient for comparing two builds or two
platforms for bit-identical behavior.
Because ms_core carries no raylib dependency, the entire ruleset — determinism,
Phase-2 balance, and Phase-3 fairness — is verifiable in headless CI in milliseconds,
with no window, no GPU, and no audio device. The presentation executable can then be
treated as a thin, best-effort view over a simulation that is already proven correct.
For cross-reference, the meters the engine tracks and clamps (ranges and tiers from
src/core/constants.hpp and game_state.cpp):
| Meter | Range | Notes |
|---|---|---|
ritual_clock |
0–100 | 4 stages: Preparation <25, Alignment, Convergence ≥50, Possession ≥75. |
suspicion |
0–10 | Phase 1 only; tiers safe / caution (≥3) / alert (≥6) / paranoid (≥9). |
trace |
0–100 | Primary Master tracking vector; medium ≥21, high ≥51, critical ≥81. |
echo_bleed |
0–100 | Whispers ≥21, intrusion ≥41, dominion ≥61, consumed ≥81. |
parasite |
0–100 | Trace Parasite; aggressive ≥40, parasitic ≥70, else symbiotic. |
bond |
0–100 | Sympathetic bond; Veyra starts at 30, all others at 15. |
Day/night runs on kDaySegments = 12 (night = segments 6–11). All player-affecting
changes to these meters pass through GameState's clamped mutators, and every one of
them is folded into state_hash — so the determinism guarantee covers the full
simulation, meters and flags alike.