The Master's Shadow

The Master’s Shadow

A narrative-driven gothic roguelike of apprenticeship, escape, and ritual inversion — where the villain is a real AI agent bound by the same brutal rules as you.

status engine tests determinism honours license

title screen

“He needs you young. He needs you skilled. He needs you alive… until the final moment.”

You are an apprentice bound to the ancient wizard Master Malacor through a partial sympathetic ritual. When the moon comes full he means to pour his mind into your young body and let what remains of you go dark. Serve. Steal. Learn forbidden magic. Sabotage the rite. Slip the wood before the clock strikes — and survive the reckoning at the Heart of the Ritual.

The Master is not a scripted villain. He is an autonomous agent adjudicated by the same deterministic engine that governs you — he forages, tires, loses the trail, lays traps, and can fail. And he can be driven by any brain: the built-in reproducible ACO pathfinder, or a local/frontier LLM you plug in at runtime.

Highlights

  • A villain that plays like a player. The Master issues the same legal actions you can, validated by one authoritative RulesEngine. He cannot cheat the simulation, and a Monte-Carlo sweep proves he is a real but beatable threat (~22% catch against an optimal fugitive).
  • Pluggable AI antagonist. Hand the Master to a local LM Studio model or a frontier API with one environment variable. Decisions run asynchronously so the game never stalls, and any illegal or slow reply falls back to the deterministic brain.
  • Three distinct games in one arc. A KQ3-style stealth cottage, a Curious Expedition–style survival hex-crawl, and a turn-based ritual duel — each with its own loop, UI, and failure states.
  • Six bloodlines that genuinely play differently. Each has a decision-changing signature across the forest and the apex — a scent-poisoning decoy, a damage-to-mana siphon, a foresight that reads the Master’s intent, and more.
  • A learnable spellbook and a usable satchel. Twenty-two data-defined spells (White/Grey/Black, including a full Grey information-war kit — Memory Fog, Invisibility, Haste, Sleep) you learn, cast in combat, and work in the field; a real inventory of items — all driven from the data/ bundle, so a new spell or item is a few lines of markdown, no code.
  • A day that costs something. Phase 1 runs on a visible segment budget — chores, snooping and study all spend it, exhaustion ends your options, and only your bed ends the day while the ritual clock grinds on. Two difficulties (Normal / Hardcore), both hard; Hardcore is a shorter fuse in a sharper house — and it never saves. One life, one shot.
  • Puzzles that feed your escape. The cottage is a web: the Master’s chest yields a cipher scrap, the hidden library’s desk breaks it, and suddenly you know where the ritual knife sleeps — the reckless escape becomes a plan. Every bloodline finds one door that opens easier for it, and every failed pry teaches the House how you work (retry-hardening DCs).
  • Fifteen Honours. An achievements board of dark medallions — escape clean, break the rite, win a Hardcore heart, keep your hands white — three of them hidden, all persistent across runs, with day-boundary Continue checkpoints on Normal.
  • An information war. Brew emergent concoctions from tagged reagents, fog the Master’s memory of your trail, and weather his six counter-workings — potion corruption, alchemical backlash, memory anchors — while his Architect self seeds the wood with seven kinds of trap.
  • Deep, systemic mechanics. A ritual doom-clock, sympathetic bond clash, corrupting Trace Parasite and Echo Bleed, crowd-simulated faction wars over the wood, weather, alchemy, and eight endings with per-lineage epilogues.
  • Fully deterministic core. (seed, lineage, action-journal) → identical state hash, enforced by the engine and verified in tests. Replays, headless AI-vs-AI sims, and balance sweeps all reproduce exactly.
  • Self-contained & regenerable. All pixel art (Python + Pillow) and audio (ElevenLabs) are generated by scripts and bundled — the shipped build needs no keys, no network, no external runtime.

The Three Phases

Phase 1 — The Apprentice’s Cage

A story-first stealth/puzzle loop in one open cottage of physical, furnished rooms. Read the chore board, do your tasks in plain sight, and learn magic or sabotage the rite only while the Master is away — his absences are announced per-destination with a consistent, learnable length and a live “~N turns till he returns” countdown. A rising Suspicion meter triggers escalating searches. Dice appear only when an action needs a check. Discover what you are, find the way out, and choose one of four escapes.

Phase 2 — The Forest Pursuit

A procedural hex-crawl on a Curious Expedition day/move budget. Manage Food / Water / Energy / Mana, make camp to survive, and forage at authored nodes — springs, fae rings, goblin markets, shrines, contested ley-nexuses — each with its own interaction. Sense the Master’s strategy through your blood, weather the rain and storms that wash or betray your trail, brew alchemy from harvested reagents, and evade a bounded pursuer (Bloodhound direct-run vs Architect interception) while faction warbands contest the wood in a live crowd simulation you can incite, exploit, or avoid. Proc-gen encounters drop you into a biome-tinted combat sub-screen.

Phase 3 — The Ritual Apex

A turn-based sympathetic bond clash at the Heart of the Ritual, amid nine chaos events that warp the field. Press the bond, foul it with Grey static, ward yourself, sever the rite, or — with enough disruption sigils — invert it and turn the Master’s own working against him. Every bloodline breaks the rite its own way. It resolves to one of eight endings, each with a written, lineage-flavored epilogue.


The Six Bloodlines

Each bloodline reshapes how you learn, flee, and face the ritual — and each has a signature mechanic that changes your decisions, not just a stat.

Bloodline Identity Phase 2 signature Phase 3 signature
Veyra — The Vessels Bond, at the cost of self Echo-foresight reads the Master’s next intent (closing vs. flanking), paid in Echo Bleed Bond-hijack skims his clash contribution
Nyxari — The Veiled Misdirection Lays a false trail that poisons the Master AI’s scent map Calms the chaos storm
Thalor — The Wardens Defensive stability Ley-line footing ignores distortion & traps on Water and Path Ward-wall inversion
Dravenblood — The Binders Black magic & inversion Black-siphon turns damage taken into mana Inversion charge; the parasite that hurts others feeds them
Sylvara — The Fae-Touched Nature & resistance Wild-forage and a beast-pact that makes the wild’s own stand aside Domination-resistant; the wood surges to their aid
Auralis — The Echoes Memory & information Copies the Master’s last tactic → a free step, a laid trap, or a cleansed trace Rides the Echo Storm

Screenshots

Every scene is a hand-directed painted backdrop walked inside by a depth-scaled, animated character — with soft firelight, foreground occlusion, particle motes, bloodline-tinted sprites, HD flat-top hex tiles, and a gothic UI-Kit HUD. It renders at a fixed 1280×720 and scales crisply to any window or DPI.

The Apprentice’s Cage The Forest Pursuit
phase 1 phase 2
Night Camp — Cook, Brew, Rest A Wild Encounter
camp combat
The Ritual Apex Choose Your Bloodline
phase 3 lineage
The Honours Board
honours

Build & Run

Requirements: CMake ≥ 3.16, a C++17 compiler (MinGW-w64 g++ or MSVC), Git, and an internet connection on the first configure (raylib is fetched automatically via FetchContent). No other runtime dependencies — executables are statically linked and self-contained.

# from the repository root
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release \
      -DCMAKE_C_COMPILER=gcc -DCMAKE_CXX_COMPILER=g++      # MinGW; omit the compiler flags for MSVC
cmake --build build

# play
./build/bin/masters_shadow                                 # Windows: build\bin\masters_shadow.exe

Handy flags: --start=title|lineage|phase1|phase2|phase3 boots straight to a screen; --smoke --shot=out.png renders a few frames, saves a screenshot, and exits (used by CI and this README).

Ship a standalone build: bash tools/package.sh produces a self-contained dist/the_masters_shadow/ (exe + assets + data + docs) that runs with no toolchain installed.

See docs/BUILDING.md for the full toolchain guide, MSVC preset, and troubleshooting.


Controls

Where Keys
Menus ←/→ choose · Enter confirm · mouse click
Phase 1 (cottage) WASD walk · E interact · Space wait (costs a segment!) · G spellbook · I satchel · L codex
Phase 2 (forest) click a hex to move · E node · Space wait · C camp · X brew/combine · T disarm trap · G spellbook · I satchel · L codex · F the war · B aid · D false trail (Nyxari) · R echo (Auralis)
Phase 3 (apex) 1 Press the Bond · 2 Grey Disruption · 3 Sever the Rite · 4 Invert · 5 Ward · G spellbook · I satchel
Anywhere in play Esc pause

The game renders at a fixed 1280×720 virtual resolution and scales crisply to any window or DPI (letterboxed, point-filtered pixels).


Plugging in an AI Master

By default the Master runs on the built-in, deterministic ACO brain (offline, reproducible). To hand him to a model instead:

export MS_MASTER_AGENT=llm                    # route decisions through the LM Studio bridge
export MS_LLM_MODEL="your-fast-instruct-model"
./build/bin/masters_shadow

