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