"There is no page in my grimoire I did not write."
This guide is for authors who wish to add or reshape the game's content — the
bloodlines, spells, wild events, endings, and the codex of the wilds — without
touching a line of C++, and for those who wish to regenerate its pixel art and
voice. Everything the game reads from disk lives under data/ and assets/,
and every record obeys one small, strict grammar.
Read this whole page before you edit. The parser is deliberately unforgiving in a few places (a mistyped separator silently swallows a record), and a handful of files are codices that describe the engine rather than drive it — editing them changes the lore the player reads, not the numbers the engine runs.
ContentDB loads content by walking the data/ tree recursively at launch
(ctx_.content.load_dir(resolve_resource_dir("data")) in src/game/app.cpp).
Every .md or .txt file found is parsed; other extensions are ignored. Load
order is sorted by path for determinism, but nothing hashable depends on it.
| Directory | File | type: |
Count | Role |
|---|---|---|---|---|
data/lineages/ |
lineages.md |
lineage |
6 | Bloodline traits — drives the engine (injected) |
data/enemies/ |
enemies.md |
enemy |
8 | Enemy stats + names — drives the engine (injected) |
data/chaos/ |
chaos.md |
chaos |
9 | Apex chaos base weights + flavour — drives the engine (injected) |
data/tuning/ |
tuning.md |
tuning |
1 | Global tunable knobs — drives the engine (injected) |
data/world/ |
terrain.md |
terrain |
9 | Terrain move-cost + encounter base — drives the engine (injected) |
data/events/ |
events.md |
event |
36 | Phase 2 wild events — drives the engine (journaled RNG) |
data/recipes/ |
recipes.md |
recipe |
3 | Phase 2 brew recipes — cost + effect deltas (app-layer) |
data/spells/ |
spells.md |
spell |
22 | Spell catalogue — drives the engine (injected; combat + field casting) |
data/items/ |
items.md |
item |
13 | Item catalogue — drives the engine (injected; inventory + use) |
data/difficulty/ |
difficulty.md |
difficulty |
2 | Normal/Hardcore rows — drives the engine (injected; row 0 must mirror the literals) |
data/reagents/ |
reagents.md |
reagent/synergy |
34 | Tag-synergy alchemy — drives the engine (injected; BrewCombine) |
data/traps/ |
traps.md |
trap |
7 | The Architect's traps — drives the engine (injected) |
data/master_spells/ |
master_spells.md |
master_spell |
6 | The Master's counter-workings — drives the engine (injected) |
data/reputation/ |
reputation.md |
repevent |
12 | Authored faction events — drives the engine (injected) |
data/lore/ |
lore.md |
lore |
32 | Codex fragments, unlock-gated — presentation |
data/strings/ |
*.md |
uistring |
500+ | UI + narrative text read via tr() — presentation |
data/barks/ |
*.md |
bark/apexbark |
35 | Master Malacor taunt corpora |
data/camp/ |
camp.md |
dream |
8 | Night-camp dream vignettes |
data/endings/ |
endings.md |
ending/coda |
14 | Ending titles + epilogues + per-lineage codas |
Every bundle also carries an index.md (type: index) and log.md (type: log)
— OKF progressive-disclosure + provenance records the runtime never queries.
Regenerate both with python tools/gen_okf_index.py after adding records, and run
python tools/okf_validate.py (gate G-OKF-CONFORM) before committing.
Codex vs. driver. Two ways data reaches the engine:
- Injected rule tables (
lineage,enemy,chaos,tuning,terrain): parsed in the app/headless layer into frozen POD structs (src/core/game_tables.hpp) and handed toms_coreonce before the first turn viaset_game_tables().ms_coreitself does no file I/O. These numeric fields must be integers — floats are stored as per-mille ints (1.25→1250), becausestd::stodis not byte-stable and a float leak would break the determinism hash. Rebalancing them changes the simulation, yet the hash is proven unchanged when values match (baseline16425e914e1636ca). - Injected catalogues (
spell,item,reagent/synergy,trap,master_spell,repevent,difficulty): also frozen intoGameTablesand drive the engine (CastSpell/UseItem/BrewCombine/ traps / Master counters). Display fields (name,effect,description) are presentation; mechanical fields are integer-only and core-consumed. - App-layer / presentation reads (
event,recipe,uistring,bark,dream,ending,lore,achievement): read by name fromContentDBin the game/UI layer.eventapplies resource swings through the journaled RNG;uistringis read viatr(id, fallback)(see §6).
Two save formats. Do not confuse them:
| Format | Where | What it stores | Used for |
|---|---|---|---|
| MSSAVE | src/save/save.cpp |
seed + lineage + action journal | Core replay / tools |
| MSSNAP | src/game/run_save.cpp |
full GameState snapshot (flags, resources, meters) |
Continue on Normal; Hardcore never writes |
Full player runs still hybrid-mutate outside the journal (see
docs/ARCHITECTURE.md §1.2 and docs/MUTABLE_STATE_INVENTORY.md), so Continue
must snapshot state rather than replay MSSAVE alone.
You may split a record type across multiple files inside its directory, or add
new files — load_dir concatenates everything it finds. A single file may also
hold records of mixed type:; the loader keys only on the type: field, not the
filename.
Every content file is a flat sequence of records. The grammar is implemented
in src/core/content_db.cpp and is intentionally tiny:
- Every meaningful line is
key: value. The parser splits on the first colon. Everything before it (trimmed) is the key; everything after it (trimmed) is the value. A line with no colon is silently skipped — so free prose that happens to contain no:vanishes without warning. - Records are separated by a line of exactly
---. The line must trim to the three characters---and nothing else.----,- - -, or---with trailing junk after trimming will not split records. A leading---before the first record is fine (it flushes an empty record, which is discarded). #starts a comment. Any line whose first non-whitespace character is#is ignored. There are no inline comments —food: +6 # cachewould make the value the literal string+6 # cacheand breakstoi. Put comments on their own line.- Blank lines are ignored and may appear freely inside or between records.
- Leading/trailing whitespace is trimmed from both key and value on every
line (spaces, tabs,
\r,\n). Windows CRLF endings are safe. - Duplicate keys: the last one wins (later
cur.fields[key] = valoverwrites earlier).
Every record must carry:
id:— a unique, lowercase-with-hyphens slug. This is the index key (ContentDB::by_id). A record with noid:is still loaded and still counts inby_type, but it can never be looked up by name, and a duplicateid:will overwrite the earlier record's index entry (last wins).type:— one of the seven types in the table above.by_typefilters on this exact string. A typo here (type: evnt) makes the record invisible to the system that wanted it.
Fields are stringly-typed on disk and coerced on read. Author your values to match how the field is consumed:
| Accessor | Coercion | Author as | Bad input falls back to |
|---|---|---|---|
get(key) |
raw string | any text | the default (usually "") |
geti(key) |
std::stoi |
6, +6, -3 |
the default (0) — no throw |
getf(key) |
std::stod |
1.15, 0.20, -4 |
the default |
getb(key) |
lowercased | true / yes / 1 |
false (anything else) |
getlist(key) |
comma-split | a, b, c (optional [ ]) |
empty list |
Numeric gotcha — no units, ever. geti/getf run stoi/stod, which parse
the leading numeric prefix. +6 and -3 are fine (sign is part of the number).
But 6 food, 6%, or 6 (per turn) parse as 6 only if the number leads —
stoi("food 6") throws and you silently get the default. Never append units.
Write +5 / -3 as bare signed integers, and floats as 1.15, not 1.15x.
Lists are one comma-separated value on a single line. getlist splits on
commas, trims each item, drops empties, and strips one optional pair of [ ]
brackets. There is no multi-line list syntax — everything must be on the one
key: line.
---
id: my-record
type: event
text: One line of prose the player will read.
food: +5
Phase 2 (The Forest Pursuit) rolls non-combat wild events when you move onto a
tile without an ambush. The dispatcher is Phase2Screen::roll_map_event() in
src/game/phase2.cpp. It draws a random type: event record from the ContentDB
and applies its effect fields directly to the player. This is the single most
mod-friendly system in the game: add a record, get a new event, no rebuild of
logic required.
Only these six numeric fields are read. Anything else on an event record is
ignored by the engine (though text: is required — it is the line logged to the
player). Values are bare signed integers (stoi, no units):
| Field | Applies to | Notes |
|---|---|---|
food: |
player.res.food |
added directly, unclamped |
water: |
player.res.water |
added directly, unclamped |
energy: |
player.res.energy |
added, then clamped to 0..60 |
mana: |
player.res.mana |
added, then floored at 0 |
trace: |
Sympathy Trace | routed through adjust_trace (the Master's tracking vector); a positive value makes you easier to hunt |
ingredient: |
alchemy reagents | grants this many crafting ingredients (ActionType::GrantItem, s = "ingredient"); use +1 — the corpus caps ingredient gifts at 1 |
text: is required and is the only string field the engine surfaces. A missing
text: yields a silent event.
Design conventions the existing corpus follows (from data/events/events.md):
resource swings of 3–8, trace of 3–6, ingredient: +1 at most. Roughly
half the events are pure boons, a quarter are mixed (a gain paid for with a loss),
and a quarter are ominous (trace up, energy down). A positive trace: is what
makes an event feel watched — the particle burst even turns violet when
trace > 0.
---
id: my-new-event
type: event
text: A single evocative line describing what the traveller finds.
food: +5
water: +3
Say we want a "poisoned mushroom feast" — filling, but it draws the Master's
notice and saps a little vigour. Drop this into data/events/events.md (or a new
file like data/events/my_events.md):
---
id: fools-banquet
type: event
text: A spread of fat pale mushrooms grows in a fairy ring, and hunger makes the risk feel small.
food: +7
mana: +3
trace: +5
energy: -4
On load it becomes one more entry in the by_type("event") pool. When
roll_map_event fires (a ~9% chance per non-ambush move) and rolls this record,
the player gains 7 food and 3 mana, loses 4 energy (clamped into 0..60), and
takes +5 Trace via adjust_trace. No recompile of game logic is needed — the
event corpus is pure data.
Determinism note.
roll_map_eventdraws from the engine's journaled RNG stream (eng()->rng()), so a given(seed, lineage, action-sequence)replays identically. Adding, removing, or reordering event records changes which record a given roll selects, so it will change replay outcomes and thestate_hash. That is expected — new content is a new world. (The faction-war side-streamwar_rng_is independent and unaffected.)
data/lineages/lineages.md holds the six bloodlines, and unlike the codices its
numbers are consumed as gameplay traits (via magic/lineage). This is where
you rebalance a bloodline's identity. The fields, drawn from the shipped records:
| Field | Type | Meaning |
|---|---|---|
name:, title:, desc: |
string | Display identity |
affinity: |
White/Grey/Black |
Primary magic colour |
phase2_mult: |
float | Phase 2 casting multiplier |
phase2_color: |
string | Colour that multiplier applies to |
parasite_dur_mult: |
float | Trace Parasite duration scaling |
parasite_refresh_bonus: |
float | Extra parasite refresh on Black cast |
trace_gen_mult: |
float | How much trace this bloodline sheds |
clock_black_penalty: |
float | Ritual-clock cost of Black work |
echo_risk: |
bool | Subject to Echo Bleed |
echo_severity: / echo_ability: |
string / bool | Echo tuning (Dravenblood high; Auralis' pattern-copy) |
inversion: |
bool | Can invert the ritual (Dravenblood) |
domination_resist: |
bool | Resists domination (Sylvara) |
flow_resist: |
float | Resistance to flow effects |
bond_start: |
int | Starting Sympathetic Bond (Veyra 30, all others 15) |
p1_observation / p1_experimentation / p1_stealth / p1_puzzle |
int | Phase 1 skill modifiers (+1/-1) |
p1_suspicion_on_fail: |
int | Extra Phase 1 suspicion on a failed action (Dravenblood) |
p2_foresight … p2_pattern_copy |
bool/int | Phase 2 signature abilities (see below) |
p3_hijack_bond, p3_inversion_bonus, p3_chaos_calm, p3_domination_resist, p3_echo_storm_ride |
int/bool | Phase 3 apex traits |
combat_white / combat_black |
int | Combat affinity bonuses |
flee_bonus / forage_bonus |
int | Phase 2 utility bonuses |
The Phase 2 signature abilities are what give each bloodline its verb:
- Veyra —
p2_foresight: true(echo-foresight,p2_foresight_echo_cost: 1) andp3_hijack_bond: 40(Phase-3 bond hijack). Pays for it with the highest echo bleed,trace_gen_mult: 1.20, and a hungry parasite. - Nyxari —
p2_decoy_strength: 3/p2_decoy_cooldown: 4power the false-trail decoy [D] that poisons the Master's scent map;p3_chaos_calm. - Thalor —
p2_ley_footing: true(footing on Water/Path);combat_white: 3. - Dravenblood —
p2_black_siphon: 0.5(Black damage → mana),inversion: true,p3_inversion_bonus: 4;parasite_dur_mult: 1.40burns them fastest. - Sylvara —
p2_wild_forage: true,p2_beast_pact: 30,domination_resist. - Auralis —
p2_pattern_copy: true(copy the Master's last strategy [R]),p3_echo_storm_ride.
Editing safely. Tuning a numeric field (say bumping Nyxari's
p2_decoy_strength from 3 to 4) is a data-only rebalance — save and relaunch.
Adding a new bloodline requires code, because the lineage roster, the
ctx_->lineage enum, the ability dispatch (lay_false_trail, auralis_invoke,
p2_foresight handling, etc.), and the art/aura mapping in tools/msart.py
(LINEAGE_COLOR) are all keyed in C++/Python by the six known ids. Data alone
cannot register a seventh. Keep edits to the existing six ids
(veyra, nyxari, thalor, dravenblood, sylvara, auralis).
Lineage template (fields you omit fall back to the accessor default):
---
type: lineage
id: veyra
name: Veyra
title: The Vessels
affinity: Grey
phase2_mult: 1.15
phase2_color: Grey
trace_gen_mult: 1.20
echo_risk: true
bond_start: 30
p1_observation: 1
p1_stealth: -1
p2_foresight: true
p3_hijack_bond: 40
combat_black: 1
desc: One line of bloodline flavour.
These four record types are looked up by id for their words, but their
numbers and behaviour live in C++. Editing prose is safe and encouraged;
editing a "stat" field changes what the codex says, not what the engine does.
The bestiary records carry hp, atk, def, speed, affinity, caster, and
a codex: blurb — but every one of those stats is defined in code in
generate_enemy() and pick_kind() (src/world/bestiary.cpp). The eight enemy
kinds, their base HP/attack/defence/speed, biome weighting, tier scaling, loot,
and sprites are all hardcoded there. The data file exists so the numbers can be
displayed and lore-checked in one place, and the tests assert the data and code
agree (tests/test_content.cpp expects by_type("enemy").size() == 8).
So: to rebalance an enemy or add a ninth, you edit bestiary.cpp (the
EnemyKind enum, the pick_kind weight table, the per-kind stat block in
generate_enemy) — and then update enemies.md to match, so the codex stays
honest. Editing only the data file changes nothing the player fights. Keep the
two in lockstep; the fields to mirror are the base (un-tiered) values:
---
type: enemy
id: dire-wolf
name: Dire Wolf
biome: DeepWoods
hp: 14
atk: 7
def: 1
speed: 8
affinity: Grey
caster: false
codex: A single line of bestiary lore for the player to read.
Fifteen spells across White / Grey / Black, each with name, color, tier,
mana, skill, effect, and description. The effect: string
(e.g. deal 7-12 fire damage) is descriptive text, not a parsed formula —
the actual damage rolls and mechanics are resolved in code. Edit mana:/tier:
freely for display, edit description: for flavour, but the mechanical resolution
of a spell is not driven by these fields. Adding a genuinely new spell effect
needs code.
---
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.
- Chaos — nine Phase-3 apex events, mirroring the
Chaosenum insrc/magic/apex.hpp. Each isid+name+ one-lineeffect:prose. Which chaos fires and what it does numerically is code; the record supplies the name and the line the player reads. - Endings — eight records mirroring the
Endingenum, each withtitle,tone(good/dark/bittersweet), andepilogue. The engine decides which ending you earn; the record supplies its title and closing paragraph. - Items — sixteen records with
name,category,phase(1/2/3), anddescription. Presentation and lore; behaviour is elsewhere.
Templates:
---
id: resonance-surge
type: chaos
name: Resonance Surge
effect: One line describing what the chaos does to the clash.
---
id: true-freedom
type: ending
title: TRUE FREEDOM
tone: good
epilogue: The closing paragraph the player reads when this ending fires.
---
id: masking-draught
type: item
name: Masking Draught
category: consumable
phase: 2
description: One line of item flavour.
Almost every word the player reads — screen titles, buttons, key-hint bars,
dialogue, combat and camp log lines — now lives in data/strings/*.md as
type: uistring records, one per line of text:
---
type: uistring
id: combat.flee_success
text: You break away into the trees!
The screens fetch them by id through tr("combat.flee_success", "…fallback…")
(src/core/strings.hpp). The second argument is the fallback — the original
English literal, baked into the binary — so if you delete a record or mistype its
id, the game still shows the fallback rather than blank text. To re-skin or
translate the game, edit the text: fields (or add a whole parallel bundle);
leave the ids alone, since the code looks them up by id.
Three parser rules you cannot break (from §2, but they bite hardest here):
- One physical line per value. The loader drops any wrapped continuation
line, so a
text:that spills onto a second line is silently truncated. Keep each string on a single (arbitrarily long) line. - Values are trimmed — leading/trailing spaces are stripped. Never rely on a space at the very start or end of a value.
- Quotes are literal — the loader does not strip surrounding quotes, so
text: "hi"shows the quotation marks. Only include quotes you actually want on screen.
Because of rules 2–3, runtime-interpolated lines are stored as whole printf
templates, not glued fragments. A line built in code as
TextFormat(tr("combat.you_strike_fmt", "You strike for %d.").c_str(), dmg) has a
matching record:
---
type: uistring
id: combat.you_strike_fmt
text: You strike for %d.
Keep the %d / %s specifiers exactly (same count and order as the code passes
arguments), or the text will be malformed. %d is a number, %s a name/word.
After editing strings, run python tools/okf_validate.py — it flags dropped
lines, duplicate ids, and typeless records.
All sprites, tiles, props, UI, particles, and item icons are generated
procedurally in Python into assets/. Nothing is hand-drawn; regenerating is
deterministic (seeded), so a rerun reproduces the same pixels.
Every generator imports from the shared msart module, which provides:
PAL— the canonical palette, a dict of named colours grouped into ramps: warm near-black inks (ink0–ink4), wood/leather (wood0–wood5), gold/brass (gold0–goldglow), parchment cream (cream0–cream3), blood-red (red0–red4), nature-green (grn0–grn3), grey-magic terracotta (gry0–gry3), black-magic void purple (vio0–vio4), white-magic amber (wht0–wht3), water blue (blu0–blu3), skin, plus pureblack/white. Always draw fromPALso new art stays inside the game's gothic identity.MAGIC_RAMP— White/Grey/Black shading ramps for auras and particles.LINEAGE_COLOR— per-bloodline aura tint (e.g.veyra: vio3,sylvara: grn2). Keyed by the six lineage ids.C(name, a=255)— palette colour → RGBA tuple with alpha.lerp(c0, c1, t)— linear colour blend.Canvas— a small RGBA pixel canvas with helpers:set,rect,hline,vline,vgrad(vertical gradient),disc,ring,dither(4×4 Bayer),outline(1px outline around opaque pixels),shadow_ellipse, andsave(which makes parent dirs and writes the PNG).sheet(cells, cell_w, cell_h, cols)— compose a list ofCanvascells into a single contact spritesheet.
Art is drawn at native resolution (no upscaling); raylib scales with a POINT filter for crisp pixels.
Each tools/gen_*.py builds one asset family and writes into assets/
(sprites, tiles, props, UI, particles, items):
tools/gen_characters.py tools/gen_enemies.py tools/gen_tiles.py
tools/gen_props.py tools/gen_items.py tools/gen_ui.py
tools/gen_particles.py
Run one from the tools/ directory (they import msart as a sibling and resolve
assets/ relative to themselves):
cd C:\Projects\the_masters_shadow-main\tools
python gen_enemies.py # regenerate the seven enemy sprites + contact sheetgen_enemies.py, for instance, defines one function per creature (wolf(),
spore(), wraith(), mire(), leywisp(), bandit(), straggler()), each
building a 40×40 Canvas from PAL colours, calling cv.outline(...), and
saving to assets/sprites/enemy_*.png; gen() also composes a
_contact_enemies.png sheet. To add or reshape a sprite, add/edit a draw
function and add it to that file's gen() cell list. Requires Pillow (PIL).
Note: the enemy sprites and the enemy stats are separate concerns. New art in
gen_enemies.pydoes not add an enemy to combat — that still needs theEnemyKindwiring insrc/world/bestiary.cpp(and a matchingenemies.mdcodex entry, and aspritepath pointing at your new PNG).
Voice, SFX, music, and ambience are generated by
tools/gen_audio_elevenlabs.py through the official ElevenLabs SDK into
assets/audio/. The shipped game bundles the generated audio and needs no key
at runtime — you only need a key to regenerate.
The key is read, in order, from:
- the
ELEVENLABS_API_KEYenvironment variable, or - the first non-blank line of
elevenlabs-api-key.txtat the repo root.
If neither yields a key the script exits with No ElevenLabs API key found. The
key is never printed and never committed — keep elevenlabs-api-key.txt out
of version control.
$env:ELEVENLABS_API_KEY = "sk-..." # session-only; do not commit
cd C:\Projects\the_masters_shadow-main\tools
python gen_audio_elevenlabs.py # generate anything missing/changedMANIFEST in the script lists every audio asset as a dict with a path, a
kind (tts | sfx | music), and the generation inputs (text, plus voice
for TTS, dur for SFX, ms for music). TTS lines are cast to voice roles
(master, narrator, lady, fairy, rebel, goblin) which
pick_voices() scores against the account's available voices.
Output is cached by content hash. For each asset the script computes a SHA-1
over kind + voice + text + dur + ms, writes it to
tools/.audio_cache/<name>.sig, and on the next run skips any asset whose
existing .sig still matches. So re-runs only regenerate what actually changed
— edit a line's text and only that clip is remade.
Flags:
| Command | Effect |
|---|---|
python gen_audio_elevenlabs.py |
Generate anything missing or changed |
python gen_audio_elevenlabs.py --force |
Regenerate everything (ignores the cache) |
python gen_audio_elevenlabs.py --only voice |
Only assets whose path contains voice |
python gen_audio_elevenlabs.py --list |
Print the manifest, make no API calls |
To add a line, append a dict to MANIFEST (pick an existing voice role for
TTS), then run without --force — the hash cache leaves every unchanged clip
untouched and generates only your new one. Requires the elevenlabs Python SDK.
After editing, sanity-check that the loader still sees the counts it expects. The
content tests live in tests/test_content.cpp and assert, among other things,
by_type("lineage") == 6, by_type("enemy") == 8, and by_type("event") >= 20.
Build and run the test target:
cmake --build build --target ms_tests
build\ms_tests # 46 doctest tests, including the content-load checksCommon failures and their causes:
- A record vanished — check that its separator is a lone
---(trimmed), and that it has bothid:andtype:. - A number reads as 0 / a default — you appended a unit or comment to the
value. Strip it; use a bare signed integer like
+5or-3. - A new event never fires — confirm
type: event(notevnt) and thattext:is present; the roll is only ~9% per non-ambush move, so test across several days. - Replays diverge /
state_hashchanged — expected whenever you add, remove, or reordereventrecords, since that shifts which record a given RNG roll selects.
"Keep to your chores, apprentice." Author well, and the wood remembers.