Each Master decision is serialized to an honest, bounded observation plus the legal moves and sent through agent_engine/run.py to a local LM Studio server (http://127.0.0.1:1234/v1). The model returns a structured, validated choice via /no_think + OpenAI-style tool calling (one act(action, index, say) tool) — typically in well under a second even on a small local model — and its choice is re-validated against the current legal set. Any illegal, timed-out, or unavailable response falls back to the deterministic bloodhound heuristic, so the Master always acts and can never cheat.

Async by design: the LLM decision runs on a background thread while the ACO brain plays instantly, so the game never freezes regardless of model speed. The bridge uses only a model LM Studio already has resident (it never force-loads → no memory blow-ups). A raw frontier endpoint works with MS_MASTER_AGENT=openai + MS_LLM_URL.

Auth: newer LM Studio builds require an API token — set MS_LLM_KEY or drop the token in lmstudio-api-key.txt at the repo root; every C++ and Python path sends it as a Bearer header automatically.

Full protocol, observation/action schemas, and env knobs are in docs/API.md.


Testing & Balance

./build/bin/ms_tests                    # 51 unit tests (core, world, AI, apex, content)
./build/bin/ms_headless --selftest      # replay determinism check (identical state hashes)
./build/bin/ms_headless --balance 500   # Monte-Carlo Phase-2 pursuit sweep (win/loss rates)
./build/bin/ms_headless --apex 600      # per-lineage Phase-3 clash balance sweep

The determinism guarantee — (seed, lineage, action-journal) → identical state hash — is enforced by the RulesEngine and verified in tests. The pursuit sweep confirms the Master is a real but beatable threat (~22% catch against an optimal fugitive); the apex sweep confirms every bloodline can win and lose the ritual — tuned FTL-flagship hard, a tight 47–67% band under a naive policy with real preparation making the difference. One command runs everything: bash tools/verify.sh.


Architecture

Three layers plus data — the deterministic simulation is completely decoupled from raylib so it can be unit-tested and self-played headlessly.

ms_core (deterministic, no raylib)         masters_shadow (raylib presentation)
├─ core/   RNG, hex math, skill checks,     ├─ render/  2.5D scene, sprites, lighting
│          RulesEngine + replay journal,    ├─ ui/      HUD, ritual dial, panels
│          ContentDB                        ├─ audio/   layered music, SFX, ambience
├─ world/  hex map, flow fields, A*,        └─ game/    screen flow: Title → Lineage
│          procedural forest, faction war              → Phase1 → Phase2 → Phase3 → End
├─ ai/     Master brain (ACO), pursuit sim
├─ magic/  lineages, ritual apex, endings   agent_engine/  standalone Python bridge to
├─ agent/  IAgent + ACO / LLM adapters                     LM Studio (SSE + REST tools)
└─ save/   serialize state + journal        data/  git-native ContentDB records

Full module map, data flow, and the determinism model are in docs/ARCHITECTURE.md.


Data-Driven by Design (OKF)

As little as possible is hardcoded. Following an Open-Knowledge-Format (OKF) convention, the game’s rules tables, tuning numbers, and every user-facing word live in a git-native data/ bundle of plain-markdown records. The branching logic stays in ms_core; the values and text it runs on are externalized, inspectable, and hot-swappable.

  • Determinism-safe injection. Core tables — enemy stats, the six lineages, chaos weights, terrain costs, tuning knobs — are parsed in the app layer and handed to the raylib-free core as frozen integer POD structs before the first turn, so the simulation stays I/O-free and its state_hash is provably unchanged. Floats are stored as per-mille integers (1.25 → 1250) because std::stod isn’t byte-stable across libc, and a float leak would break the hash.
  • All text is data. ~364 UI and narrative strings resolve through a tr(id, fallback) lookup against data/strings/ — the game can be re-skinned or translated without touching a line of C++.
  • A conformance gate. python tools/okf_validate.py enforces the record grammar, unique ids, and the integer-only rule for core-consumed tables; python tools/gen_okf_index.py maintains an index.md + log.md per bundle.
  • A condensed design corpus. The full design — concept, systems, the AI antagonist, the world, the arc — is itself a cross-linked OKF bundle: design/okf/.

Authoring guide: docs/MODDING.md.

Tech Stack

  • Engine: C++17 + raylib (fetched via CMake FetchContent), MinGW-w64 / MSVC, statically linked.
  • Testing: doctest; a headless ms_headless runner for determinism and Monte-Carlo balance.
  • Art: procedural pixel art in Python + Pillow (tools/gen_*.py, tools/msart.py).
  • Audio: generated via the ElevenLabs API (voice, music, SFX, ambience), cached by content hash, bundled at ship.
  • AI Master: deterministic ACO pathfinder + a pluggable LLM bridge (agent_engine/) to LM Studio / OpenAI-compatible endpoints.
  • Fonts: Orbitron, IM Fell English, Share Tech Mono (SIL OFL).

The Master AI — Ant-Colony Optimisation

Master Malacor is not a game-over timer or an omniscient threat. He is a rule-bound autonomous agent running on the same deterministic Clockwork Dark engine as the player, subject to the same world friction: every action is a legal engine call (move_to_node, resolve_skill_check, craft_item, forage/rest), governed so he cannot cheat or hallucinate outcomes. He spends Food, Energy and Mana, is slowed by hazardous nodes, and can be outlasted if he burns resources recklessly. His edge is better starting knowledge and stats; the player’s counter is that this knowledge is imperfect and corruptible.

The ACO model

Ant-Colony Optimisation (ACO) is a swarm metaheuristic: artificial ants lay pheromone on good paths, pheromone evaporates to allow adaptation, and each next-step choice combines pheromone (τ, weight alpha) with a heuristic (η, weight beta) via seeded roulette-wheel selection. ACO gives the Master and crowds a memory of good routes that A* alone lacks, and adapts naturally to changing traps, trace and reputation.

Hex adaptation: pheromone is stored per directed edge between cube-coordinate hexes (the 6 neighbours). Heuristic η = 1 / movement_cost, where movement_cost already folds in terrain, traps, trace and reputation. All random choices are seeded with game_seed + state_hash, so ACO is fully deterministic. See World & Flow Fields for the hybrid: ACO is the strategic layer that emits a temporary flow field, blended (weight ~0.7) with the global flow field; flow fields + local steering handle cheap real-time crowd movement.

Recommended parameters (per context):

Context ants iters alpha beta evap Q
Master Bloodhound pursuit 15 25 1.2 2.5 0.08 1.0
Master Architect planning 25 40 1.0 2.0 0.12 0.8
Loyalist patrol swarms 10 15 0.8 3.0 0.15 0.6
Rebel groups 12 20 1.0 1.8 0.10 0.7
Monster swarms 8 12 1.5 1.5 0.20 0.5

Tracking vectors

The Master maintains an internal world model from state flags, imperfect and degrading with time/distance:

  1. Sympathy Trace (primary). Black spells (Drain Life, Shadow Step, Bind, Summon Familiar), heavy Black ingredients (Nightshade, Mandrake Root, Sympathy Ash) and failed Black alchemy add trace. Trace decays ~1–3 per node moved (faster with White magic, rain, Shimmering Lake). Bands drive behaviour:

    Trace Master behaviour Player-visible Counters
    Low (0–20) Normal patrol / Architect none
    Med (21–50) Bloodhound chance ↑ raven cries, scorched nodes Grey masking, Decoy, weather
    High (51–80) Locks onto path, faster dead NPCs, stripped nodes on trail White trace-purge, false trails
    Critical (81+) Aggressive pursuit, trackers projection appears, Loyalists converge Shadow Cloak + high Grey, sabotage
  2. Last-known position + path history (from Raven Familiar, Loyalist Trackers, direct sight). Grey magic — Invisibility, Muffle, Decoy, high Concealment, Shadow Cloak — breaks or falsifies it.

  3. Reputation & world flags (secondary): Loyalist reputation drives patrol/trap density; bribed NPCs may feed him false intel; helping/harming key characters sets persistent readable flags.

Pursuit strategies

The Master switches mode dynamically on trace, resources, last-known position, and predicted intent:

  • Bloodhound (reactive). Trigger: medium+ trace, recent sighting, or obvious Black magic. Runs the player’s exact recent path, spends Mana on Haste, strips resources behind them, prioritises closing distance. Countered by Haste, false trails, weather (Sudden Storm doubles all costs), or leading him into monster nodes.
  • Architect (predictive). Trigger: low trace but the player is heading to a chokepoint (Boundary Gate, Rebel Encampment, Smuggler’s Cove) or is patterned. Diverges ahead, lays snares, bribes guards (Gorn, Vex, Captain Thorne), sets ambushes. Countered by breaking patterns, varying routes, counter-intel (Divination Mirror), or sabotaging his forward assets.
  • Opportunistic / Idle. Casts about the strongest scent when neither loud nor cleanly predictable; Idle covers forage/rest.

He can switch mid-chase if trace spikes or he loses the trail.

Learning: memory decay & protection

The Master keeps a separate pheromone map of the player (observed spell chains, escape routes, alchemy habits, trap responses) that steers strategy, trap placement and spell priority. It is a learning but fallible memory that decays under pressure, so the player must keep adapting.

  • Decay base_rate = 0.06 (slower than player-side magical pheromone; he is disciplined). Multipliers: Ritual Clock 1.0 + stage*0.35; player trace 1.0 + trace/120; low Mana ×1.3; recent failed counter ×1.2; heavy recent Grey ×1.4; strong Water-Spirit reputation ×1.15; time-since-observed up to ×1.8. Capped at 0.28; floor 0.005.
  • Protection (else Grey trivialises him): Ritual-Clock hardening (1.0 − stage*0.12, up to ~36% slower late), extra ×0.7 on recently-used patterns, importance weighting, resource-based resilience (stronger >60% Mana), and active tools — Trace-Burn, Clarity Projection, Pheromone Anchor, surveillance agents. Targeted Memory Fog he may detect and reinforce against, a cat-and-mouse mini-game.
  • Player disruptor — Memory Fog (Grey). Applies a decay boost 0.18 + grey_level*0.03 (×1.5 targeted) to his player-model over 4–8 nodes (10–12 with alchemy); ~+0.5 clock at stage ≥2; recent-use penalty ×0.5 within 8 turns to stop spam.

Net arc: early memory strong and teaching; mid older observations erode, he can be re-fooled; late desperation (clock) both accelerates his forgetting and hardens his prized recent counters, so he grows more brute-force and predictable — windows for skilled play.

The unified ACO loop (player + Master)

ACO also drives spell-sequence selection for both sides. Each “ant” runs a short simulated cast chain (≤4 spells); transition probability P = τ^alpha · η^beta · clock_pressure^gamma · reputation_mod^delta, normalised (uniform fallback if none viable). The heuristic scores Mana efficiency, trace delta (heavy penalty for Black when trace >60–70; small reward for controlled Black when low), affinity match, alchemy synergy (potions as temporary pheromone boosters), and tactical context (near-traps favours Haste/Shadow Step/Decoy; low health favours Mend/Ward; density favours Bind/Sleep). Successful chains deposit pheromone, so tactics that work become reliable — but also predictable.

Ritual-Clock tuning ramps parameters by stage: alpha 1.0→1.5, beta 2.5→3.5, evaporation 0.10→0.30, with rising Black penalty and survival bias. The Master’s loop uses higher alpha (1.4–1.6, leans on learned patterns), aggressive elitist reinforcement, and grows more efficient and less exploratory as the clock advances.

Phase carry-over. Phase 1 choices seed Phase 2 pheromone: reputation flags become initial pheromone biases; Grey progress → stronger stealth-pair pheromone and better decoys; heavy Black → higher starting trace; ritual sabotage → lower starting clock and persistent low-cost corridors.

In the build

The design is realised, though the shipped C++ core is a deliberately lean, deterministic subset of the sprawling design above — not the full multi-map ACO/LLM stack.

  • src/ai/master_ai.hpp / master_ai.cppMasterBrain. A single scent (pheromone) map unordered_map<Hex,float> with observe_player (deposit kDeposit=1.0 on sighting), decay (evaporation kEvap=0.15, prune <0.02), and strongest_scent with a deterministic hex-order tiebreak. decide() implements the strategy FSM: forage if food<25 or energy<30; else Bloodhound if trace≥50 or seen within 1 turn, Architect (interception via a_star 3 hexes ahead toward the gate) if trace<30 with a fix, else Opportunistic on strongest scent — then steps via a flow field to the goal. poison_scent(h, amt, steal_fix) is the Nyxari false-trail hook that can steal his fix.
  • MasterStrategy enum lives in src/core/types.hpp: Idle, Opportunistic, Bloodhound, Architect.
  • src/ai/pursuit_sim.cppsimulate_pursuit proves the Master is rule-bound and can win or lose: a seeded headless chase (greedy player vs a Master IAgent), used by tests and balance sweeps.
  • agent_engine/master_agent.py — the pluggable external-agent bridge. The Master can be driven by an LLM via LM Studio (lmstudio_client.py, OpenAI-style tool calling, /no_think), choosing among only legal moves through a single act(action, index, say) tool — he still cannot cheat. It uses a loaded-only model (never force-loads) and falls back to a deterministic bloodhound heuristic() when no model is resident, the call fails, or MS_AGENT_HEURISTIC=1. This is the “CosySim” agent seam referenced in project history.

Note: sources differ on decay/trace constants vs. the build. Design docs specify player-model decay base=0.06, evaporation ~0.08–0.30, and trace bands with a slow 1–3/node fade; the shipped MasterBrain uses a single scent map with fixed kEvap=0.15, kDeposit=1.0, and integer thresholds kTraceBloodhound=50 / kTraceArchitect=30. The build values are canonical for the current implementation; the finer per-mille/per-affinity model and the separate player-model pheromone map are design-only / not yet in the C++ core.

Note: the design describes multiple pheromone maps, full ACO spell-sequence search, and Phase-3 action scoring (stabilize_flow / pressure_bond / deploy_distortion / counter_disruption). The GDD claims these; the current C++ MasterBrain implements movement/pursuit ACO only — spell-chain ACO and the multi-map planner remain design-level.

World, Flow Fields & Crowd Simulation

Phase 2 (The Forest Pursuit) and Phase 3 (The Ritual Apex) play out on a procedural hex-grid forest governed by flow fields. The same map, movement costs, and pathfinding serve the player, the Master, faction warbands, and monsters — everything is deterministic given seed plus state, honoring the Clockwork Dark philosophy. See Phases, Master AI, and Factions.

Hex Coordinate System

All spatial logic uses cube coordinates (q, r, s) with the invariant q + r + s = 0 (stored as axial q, r; sparse unordered_map<Hex, Tile>). This makes the six neighbors, distance, and A* heuristics clean:

  • Distance = max(|Δq|, |Δr|, |Δs|) — admissible and consistent, so A* is optimal.
  • Neighbors = the 6 cube deltas (vs. 4/8 on square grids), giving smoother turns, better packing, and no diagonal-vs-cardinal cost inconsistency.
  • Deterministic tie-breaking uses hex_key() (a stable packing of q,r) so priority-queue order is reproducible.

Terrain & Movement Cost

Each Tile carries a Terrain, an optional special-location node, a trap tier, a float distortion, and fog-of-war flags (discovered, visible). Movement cost entering a hex = terrain base cost + distortion + trap penalty (3 × trap tier). Water is costly, not blocking — nothing is truly impassable, only expensive.

Terrain base costs (per-mille integers, X/1000, so the sim stays byte-stable):

Terrain cost Terrain cost
Path 0.70 Deep Woods 1.60
Grass / Clearing 1.00 Rock 1.80
Forest 1.20 Swamp 2.00
Ritual 1.30 Water 3.00

Note: the pathfinding design docs described costs as base 1.0 + terrain modifier (e.g. “Deep Woods 1.5, Spore Caves 2.0”); the build instead uses absolute per-terrain costs (above). Spore Caves is a node type placed onto Swamp terrain (2.0), not a distinct terrain.

Procedural Forest Generation

generate_forest(seed, radius=6) builds a hex disc deterministically:

  1. Terrain — two layers of fractal value noise (fBm, 4 octaves) drive an elevation field and a moisture field; a classify() threshold table maps them: elevation > 0.74 → Rock, > 0.60 → Deep Woods, low elevation + high moisture → Water/Swamp, very low → Grass, else Forest.
  2. Endpoints — the start clearing sits on the west edge (-radius+1, 0); the Boundary Gate (the winnable exit) on the east edge (radius-1, 0), set to Ritual terrain.
  3. Node placement — candidate hexes (excluding a 2-hex radius around start/gate) are sorted for a stable base order, then Fisher-Yates shuffled with the seeded RNG, and greedily placed with a minimum spacing of 2 hexes. Some node types stamp their terrain (Lake/Spring → Water, Ritual Scar → Ritual, Spore Caves → Swamp, High Ground → Rock).
  4. Reveal — hexes within distance 2 of start begin discovered.

Node types (18): FairyRing, WhisperingWell, GoblinMarket, TrollBridge, RebelCamp, LoyalistOutpost, AncientShrine, ShimmeringLake, SporeCaves, AbandonedMill, SmugglersCove, BoundaryGate, plus the six high-value contested nodesLey Line Nexus, Fae-Touched Grove, Corrupted Ritual Scar, Water Spirit Spring, Hidden Rebel Cache, High Ground Overlook — which offer strong rewards but draw agents, backlash, and flow distortion.

The design canon frames this as a hybrid PCG pipeline (Curious Expedition 2-inspired): Noise for organic terrain, plus WFC/template-and-prefab structure for special nodes and a guaranteed start→Gate route, then state-driven post-processing (high trace spawns more hazards; high Rebel reputation adds friendly nodes/hidden routes; advanced Ritual Clock fortifies chokepoints). The survey considered BSP, cellular automata, drunkard’s walk, DLA, Voronoi, and WFC before settling on Noise+templates.

Note: WFC and full state-driven node re-weighting are design canon but not in the current build — the build uses fBm noise + seeded shuffle + spacing. Terrain classification and node roster are implemented.

Hex Pathfinding (A*)

a_star(map, start, goal) is standard A* on the hex grid: heuristic = true hex distance, cost = HexMap::movement_cost, deterministic tie-breaking, returns the inclusive path or empty if unreachable. The player’s greedy escape and the Master’s precise approach both call it, so both feel the same world friction (traps, distortion, terrain). Design notes call out optional variants (Dijkstra for influence maps, greedy-weighted heuristic for Bloodhound haste, hierarchical/JPS for very large maps) — the build ships plain A* plus the flow field below.

Flow Fields

A flow field is a Dijkstra flood-fill outward from one or more goals: for every reachable hex it stores the best next neighbor toward the goal and the accumulated cost. build_flow_field(map, goal) (or a vector of goals) drives crowd/warband movement and the Master’s coordinated pursuit — far cheaper than per-agent A* when many agents share few goals, and agents naturally spread out and route around cost. Multi-goal builds seed every goal at cost 0, enabling attraction (toward the player’s last-known position or a chokepoint).

Blended attraction + repulsion (design canon): compute an attraction field and a repulsion field (from traps, high-trace zones, hostile reputation, player decoys), convert each to a direction vector, and blend attr × 1.0 − rep × 0.6, snapping to the best legal neighbor. This is the mechanism behind Decoy/Illusion pulling crowds and Grey magic (Invisibility, Muffle) letting the player slip between flow lines.

Note: the build implements single- and multi-goal attraction flow fields; repulsion blending and per-agent Boids steering weights are design-only so far.

Flow Distortion & Falloff Curves

Both player and Master can distort the field — raising movement cost in an area to deny it. apply_distortion(map, center, strength, radius, type) adds a falloff-scaled value to each tile’s distortion within the radius. Four falloff curves (Falloff enum):

  • Quadraticstrength / (1 + dist² / radius²): smooth, long tail.
  • Gaussianstrength · exp(−dist² / (2σ²)), σ = radius/2: soft bell.
  • Exponentialstrength · exp(−2·(dist/radius)): sharp decay.
  • Stepped — banded: full strength inside 34% of radius, ×0.5 to 67%, ×0.2 to the edge, 0 beyond.

Design canon adds dynamic falloff modifiers — the curve/strength responds to lineage (e.g. Nyxari, “The Veiled” excel at misdirection and flow control), Trace Parasite, Ritual Clock stage, and whether the Master is actively contesting. In Phase 3 the fields become violently chaotic. The Master’s ACO planner weighs flow_denial against contest_resources and direct_pursuit. See Master AI.

Crowd & Warband Simulation

Faction warbands are mobile forces on the hex grid (FactionWarSim). Each turn a band finds its nearest hostile enemy, builds a flow field to it, and steps one hex along it; adjacent hostile bands then clash — resolution is d20 + strength/8 per side, loser takes 3 + |Δroll| damage; a band at strength 0 is routed. Survivors hold territory (hex → controlling faction). The war axis is Rebels vs Loyalists, with Trolls and Goblins hostile to Loyalists for a clean three-way; Fae/Villagers stay out. The player can reinforce a faction (capped at strength 60), tipping the balance. See Factions.

The full design vision layers Boids-style local steering (separation / alignment / cohesion + obstacle avoidance) on the global flow field, with per-faction weight templates — Loyalists: high alignment/goal-focus (patrols); Rebels: high cohesion, avoid high-trace; Monsters: loose, drawn to player trace; Master’s trackers: very goal-driven, trap-smart. The GDD also describes a lightweight movement physics layer (velocity, mass, flow forces) where Trace Parasite raises effective mass. These steering/physics layers are design-only; the build’s warband sim uses flow-field marching + deterministic clash resolution.

In the build

  • Terrain tabledata/world/terrain.md (cost_milli, encounter_base, per-mille ints), injected into ms_core via content_tables.cppgame_tables().terrain, consumed by src/world/hexmap.cpp (terrain_base_cost, movement_cost).
  • Hex map & coordssrc/world/hexmap.hpp/.cpp, src/core/hex.* (distance, neighbors, hex_key).
  • Procedural gensrc/world/forestgen.hpp/.cpp (fBm noise, node roster, seeded placement).
  • Pathfindingsrc/world/pathfind.hpp/.cpp (A*).
  • Flow fields & distortionsrc/world/flowfield.hpp/.cpp (Dijkstra field, Falloff curves, apply_distortion).
  • Warband crowd simsrc/world/faction_war.hpp/.cpp.
  • Headless pursuitsrc/ai/pursuit_sim.cpp drives a greedy player vs. a Master IAgent on a generated forest (Bloodhound “haste” = a second step when the Master has a fresh fix and mana), proving the rule-bound Master can win or lose; used by tests and --balance.

THE MASTER’S SHADOW — Technical Architecture

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.


1. The Three-Layer + Data Design

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.

1.1 ms_core — the raylib-free deterministic simulation

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.

1.2 masters_shadow — the presentation executable

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 submits Actions to change anything. It never reaches into GameState fields to write them (the one sanctioned exception, RulesEngine::mutable_state(), is documented as “setup / scripted world building only” and used to place units and seed carryover pools at phase entry).

1.3 The agent layer

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.

1.4 The content layer (data/)

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 data/ tree is organized by type:

data/
├── lineages/   (six bloodlines)
├── spells/     (fifteen spells)
├── enemies/    (eight enemies)
├── events/     (twenty events)
├── chaos/      (nine Phase-3 chaos events)
├── endings/    (eight endings)
└── items/      (sixteen items)

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. Because content is plain text, it is diff-friendly, moddable, and requires no recompile to tune — numeric balance constants that are truly global live in constants.hpp, while per-content values (spell costs, puzzle DCs, ingredient tags) live in data/.


2. The Determinism Model

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.

2.1 One authoritative RNG stream

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.

2.2 The replay journal

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.

2.3 state_hash — the fingerprint

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 flags is a std::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.

2.4 Why war_rng_ is a separate side-stream

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.cpp

Seeding 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 from war_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.


3. Module Map

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).


4. Action-Based Data Flow

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└──────────────────────┘

  1. A screen builds an Action describing intent — e.g. Action::move(dir), Action::end_turn(), or a hand-filled CastSpell / Forage / CounterRitual.
  2. RulesEngine::apply validates it via is_legal. Illegal actions are rejected without touching state (Move needs energy, CastSpell needs mana ≥ cost, an ended game only accepts Noop). This is the same gate the Master’s actions pass through — the agent literally cannot submit a move the player couldn’t.
  3. dispatch applies the effect, drawing any randomness from rng_ and mutating GameState only 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 in constants.hpp, so state can never drift out of bounds.
  4. The action is journaled and side effects are announced on the EventBus (Ev::Moved, Ev::SkillChecked, Ev::ClockAdvanced, …). The EventBus is a one-way notification channel for the presentation layer — HUD flashes, toasts, audio cues. It carries no authority; it cannot change state.
  5. The screen reads back engine->state() (const) to redraw, and inspects the returned ApplyResult (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.


5. The Fixed 1280×720 Virtual-Resolution Render Pipeline

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.

5.1 Render-to-texture, then letterbox

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:

  1. 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-wide Viewport singleton:

    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;
    
  2. Render the world into the target at native virtual resolution (BeginTextureMode(target)screen_->draw() → optional pause overlay → EndTextureMode()).

  3. 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.

5.2 virtual_mouse() closes the loop

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.

5.3 Assets

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.


6. The Pluggable Master Agent

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.

  • AgentObs hands the agent a MasterView and the pre-computed list of legal_moves (walkable neighbours). The header note is blunt about intent: this is “what keeps a plugged-in LLM restricted just like the player.”
  • AgentAction is one of Move (to a target hex), Forage, or Rest, plus an optional in-character narration line 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.


7. Build Targets and the Headless Harness

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.

7.1 ms_headless — self-test, balance, apex

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 two state_hash fingerprints match. This is the determinism guarantee reduced to a single PASS/FAIL:

    selftest seed=12345 turns=40  h1=…  h2=…  PASS
    
    

    The 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 decide RNG 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 via simulate_pursuit, tallying apprentice-escaped / Master-caught / stalemate across N seeds. 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 mirroring Phase3Screen::resolve_round (bond contribution, the nine chaos events, ritual inversion, ending determination). It flags any lineage as ONE-SIDED if 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.

7.2 Why the split pays off

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.


Appendix — Key State Meters

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.

The Master’s Shadow — Game Design Document

An apprentice is bound to an ancient wizard by a half-finished sympathetic ritual. Before the last thread is tied, the Master intends to pour his mind into the apprentice’s body and wear it out of the wood. You have until then to run, unmake the rite, or take his crown for yourself.

Genre: gothic narrative roguelike · single-player · turn-based Engine: C++17 + raylib (deterministic ms_core static library; raylib-free simulation) Structure: three acts, one continuous run, eight endings, six asymmetric bloodlines Audience: players who want a tense cat-and-mouse story with roguelike replay and a genuinely thinking antagonist


1. Vision & Design Pillars

Pillar 1 — The antagonist is a real agent, bound by the same rules, and able to fail.

This is the load-bearing pillar; everything else hangs from it. Master Malacor is not a scripted difficulty curve wearing a robe. In Phase 2 he is a pluggable decision-maker (IAgent, src/agent/agent.hpp) driven each turn by an honest, bounded observation of the world — MasterView in src/ai/master_ai.hpp gives him the map, his own resources, the player’s last known position, and the current trace. He does not see through walls, he does not know where you are when he has lost your scent, and he cannot cheat: every action he chooses is re-validated against the same legal-move set the engine would grant any unit before it is applied.

He can also lose in ordinary ways. If his Food drops below 25 or his Energy below 30 (kMasterForageFood / kMasterForageEnergy), he stops hunting to forage — a window you can exploit. His pheromone scent map evaporates at 15% per turn (kEvap), so a cold trail genuinely goes cold. He commits to one of three strategies based on how loud you are, and each strategy has a counter. Across a large balance sweep the default brain catches the player only about 22% of the time (ms_headless --balance), and the harness explicitly asserts that both outcomes occur — a Master who always won or always lost would be flagged UNBALANCED.

The default brain is a deterministic Ant-Colony-Optimization (ACO) pheromone tracker (MasterBrain, src/ai/master_ai.cpp). But the same seam accepts a large language model: set MS_MASTER_AGENT=llm (or cosysim) and the Master’s turns are decided by a local model through agent_engine/run.py, asynchronously, with the ACO brain playing instantly as a fallback so a slow model never freezes the game (BridgeAgent, src/agent/bridge_agent.cpp). Whether the mind behind the Master is ant-math or a neural net, it plays by your rules.

Pillar 2 — Three games, one continuity of state.

Each act is a different kind of game. Phase 1 is a room-based stealth/social puzzle; Phase 2 is a survival-and-evasion overworld roguelike; Phase 3 is a turn-based duel of wills. They do not merely follow one another — they inherit one another. A single GameState (src/core/game_state.hpp) carries every meter forward, so how you survived captivity literally becomes your starting hand in the flight, and how you fled becomes your footing in the final clash. The game never resets you; it compounds you.

Pillar 3 — Asymmetry over symmetry.

Six bloodlines are not six stat spreads. Each plays a different game — a different verb, a different key on the keyboard, a different failure mode. The Veiled lay false trails; the Echoes copy the Master’s last move back at him; the Binders drink the pain they take and turn the whole rite inside-out. Balance here does not mean sameness. It means every bloodline can win and lose the apex — the sweep confirms a 40–78% apex win band across all six, and asserts BALANCED (every bloodline can win and lose).

Pillar 4 — Determinism as a design tool, not just a feature.

The simulation is a pure function of (seed, lineage, journal). One RNG stream lives in the RulesEngine; every action is appended to a replay journal; replaying that journal reproduces an identical state_hash. This is not academic. It makes the game auditable (the Master provably cannot cheat), balanceable (thousands of headless runs are reproducible), and fair (a given trail of choices always yields the same world). Determinism is how we keep a promise.

Pillar 5 — Corruption is a currency, not a health bar.

Power is bought with self. Black magic is the strongest tool in the game and it feeds the Trace Parasite; the sympathetic bond that lets a Veyra hijack the Master’s mind is the same bond that bleeds their identity into echo. The player is always spending the very thing they are trying to save. Every corruption meter is a Faustian ledger.


2. The Three-Act Structure

The player is one apprentice across one unbroken run. The Ritual Clock (0–100, kClockMax) is the spine that threads all three acts: it is the doom timer, and it never stops climbing.

Phase 1 — The Apprentice’s Cage Phase 2 — The Forest Pursuit Phase 3 — The Ritual Apex
Phase enum Captivity Flight Apex
Genre room-based stealth / social puzzle survival + evasion roguelike overworld turn-based bond duel
Space open cottage + clearing + forest edge procedural hex forest, flow fields the Heart of the Ritual
The Master scripted routine; timed, announced absences live ACO/LLM agent hunting you desperate climax adversary
Core verb observe, chore, sabotage, don’t get caught forage, evade, mislead, survive clash, disrupt, invert, decide
Primary meter Suspicion (0–10) Sympathy Trace (0–100) Ritual Clock stage + Bond clash
Exit one of four escapes reach the Boundary Gate one of eight endings

Phase 1 — The Apprentice’s Cage

An open cottage, explored Sierra-style: Main Room, Your Room, Master’s Room, Study, Library, Kitchen, Cellar, Clearing, Forest Edge. There is no escape-plan menu handed to you — the loop is chore-driven. You complete the tasks Malacor sets, and in the gaps you watch him, secretly learn magic, and pick at the ritual’s foundations, all while a Suspicion meter (0–10) breathes down your neck.

Suspicion has real teeth at each tier (src/core/constants.hpp): 3–5 caution, 6–8 alert (the study locks, hidden compartments relocate), 9–10 paranoid (the study is sealed and the clock accelerates). The Master leaves on announced, consistently-timed errands — a departure line tells you the errand and roughly how long he’ll be gone, and a live clock counts down “~N turns till he returns.” Absence length is deterministic per destination (village 3 / stones 5 / deep wood 6 / old ruin 7 segments, ±1 journaled jitter). Come back too greedy and the boom lands: “YOU CANNOT LEAVE.” Dice are rolled on demand and shown to the player — a d20 skill check (src/core/skill_check.hpp) with a four-outcome band (Major/Minor Failure, Success, Critical at DC+8 or a natural 20).

Phase 1 ends with one of four escape strategies (Phase1Screen::try_escape), each a Stealth check against a different DC, each carrying different consequences into the wood:

Escape DC Note
Stealthy & Prepared 13 +1 modifier; the clean exit
Distraction-Heavy 15 +1 modifier; noisier but flexible
Aggressive High-Value Theft 17 no modifier bonus — you’re going for the ritual knife
Desperate 13 the raw dash when the walls are closing

Phase 2 — The Forest Pursuit

A Curious Expedition-style survival march across a procedurally generated hex forest with flow fields. The economy is the game: Food, Water, Energy, Mana, and Health (Resources), all draining as you move. Time is rationed as a 7-move-per-day budget (kMovesPerDay = 7); spend it and you must make camp ([C]) to eat, drink, rest, mend, and roll the next day and its weather (Clear / Rain / Storm / Anomaly). Nowhere to be, no camp, no food — you starve or die of thirst.

The overworld is dense with systems that interlock (§4): resource nodes with per-type panels (drink / attune / barter / fae-bargain / parley / harvest) and depletion/regrowth; alchemy ([X]) that converts foraged reagents into a Masking Draught (purge Trace & parasite), Trail Ration, or Stimulant; proc-gen combat against eight biome-keyed foes; and faction wars — mobile warbands contesting territory that you can tip with aid ([F] / [B]). Over all of it, the Master hunts. Reach the Boundary Gate to the east and you cross into the apex.

Phase 3 — The Ritual Apex

At the Heart of the Ritual the flow fields go violently chaotic and the game becomes a turn-based clash of wills (src/magic/apex.cpp). The Ritual Clock’s four stages (clock_stage(): Preparation < 25, Alignment, Convergence ≥ 50, Possession ≥ 75) now measure how close Malacor is to owning your body. Three things resolve the ending:

  1. The Sympathetic Bond Clash — a per-turn contested score. You contribute via player_clash_contribution (base 5 + lineage bonus + action quality + magic affinity, dragged down by echo bleed and parasite, lifted by bond for Veyra and by deep corruption for Dravenblood). He contributes via master_clash_contribution (resource + chaos bonus, blunted against Dravenblood and Sylvara).
  2. Chaos Events — one of nine (Chaos enum): seven core plus two lineage-flavoured (Wild Resurgence for nature-blood, Echo Storm for echo-blood). The storm is biased by your bloodline — Nyxari calms it, Dravenblood courts surges, Sylvara calls the wild.
  3. Ritual Inversion — the high-risk turn where you stop trying to escape the rite and try to reverse it, pouring him into the vessel he prepared and being already there, stronger. Requires 5–7 assembled disruption components and a roll against a DC that rises with echo bleed (inversion_dc: 16 → 18 → 20 → auto-fail at Consumed).

The final state runs through determine_ending — a priority tree over inversion level, bond outcome, clock, echo, parasite, and lineage — yielding one of eight endings (§8).


3. The Core Systems and How They Interlock

The Ritual Doom-Clock (0–100)

The single global timer, four stages. Everything urgent pushes it. In Phase 1 paranoid suspicion accelerates it; a Master ritual reaching Convergence can force Phase 2 (kClockPhase2Trigger = 51); in Phase 3 it is the danger — cross 90 and Tragic Possession looms even if you were winning the clash. It is the drum the whole game marches to.

The Sympathetic Bond (0–100)

The connection that powers the rite and the battleground where it ends. It is dormant instrumentation for most bloodlines and the central resource for Veyra, who start at bond 30 (bond_start) while everyone else starts at 15/20. High bond is double-edged: it lets Veyra press the clash harder (+min(2, bond/40)) and arms a one-shot Phase-3 hijack at bond ≥ 40 (clash_hijack_bond), but bond is also what carries the Master’s mind toward yours.

The Corruption Axis — Trace, Echo, Parasite

Three meters, one theme: power costs self.

  • Sympathy Trace (0–100) — the Master’s primary tracking vector. Loud trails (21–50) raise Bloodhound odds; 51–80 locks paths and accelerates; 81+ brings aggressive pursuit and trackers. Trace is how he finds you; managing it is the core Phase-2 tension.
  • Echo Bleed (0–100) — identity erosion from sympathetic exposure and Black magic. Whispers at 21, intrusion at 41 (the Master’s ACO starts suggesting his preferred actions to you), dominion at 61, and Consumed at 81 — inversion auto-fails and the self is lost. Echo directly raises the inversion DC and drags the clash score.
  • Trace Parasite (0–100) — a parasitic evolution of the bond (Aggressive 40 / Parasitic 70). It is fed by Black-magic casts (kParasitePerBlackCast = 5, ×lineage parasite_mult) and decays 1/turn when starved. For most it is pure drag on the clash; for Dravenblood it is a boon — deep parasitic corruption adds to their clash score, and a body already colonised by the parasite is a “spoiled vessel” the Master cannot cleanly possess (a Mutual-Destruction path).

The axis is what makes Black magic a devil’s bargain: it is the most powerful path, and it feeds two of the three meters that can end you.

The Three Magic Colors — White / Grey / Black

Fifteen spells (data/spells.md), five per color, tiered 1–3 by mana and skill:

  • White — wards, mending, light, banishment, sanctuary (Ward, Mend Flesh, Candle of Truth, Banish Shade, Sanctuary). The Thalor path; clean, costs nothing to the self.
  • Grey — illusion, misdirection, silence, false-trail, veil (Hex, Phantom Double, Shroud of Silence, False Trail, Veil of Mist). The disruption toolkit; the escape-artist’s color.
  • Black — ember, drain, bind, rot, black-fire (Ember, Leeching Grasp, Shackle of Thorns, Creeping Rot, Black Fire). The strongest damage, and the color that feeds the parasite and bleeds the echo. The Dravenblood path — and a temptation for everyone.

The Six Lineages — asymmetric identities (§5 for full detail)

Each bloodline is a distinct verb, wired through LineageTraits (src/magic/lineage.cpp) — a pure function of lineage, deliberately kept out of GameState so extending it never touches the state hash. Bloodline touches Phase-1 skill starts, Phase-2 movement/forage/sensing signatures, and Phase-3 clash/inversion.

Faction Reputation & Wars

Six factions (Faction enum: Rebels, Loyalists, Goblins, Trolls, FaeWater, Villagers) with reputation from −100 to +100 and thresholds for hostile / distrust / trusted / allied. Beyond static reputation, the wood is alive: warbands (FactionWarSim, src/world/faction_war.hpp) march the hex grid, contest territory, and clash — a deterministic crowd-sim built on the same flow-field/ACO substrate as the Master. The player can reinforce a faction to tip a battle. Faction wars are an intentional extension beyond the original design corpus, layered on the existing simulation.

Weather

Clear / Rain / Storm / Anomaly (Weather enum), rolled per day at camp. Weather modifies encounter pressure and atmosphere — a live world field wired through Phase-2 movement.

Alchemy

Foraged reagents (item_ingredient flags) become brews at any camp via [X]: Masking Draught (2 reagents — purges Trace and recoils the parasite), Trail Ration (+food/+water), Stimulant (+energy/+mana, at a later cost). Alchemy is the crafted counter-play to the corruption axis — the one reliable way to pay down Trace mid-flight.


4. Progression & Carryover (Phase 1 → 2 → 3)

There is no level-up. Progression is carryover — the state you accrue is the build you play.

Phase 1 → Phase 2. The escape strategy and your surviving state seed the flight. On entry, Phase 2 tops up the pools shaped by how you left (Phase2Screen): Food max(30, +24), Water max(30, +20), Energy max(50, …), Mana max(20, +10). A prepared, low-suspicion exit is a very different opening hand from a desperate one. Skills learned in captivity (Observation, Experimentation for a scholar; Stealth for a ghost) persist as per-skill modifiers.

Phase 2 → Phase 3. Everything you did in the wood arrives at the Heart. Sabotage progress becomes ritual-disruption components toward inversion. Trace, echo, and parasite carry in directly as clash modifiers and inversion DCs. Bond (for Veyra) carries in as clash pressure and the hijack charge. The Ritual Clock you let climb sets your starting stage. ApexOutcome reads these carried fields verbatim — the apex is a reckoning of the whole run, not a fresh fight.

Because the entire chain is deterministic, a given sequence of choices always produces the same reckoning — which is exactly what lets us balance it.

6. The Master AI — a pluggable, honest adversary

The Master’s brain is swappable behind one interface (IAgent). The default is the deterministic ACO pheromone tracker (MasterBrain): it deposits scent where it sees you, evaporates it at 15%/turn, and runs a three-strategy FSM off how loud you are —

  • Bloodhound — loud trail (trace ≥ 50) or fresh sighting: run you down straight and fast.
  • Architect — quiet and predictable (trace < 30) with a fix: cut ahead and intercept on your likely route to the gate.
  • Opportunistic — otherwise: cast about on the strongest remaining scent.

Each strategy is legible and counterable — and a sensing bloodline can read which one he’s in (lineage_sense_line), turning his commitment into your information. Crucially, he obeys his own scarcity: low resources send him foraging instead of hunting.

The same seam accepts a local LLM (MS_MASTER_AGENT=llm|cosysim, via agent_engine/run.py to LM Studio / CosySim). The bridge runs asynchronously so the game never stalls, hands the model only the honest bounded observation, and re-validates every reply against current legal moves — an illegal or missing choice falls straight back to ACO. The antagonist can be a language model or a colony of ants; either way it is restricted exactly like the player.


7. Balance Philosophy

Determinism first. Because (seed, lineage, journal) → identical state_hash, balance is empirical and reproducible. ms_headless runs the whole thing at scale: --selftest proves determinism, --balance N sweeps the Phase-2 chase, --apex N sweeps the Phase-3 clash. There is no guesswork — we run thousands of deterministic games and read the distribution.

“Both outcomes must occur.” The harness does not target a number; it targets tension. Phase 2 is declared BALANCED only when the Master both catches and fails to catch (he lands around 22% — enough to be feared, rare enough to be beaten). Phase 3 is BALANCED only when every bloodline can both win and lose the apex — the observed band is 40–78% across the six. A build where any lineage always won, or always lost, is a bug the sweep catches.

Asymmetry, not sameness. We do not balance by flattening the bloodlines toward a mean. We balance by making each one’s strong line and failure mode different, then verifying the win bands overlap in a healthy range. Dravenblood’s ceiling is higher and its floor is lower than Thalor’s — and that is correct. The spread is the design.

Corruption is self-limiting. The strongest tools (Black magic, high bond, deep parasite) carry their own tax. Balance largely emerges from that Faustian pricing rather than from flat nerfs — the player is trusted to overreach, and the meters make them pay for it.

A determinism side-note: the main RulesEngine RNG is the single canonical stream that the state hash covers. The faction-war/encounter-gating side-stream (war_rng_, seeded seed ^ 0xBA771E) is deliberately separate so cosmetic world churn never perturbs the audited core state — a small but important discipline that keeps “the Master can’t cheat” provable.


8. Content Scope & Replayability

Content is authored as flat key: value records split by ---, loaded from data/ by the ContentDB (src/core/content_db.cpp). Current corpus:

Content Count File
Bloodlines 6 data/lineages.md
Spells (5 White / 5 Grey / 5 Black) 15 data/spells.md
Enemies (biome-keyed) 8 data/enemies.md
Map / wild events 20 data/events.md
Chaos events (7 core + 2 lineage) 9 data/chaos.md
Endings 8 data/endings.md
Items 16 data/items.md

The eight endings (Ending enum, resolved by determine_ending): True Freedom, The Rite Unmade (counter-ritual), The New Master (clean inversion), Half a Crown (partial inversion), The Fused Thing (corrupted inversion), Tragic Possession, Mutual Destruction, and Hidden in the Forest (Sylvara-only). Each ending further branches on a per-lineage coda — a closing line written for every bloodline (ending_epilogue), so the same ending lands differently for a Veyra than a Dravenblood.

Where replayability comes from. Not from grinding — from combinatorics of consequence:

  • Six asymmetric bloodlines × three acts, each a genuinely different verb-set.
  • Four escapes seeding four Phase-2 opening hands.
  • A procedural forest with nodes, weather, faction wars, and proc-gen combat — no two flights alike.
  • A live antagonist (ACO or LLM) whose strategy adapts to how you play, so the same map plays differently against a different you.
  • Eight endings × six codas — the reckoning at the Heart reflects the whole run, not a final button-press.

The result is a short-to-medium run you can replay for the how: the same wood, a different bloodline, a different Master, a different self spent to buy your way out — or your way into his throne.


9. Build & Verification Targets

Target Purpose
masters_shadow the game executable (raylib presentation over ms_core)
ms_tests 46 doctest unit tests (determinism, apex, content coverage)
ms_headless balance & determinism harness: --selftest, --balance N, --apex N

The ms_core simulation library is raylib-free and fully testable in isolation — the deterministic heart can be verified without ever opening a window. That separation is what makes the promises in this document checkable rather than merely stated.


The Master is patient. The clock is not. Choose your blood, and choose what you are willing to spend.

The Master’s Shadow — Integration API Reference

“He is bound by the same wood, the same hunger, the same rules. That is the only mercy the ritual grants you.”

This document is the integration reference for embedders and modders. It covers four seams:

  1. The Agent protocol — the IAgent interface that lets any brain (deterministic or neural) drive Master Malacor during the Phase-2 Forest Pursuit, and the honest, bounded observation that keeps it restricted just like the player.
  2. Driving the Master with an LLM — the environment-variable reference, the agent_engine bridge protocol, the asynchronous decision model, and the validation plus ACO-fallback guarantees that forbid cheating.
  3. The ContentDB data format — the flat key: value record grammar under data/, the --- separators, and the Record / ContentDB getters.
  4. The engine Action API — the ActionType taxonomy and the apply / journal contract underpinning determinism and replay.

All signatures below are copied verbatim from the headers. Cited files are relative to the repository root.


1. The Agent Protocol

Header: src/agent/agent.hpp · src/ai/master_ai.hpp

During the Forest Pursuit, the Master is not scripted — he is a pluggable decision-maker. Every turn the engine hands the active agent a bounded observation and receives one action back. The agent may be the default deterministic ACO/pheromone brain, a raw OpenAI-compatible endpoint, or a local model behind the Python bridge. All of them implement the same interface.

1.1 IAgent

// Pluggable decision-maker for the Master. Default is the deterministic ACO brain;
// an LLM (local or frontier) can be plugged in via LLMAgent.
class IAgent {
public:
    virtual ~IAgent() = default;
    virtual AgentAction decide(const AgentObs& obs) = 0;
    virtual const char* name() const = 0;
    virtual void reset() {}
    virtual void observe(const Hex& player_hex, bool seen) {}
    virtual void decay() {}
    virtual MasterStrategy strategy() const { return MasterStrategy::Idle; }
    // Nyxari false trail: poison the Master's scent memory (default: no effect).
    virtual void plant_decoy(const Hex& hex, float amt, bool steal_fix) {}
};
Method Called Purpose
decide(obs) Each Master turn The only required decision hook: returns the chosen AgentAction.
name() Diagnostics / HUD Human-readable label (e.g. "ACO", "LLM", "CosySim/LLM", or a (->ACO) fallback tag).
reset() New pursuit Clears per-run memory (scent map, fix, strategy FSM).
observe(player_hex, seen) When the player acts / is spotted Deposits pheromone scent and updates the last-known fix. seen marks a fresh sighting.
decay() Passive tick Evaporates pheromone (the trail cools over time).
strategy() HUD / balance Reports the current FSM mode (see MasterStrategy below).
plant_decoy(hex, amt, steal_fix) Nyxari false trail [D] Lays a phantom scent spike and, if steal_fix, hijacks the Master’s last-known fix toward the decoy so he chases the wrong way.

strategy() returns one of:

enum class MasterStrategy : uint8_t { Idle = 0, Opportunistic = 1, Bloodhound = 2, Architect = 3 };
  • Idle — no fix; wandering / recovering.
  • Opportunistic — a stale trail; probes toward the strongest remaining scent.
  • Bloodhound — hot trail (trace ≥ 50); direct pursuit.
  • Architect — mid trail (trace ≥ 30); interception, cutting toward the gate.

1.2 AgentAction and AgentObs

// The Master's Phase-2 action space (the same legal moves the engine would allow
// a unit): step to an adjacent hex, forage, or rest. The agent picks among these.
struct AgentAction {
    MasterAct act = MasterAct::Move;
    Hex target;
    std::string narration;  // optional in-character line the Master speaks (LLM)
};

// Everything an agent is allowed to see. Honest and bounded — this is what keeps
// a plugged-in LLM "restricted just like the player."
struct AgentObs {
    MasterView view;
    std::vector<Hex> legal_moves;  // walkable neighbours of the Master
};

The Master’s Phase-2 action space is deliberately tiny:

enum class MasterAct : uint8_t { Move, Forage, Rest };
  • Move — step to AgentAction::target, which must be one of obs.legal_moves.
  • Forage — replenish food/energy in place.
  • Rest — recover energy/mana in place.

narration is optional flavour: if the agent supplies a line (LLM agents do), the Master speaks it once. Non-move actions ignore target.

1.3 MasterView — the honest, bounded observation

// What the Master brain observes each turn (a bounded, honest view — no cheating).
struct MasterView {
    const HexMap* map = nullptr;
    Hex master, gate;
    Hex player_last_known;
    bool has_fix = false;   // is the player currently in view / freshly tracked
    int master_food = 0, master_energy = 0, master_mana = 0;
    int player_trace = 0;
    int turn = 0;
};

This is the entire information channel the Master receives. He does not get the player’s true hex — only player_last_known, the last position he had a fix on, plus has_fix telling him whether that fix is fresh. His own resources (master_food, master_energy, master_mana) and the player’s player_trace (0–100, how loud a trail the apprentice is leaving) are visible; the player’s inventory, meters, and plans are not. This asymmetry is what a Nyxari false trail exploits — poisoning player_last_known sends the hunter the wrong way.

1.4 How legal moves constrain any agent

The legal_moves vector is computed by the engine (walkable neighbours of the Master’s hex) and is the only set of destinations any agent may return. Every agent path re-validates the chosen move against this list before it is applied, so an agent — LLM or otherwise — physically cannot teleport, phase through terrain, or move further than a player unit could. The validation lives at three layers:

Default ACO agent (src/agent/agent.cpp) guards its own brain’s output:

AgentAction ACOMasterAgent::decide(const AgentObs& obs) {
    MasterDecision d = brain_.decide(obs.view);
    AgentAction a;
    a.act = d.act;
    a.target = d.target;
    // Guard: if the brain's Move target isn't a legal step, forage instead.
    if (a.act == MasterAct::Move) {
        bool legal = false;
        for (const Hex& m : obs.legal_moves)
            if (m == a.target) { legal = true; break; }
        if (!legal && !obs.legal_moves.empty()) a.target = obs.legal_moves.front();
        else if (!legal) a.act = MasterAct::Rest;
    }
    return a;
}

The LLM and bridge agents apply the identical legal_moves check (§2.4) and fall back to the ACO brain on any illegal or missing choice.

1.5 The concrete agent classes

Class name() Backend Config
ACOMasterAgent "ACO" Deterministic pheromone brain (MasterBrain) none — always available, offline, reproducible
LLMAgent "LLM" / "LLM(->ACO)" Raw OpenAI-compatible HTTP via curl MS_LLM_URL, MS_LLM_MODEL, MS_LLM_KEY
BridgeAgent "CosySim/LLM" / "CosySim(->ACO)" agent_engine/run.py → LM Studio, async env below + a loaded LM Studio model

All three own an ACOMasterAgent fallback_ and forward reset / observe / decay / strategy / plant_decoy to it, so scent memory and the strategy FSM stay coherent even when the neural brain is unreachable.

1.6 The factory

// Factory: "aco" (default), "llm"/"cosysim" (Python bridge), or "openai" (raw HTTP).
std::unique_ptr<IAgent> make_master_agent(const std::string& kind);
std::unique_ptr<IAgent> make_master_agent(const std::string& kind) {
    if (kind == "llm" || kind == "cosysim") return std::make_unique<BridgeAgent>();
    if (kind == "openai") return std::make_unique<LLMAgent>();
    return std::make_unique<ACOMasterAgent>();
}

Phase 2 selects the agent at pursuit start from MS_MASTER_AGENT (src/game/phase2.cpp):

// The Master is a pluggable agent. Default ACO (offline, deterministic);
// set MS_MASTER_AGENT=llm to drive him with a local/frontier model.
const char* kind = std::getenv("MS_MASTER_AGENT");
master_agent_ = make_master_agent(kind ? kind : "aco");
master_agent_->reset();

2. Driving the Master with an LLM

Files: src/agent/llm_agent.cpp · src/agent/bridge_agent.cpp · agent_engine/{config,lmstudio_client,master_agent,stream_processor,run}.py

There are two neural paths. MS_MASTER_AGENT=openai uses LLMAgent, a self-contained C++ client that shells out to curl against any OpenAI-compatible /chat/completions endpoint. MS_MASTER_AGENT=llm (alias cosysim) uses BridgeAgent, which invokes the standalone agent_engine Python package — the LM Studio / CosySim foundation — that never force-loads a model and prefers structured OpenAI tool-calling.

2.1 Environment-variable reference

Variable Read by Default Meaning
MS_MASTER_AGENT phase2.cpp aco Agent kind: aco (default), llm or cosysim (Python bridge), openai (raw C++ HTTP).
MS_LLM_URL LLMAgent (C++); config.base_url() (Python) (unset → LLMAgent stays disabled); Python defaults to http://127.0.0.1:1234/v1 Endpoint. LLMAgent posts to this exact URL; the bridge appends /chat/completions.
MS_LLM_MODEL LLMAgent; config.pick_model() C++: gpt-4o-mini; Python: first loaded model Model id. The bridge honours it only if that model is already loaded (or MS_LLM_FORCE=1).
MS_LLM_KEY LLMAgent; config.api_key() falls back to lmstudio-api-key.txt Bearer token. If unset, both paths read the first non-empty line of lmstudio-api-key.txt.
MS_LLM_TIMEOUT LMSClient 20 (seconds) HTTP timeout for the bridge’s httpx client. (LLMAgent’s curl uses a fixed --max-time 30.)
MS_LLM_MAXTOK master_agent.decide 220 Max tokens for the bridge’s tool-call completion.
MS_AGENT_HEURISTIC master_agent.decide (unset) 1 forces the Python bridge to skip the model and use the deterministic bloodhound heuristic.
MS_LLM_FORCE config.pick_model (unset) 1 lets the bridge request MS_LLM_MODEL even if it is not resident (may trigger a load).

Key resolution (Python, agent_engine/config.py) and model selection are loaded-only by design:

def pick_model() -> str | None:
    """Choose a model to use WITHOUT triggering a load.

    Honors MS_LLM_MODEL only if that model is already loaded (or MS_LLM_FORCE=1).
    Otherwise uses the first loaded model, else None.
    """
    want = os.environ.get("MS_LLM_MODEL", "").strip()
    loaded = loaded_models()
    if want:
        if want in loaded or os.environ.get("MS_LLM_FORCE") == "1":
            return want
    return loaded[0] if loaded else None

If no model is resident, pick_model() returns None and the bridge answers from its own heuristic — the game never blocks on a model load.

2.2 The agent_engine bridge protocol

BridgeAgent locates the script by probing (preferring the standalone package, then the legacy shim):

agent_engine/run.py         ../agent_engine/run.py   ../../agent_engine/run.py   ../../../agent_engine/run.py
tools/master_agent.py       ../tools/master_agent.py ../../tools/master_agent.py ../../../tools/master_agent.py

Each turn it serialises the observation to a temp file and invokes:

python "<script>" "<obs.json>"

run.py reads the observation (path argument or stdin), defaults mana/trace, and prints only the action JSON on stdout (diagnostics go to stderr):

def main() -> int:
    if len(sys.argv) > 1 and os.path.exists(sys.argv[1]):
        obs = json.load(open(sys.argv[1]))
    else:
        obs = json.load(sys.stdin)
    obs.setdefault("mana", 0)
    obs.setdefault("trace", 0)
    print(json.dumps(master_agent.decide(obs)))  # ONLY the action on stdout
    return 0

Observation JSON (emitted by bridge_agent.cpp from MasterView + legal_moves):

{
  "master": [q, r],
  "player": [q, r],
  "gate":   [q, r],
  "food": 40, "energy": 55, "mana": 10, "trace": 62,
  "legal": [[q,r], [q,r], ...]
}

Action JSON (returned on stdout): one of

{"action": "move", "index": 3, "say": "You cannot outrun the wood, child."}
{"action": "forage", "say": "..."}
{"action": "rest"}

index is an index into the legal array. say is the optional in-character line, surfaced as AgentAction::narration.

2.3 /no_think + single-tool act(...)

The bridge does not free-form parse by default. It offers the model exactly one OpenAI tool, act, and asks it (with a trailing /no_think) to call it (agent_engine/master_agent.py):

ACT_TOOL = [{
    "type": "function",
    "function": {
        "name": "act",
        "description": "Choose Master Malacor's action for this turn and speak one short line.",
        "parameters": {
            "type": "object",
            "properties": {
                "action": {"type": "string", "enum": ["move", "forage", "rest"],
                           "description": "move to hunt, forage if starving, or rest"},
                "index": {"type": "integer",
                          "description": "the legal move index (required when action=move)"},
                "say": {"type": "string",
                        "description": "one short, cold, menacing line to the fleeing apprentice"},
            },
            "required": ["action"],
        },
    },
}]

The completion is non-streaming with tool calling (LMSClient.complete):

out = client.complete(messages, model=model, tools=ACT_TOOL, tool_choice="auto",
                      max_tokens=int(os.environ.get("MS_LLM_MAXTOK", "220")))

Decision precedence in decide(obs):

  1. Structured tool call — parse act’s arguments; a move requires a valid 0 ≤ index < len(legal).
  2. Tag/JSON epilogue fallback — if the model narrated instead of calling the tool, stream_processor.parse scrapes inline tags ([MOVE:N], [FORAGE], [REST], [SAY:...], [MOOD:x]), a trailing {"tool":..., "index":N} blob, or a bare index N.
  3. Deterministic heuristic — starving? forage. Otherwise step to the legal move with the smallest hex distance to the apprentice. This is also used immediately when MS_AGENT_HEURISTIC=1 or when no move is legal or no model is loaded.

Every branch re-checks 0 ≤ index < n before returning, so an out-of-range index from the model is discarded rather than applied.

2.4 The asynchronous decision model

A slow local model must never freeze the game, so BridgeAgent::decide is pipelined: the ACO brain plays instantly every turn while a background thread asks the model for a (possibly slower) decision. When that decision lands it is re-validated against the current legal moves and used from then on.

AgentAction BridgeAgent::decide(const AgentObs& obs) {
    if (available_ && obs.legal_moves.size() > 0) {
        // 1) harvest a finished decision
        if (has_pending_ &&
            pending_.wait_for(std::chrono::milliseconds(0)) == std::future_status::ready) {
            auto r = pending_.get();
            has_pending_ = false;
            if (r.first) { llm_action_ = r.second; have_llm_ = true; }
        }
        // 2) launch a fresh decision for the current view (one in flight at a time)
        if (!has_pending_) {
            AgentObs snap = obs;
            std::string script = script_;
            pending_ = std::async(std::launch::async,
                                  [snap, script]() { return call_bridge(snap, script); });
            has_pending_ = true;
        }
        // 3) use the freshest LLM decision, re-validated against current legal moves
        if (have_llm_) {
            bool ok = (llm_action_.act != MasterAct::Move);
            if (!ok)
                for (const Hex& m : obs.legal_moves)
                    if (m == llm_action_.target) { ok = true; break; }
            if (ok) {
                AgentAction out = llm_action_;
                llm_action_.narration.clear();  // a line is spoken only once
                return out;
            }
        }
    }
    // no LLM decision yet (or slow model still thinking) — the ACO brain plays now
    return fallback_.decide(obs);
}

Notes:

  • At most one bridge call is in flight at a time.
  • A Move decision is only used if its target is still in the current legal_moves; if the world moved on, the ACO brain plays instead.
  • narration is cleared after use so a line is spoken only once.

2.5 Validation + ACO-fallback guarantees

The Master cannot cheat, at every layer:

  • LLMAgent (src/agent/llm_agent.cpp) is inert unless MS_LLM_URL is set (configured_ = !url_.empty()); it parses the final JSON object after any <think> reasoning, and any missing content, unparseable reply, or out-of-range index falls back to fallback_.decide(obs):

    int idx = find_int(content, "\"index\"", -1);
    if (idx < 0 || idx >= (int)obs.legal_moves.size()) return fallback_.decide(obs);
    
  • BridgeAgent re-validates the returned index inside call_bridge and the cached decision against current legal moves before use (§2.4); an unavailable script, empty stdout, or invalid choice yields the ACO play.

  • agent_engine never raises across the seam: LMSClient.chat / .complete catch every exception and return empty content, and master_agent.decide always returns a valid action dict (heuristic as the floor).

Because the fallback is the deterministic ACO brain, a run with the LLM detached still proceeds; and because only legal_moves are ever applied, plugging in a model changes how the Master hunts, never what he is allowed to do.


3. The ContentDB Data Format

Header: src/core/content_db.hpp · Impl: src/core/content_db.cpp

All authored content — six lineages, 15 spells, 8 enemies, 20 events, 9 chaos events, 8 endings, 16 items — lives under data/ as git-native, human-editable, moddable flat records. The parser is intentionally minimal (no YAML/JSON dependency).

data/
  spells/spells.md      endings/endings.md    items/items.md
  chaos/chaos.md        enemies/enemies.md    events/events.md
  lineages/lineages.md

3.1 Record grammar

  • One record per block; blocks are separated by a line containing exactly ---.
  • Each field is a single line of the form key: value (first : splits; the key and value are trimmed).
  • Blank lines and lines beginning with # are ignored — so a file can carry comment banners freely.
  • A line with no : is skipped. Fields with an empty key are skipped.
  • load_dir walks .md and .txt files recursively, in sorted order.
  • A record is indexed by its id field (if present); by_type filters on the type field.

Example (data/spells/spells.md):

# The Master's Shadow — Spellbook
# ContentDB (strict flat): key: value per line, records split by ---

id: ward
type: spell
name: Ward
color: White
tier: 1
mana: 1
skill: Willpower
effect: shield 8 for one turn
description: A ring of pale sigils flares between you and the dark, and the dark, for now, obeys.
---
id: mend-flesh
type: spell
name: Mend Flesh
color: White
tier: 1
mana: 2
skill: Alchemy
effect: heal 6-10
description: The wound knits shut beneath your hand...

3.2 Record — fields and typed getters

struct Record {
    std::string source_file;
    std::map<std::string, std::string> fields;

    bool has(const std::string& key) const { return fields.count(key) != 0; }
    std::string get(const std::string& key, const std::string& def = "") const;
    int geti(const std::string& key, int def = 0) const;
    double getf(const std::string& key, double def = 0.0) const;
    bool getb(const std::string& key, bool def = false) const;
    std::vector<std::string> getlist(const std::string& key) const;  // comma-separated
};
Getter Returns Notes
has(key) bool Field present?
get(key, def) raw string Default when absent.
geti(key, def) int std::stoi; returns def on parse failure.
getf(key, def) double std::stod; returns def on parse failure.
getb(key, def) bool true for true / yes / 1 (case-insensitive).
getlist(key) vector<string> Comma-separated; optional surrounding [ ] stripped; items trimmed; empties dropped.

3.3 ContentDB — loading and lookup

class ContentDB {
public:
    bool load_dir(const std::string& path);   // loads *.md / *.txt recursively
    bool load_file(const std::string& path);
    void clear();

    size_t size() const { return records_.size(); }
    const std::vector<Record>& all() const { return records_; }
    const Record* by_id(const std::string& id) const;
    std::vector<const Record*> by_type(const std::string& type) const;

private:
    std::vector<Record> records_;
    std::map<std::string, int> id_index_;
};

// Search common relative locations for a resource directory (so the game works
// whether launched from repo root, build/bin, or an install layout).
std::string resolve_resource_dir(const std::string& name);
  • by_id(id) — O(1) lookup via the id index; nullptr if unknown.
  • by_type(type) — linear scan returning every record whose type field matches (e.g. by_type("spell")).
  • resolve_resource_dir("data") — probes "", ./, ../../../../../ so the game finds data/ whether launched from the repo root, build/bin, or an install layout.

Modding: add a new record (or a whole new .md file) under data/, give it a unique id and the right type, and it is loaded on next launch — no recompile. Keep one key: value per line and separate records with ---.


4. The Engine Action API

Header: src/core/action.hpp · Engine: src/core/rules_engine.hpp / .cpp

Every legal move — player or Master — is a single Action. The Master is bound to the same taxonomy; that is the mechanical root of “restricted just like the player.”

4.1 ActionType

// Every legal move a player OR the Master can submit. The Master is bound to the
// same taxonomy — that is what keeps it "restricted just like the player."
// The set grows per phase; generic int/string params keep the journal compact
// and serialisable. Enum values are stable (save/replay format).
enum class ActionType : uint16_t {
    Noop = 0,
    EndTurn = 1,        // advance turn, day segment, passive ticks
    Move = 2,           // a = hex direction 0..5
    SkillCheck = 3,     // a = modifier, b = dc, s = result-flag key (optional)
    CastSpell = 4,      // a = MagicColor, b = mana cost, c = trace delta, d = clock delta
    Forage = 5,         // b = dc
    Rest = 6,           // recover energy/mana
    PerformChore = 7,   // b = dc  (Phase 1)
    AttemptPuzzle = 8,  // a = skill modifier, b = dc, s = puzzle id
    CounterRitual = 9,  // b = clock reduction on success, a = alchemy modifier
    AdvanceRitual = 10, // a = amount (Master research)
    LearnSpell = 11,    // a = MagicColor, b = dc, s = spell id
    SetFlag = 12,       // s = key, a = value (scripted/debug)
    AdjustSuspicion = 13, // a = delta (Phase 1 detection risk etc.)
    GrantItem = 14,     // s = item id, a = count (default 1) -> flag "item_<id>" += a
    GrantSpell = 15,    // s = spell id (unconditional unlock) -> flag "spell_<id>" = 1
};

Enum values are stable — the save/replay format depends on them; never renumber.

4.2 Action

struct Action {
    ActionType type = ActionType::Noop;
    int a = 0, b = 0, c = 0, d = 0;
    std::string s;
    bool by_master = false;

    static Action end_turn(bool master = false) {
        Action x; x.type = ActionType::EndTurn; x.by_master = master; return x;
    }
    static Action move(int dir, bool master = false) {
        Action x; x.type = ActionType::Move; x.a = dir; x.by_master = master; return x;
    }
};

The four generic ints (ad) and one string (s) carry per-type parameters (see the inline comments in ActionType), keeping the journal compact and serialisable. by_master distinguishes the two actors sharing the taxonomy. The static factories cover the two most common actions.

Bridge note. Phase-2 agents speak the smaller MasterAct vocabulary (Move / Forage / Rest, §1.2). Phase 2 translates the chosen AgentAction into the corresponding engine Action (e.g. Action::move(dir, /*master=*/true), Forage, Rest) before it is validated and applied — so the agent’s decision flows through the same is_legal / apply gate as any other move.

4.3 The apply / journal contract

class RulesEngine {
public:
    struct ApplyResult {
        bool ok = false;
        bool had_check = false;
        CheckResult check;
        std::string message;
    };

    RulesEngine(uint64_t seed, Lineage lineage);
    explicit RulesEngine(const GameState& gs);  // resume from loaded state (fresh RNG from seed)

    const GameState& state() const { return state_; }
    GameState& mutable_state() { return state_; }  // setup / scripted world building only
    Rng& rng() { return rng_; }
    EventBus& events() { return bus_; }
    const std::vector<Action>& journal() const { return journal_; }

    bool is_legal(const Action& a) const;
    ApplyResult apply(const Action& a);

    // Reconstruct a state deterministically from a recorded journal.
    static GameState replay(uint64_t seed, Lineage lineage, const std::vector<Action>& journal);
    // ...
};

Every action passes through one gate. apply rejects illegal actions, dispatches the legal ones, and appends the action to the 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);
    return res;
}

The determinism guarantee. The engine owns one authoritative GameState and a single RNG stream seeded from the game seed. Because every mutation flows through apply and is journalled, the triple (seed, lineage, journal) reproduces an identical state — the same guarantee that makes the Master auditable and forbids cheating. replay rebuilds that state by re-applying the journal on a fresh engine:

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();
}

GameState::state_hash() produces an order-fixed 64-bit fingerprint over all fields; ms_headless prints it so that (seed, lineage, turns) can be verified to yield an identical state_hash across runs and machines.

One stream, one side-stream. The RulesEngine RNG drives all gameplay resolution. The faction war / encounter gating uses a separate side-stream (war_rng_), so crowd-simulated warband contests never perturb the main stream — the replay hash for (seed, lineage, actions) stays identical regardless of war activity.


Appendix — Integration Checklist

  • Run the default (offline) Master: nothing to do — MS_MASTER_AGENT unset ⇒ deterministic ACO.
  • Run a local model via the bridge: start LM Studio with a model loaded, then set MS_MASTER_AGENT=llm. Optionally pin MS_LLM_MODEL (must be resident) and MS_LLM_URL / MS_LLM_KEY. Force the heuristic with MS_AGENT_HEURISTIC=1.
  • Run a frontier / raw endpoint: set MS_MASTER_AGENT=openai and MS_LLM_URL (required — LLMAgent is inert otherwise), plus MS_LLM_MODEL / MS_LLM_KEY.
  • Add content: drop a key: value record under data/<type>/…​.md, separated by ---, with a unique id. Reload the game.
  • Reproduce a run: capture (seed, lineage, journal); RulesEngine::replay (or ms_headless --selftest) yields an identical state_hash.

The wood remembers every step. So does the journal.

An iteration of my current work towards integrating AI agent’s into video games, etc. It is rather complete, still has a lot of problems. But the goal is not the game itself, The goal is to show the methods and trials, the progress, the underlying structure on how to aproach things like this.

As per usual, There is a lot of documentation, all in OKF, Alot of interesting stuff to take and play with. Hopefully someone finds something useful in here and if you do, feel free to copy, use, learn, do whatever with it!

*Note License is MIT I will edit that later(When I don’t want the post to be hidden for days for being edited), but it is MIT on the github repo’s so all good. I will probably return to this project every now and then and add or fix things, make things better etc. But I have no plans on going further with it currently. It’s just another Project designed to hopefully motivate or give people idea’s or a basis to build on.

The wood remembers every step. So does the journal.