Config used to be strictly per-story: everything under
config/stories/{story}/, nothing shared, resolved by hardcoded
f"config/stories/{story}/..." paths. A story may now opt into shared
modules so several stories draw on one copy of an asset kit.
Source of truth for the resolver: game/config_source.py.
story.json:
{
"id": "the_long_silence",
"name": "The Long Silence",
"modules": ["figures-human", "audio-core"],
...
}
Each name is a directory config/modules/<name>/ whose subtree mirrors a
story’s own — graphics/body/*.json, ship_types.json, audio.json, and
so on. Order matters: earlier modules win over later ones, and the story
always wins over every module.
Each module carries a module.json:
{ "name": "figures-human", "version": "1.0.0", "description": "...",
"modules": ["figures-base"] }
module.json’s own "modules" list works exactly like a story’s.
config_source.resolved_modules(story) flattens the whole tree: a depth-first,
pre-order walk (a module, then its own dependencies, then the story’s later
modules), de-duplicated keeping the first occurrence. The precedence
contract is unchanged — the story always wins, and an earlier-listed module (or
dependency) wins over a later one. A dependency cycle raises ValueError
naming the chain.
story_modules(story) still returns only the story’s direct list (it seeds
the walk); everything else — _search_roots, story_catalogue, story_meta,
module_versions — consumes resolved_modules.
| Kind of file | Resolver | Rule |
|---|---|---|
Per-name pipeline files — graphics/<kind>/<name>.json (ships, stations, bodies, articles, sets, palettes, faces, collision, interiors, decorations, floor_patterns) |
config_source.story_path(story, *relparts) |
First existing of story/<rel>, then modules/<m>/<rel> in declared order. Whole-file override: a story shadows a kit file by dropping its own copy at the same path. Falls back to the story path if nobody has it, so “missing file” is unchanged. |
Flat {id: entry} catalogues — graphics.json, cultures.json, commodities.json, items.json, ship_types.json, ship_outfits.json, building_types.json, asteroid_types.json, events.json, pilots.json, missions.json, factions.json, endings.json, graphics/materials.json, audio.json |
config_source.story_catalogue(story, filename) |
Deep-merge: every module’s dict layered under the story’s, entry by entry (nested dicts merge, lists/scalars replace). A story overrides or extends one entry without copying the file. Result is cached and shared — treat read-only, exactly like load_json. |
systems/*.json is not shared — inherently story-specific — and loads by
direct path. story.json is a special case: a module may ship a partial
story.json (tuning blocks only) that config_source.story_meta() merges
under the real story’s, so get_story() sees module defaults with every
story key still winning.
| Module | Provides | Used by |
|---|---|---|
figures-human |
Just the human figure now: the walk rig, masc/femme bodies, generic wardrobe articles/items/sets, faces, palettes, base materials.json. the_long_silence keeps ~114 faction-specific files locally but no kit shadows — its 10 hand-tuned kit files (human_femme, draw_order, eyes_almond / lips_full / nose_soft, badge / buttons / hair_short / pants / stand_collar) replaced the module copies in 1.1.0, so both consuming stories now share them. (The old module human_femme/draw_order carried extra leg-pants fitting curves/layers that nothing referenced; recover from git if ever needed.) Its settlement/scenery art moved to orbital-std in 2.0.0 — see that entry. |
the_long_silence, the_whisper_line, test_story |
orbital-std |
Settlement/scenery art plus the flat-catalogue kind of sharing (graphics.json/cultures.json/building_types.json/ship_types.json, not the per-name graphics/<kind>/<name>.json kind figures-human provides for wardrobe) — the orbital_std/regolith_std cultures, the courier ship + trade_ring station (and their collision), regolith_moon, admin_office/housing_block/market_hall buildings, the shared furniture set (bench/column/planter/lamp_post/crates, decorations + collision), and two walkable interiors (concourse, settlement). This used to be bundled inside figures-human under a misleading name — it was pulled out in 2.0.0 since none of it is a human figure or wardrobe piece; any story using this scenery (including the_long_silence, for its five generic furniture building types) must now declare orbital-std explicitly rather than getting it for free from figures-human. |
the_whisper_line, mining_101, the_long_silence |
audio-core |
The default SoundBoard recipes and the two ambient music loops, as data (audio.json). See SOUND.md. |
all five stories |
ships-core |
The standard ship-equipment kit — ship_outfits.json (weapons/engines/utility) and asteroid_types.json — that default and the_long_silence had entry-for-entry identical. Ship hulls stay per-story (no two stories share one). |
all five stories |
common-goods |
items.json — the personal inventory items every story carries (repair_kit, medkit, star_chart, engraved_flask). |
all five stories |
story-defaults |
A partial story.json: camera + interior zoom bounds, walking_speed, the jump drive constants. Merged under each story’s own story.json. |
all five stories |
system-events |
events.json — the shared catalogue a system’s "events" block references by id (see architecture/config-formats.md’s “System events”). Three kinds so far: "special_asteroid" (rich_ore_vein) and "pirate_ambush" (lone_pirate, a story-authored pilot demands tribute or fights — see architecture/combat-and-mining.md), both defined directly in this module’s own events.json. "derelict_ship" instead follows a three-layer split (below) — this module owns only the mechanism, no concrete derelict types. More kinds (anomalies, wormholes) land here later, each with its own spawn code. |
mining_101 |
ls-authority, ls-combine, ls-drift, ls-vigil, ls-warden, ls-carrier |
One module per the_long_silence culture, each a complete culture kit: graphics/{articles,buildings,palettes,sets,ships,stations,floor_patterns}/<pfx>_* per-name files, plus that culture’s slice of the flat catalogues (graphics.json’s ships/space_stations/outfits, building_types.json’s <pfx>_hall/<pfx>_housing/<pfx>_spire, ship_types.json’s <pfx>_courier/<pfx>_hauler/<pfx>_patrol). Split out (2026-09-12) from files that used to live directly under config/stories/the_long_silence/, so a future story wanting just one culture’s look (as the_whisper_line already does — it depends on ls-authority alone for its single floor_pattern: "authority" station) can take it without the other five. Replaces the old long-silence-floors module, whose six floor_patterns/<name>.json files are now one per culture module instead of bundled separately. cultures.json/factions.json/commodities.json/moons and the narrative catalogues (missions.json, dispatches.json, endings.json) stayed in the story — not graphics, and not obviously reusable outside this story’s plot. |
the_long_silence (all six); the_whisper_line (ls-authority only) |
Most flat catalogues (ship_outfits.json, asteroid_types.json, the
system-events module’s own special_asteroid/pirate_ambush entries) are
two-layer: the module defines concrete, reusable entries, and a story either
uses them as-is or adds/overrides its own via a same-named story-scoped file
(story_catalogue’s ordinary merge). That’s not always the right split —
"derelict_ship" (the system-events module’s third event kind) needs a
third layer, since its concrete entries reference ids (a trap’s pirate
ship/pilot) that are inherently story-specific and don’t belong baked into a
shared module:
config/modules/system-events/) defines the general
mechanism only — what a "derelict_ship" kind means (spawn placement,
targeting-range gate, boarding, the three possible outcomes) — and no
concrete derelict types of its own.config/stories/{story}/events.json, merged over the module’s
events.json via the same story_catalogue two-layer merge every other
flat catalogue uses) defines the actual named derelict types — their
"outcome", ship/pilot references, loot table or payout range. This is
the layer that would otherwise have to live in the module if derelict
types were two-layer like everything else.systems/*.json) references a type by id in its "events"
array, the same lightweight {"event": <id>, "frequency": ...} idiom
every other event kind already uses — letting several systems in one
story reuse the same derelict-type definition.See architecture/config-formats.md’s
“System events” section for the full field-by-field shape, and
config/stories/mining_101/events.json for the worked example
(adrift_hauler/stranded_skiff/suspect_wreck). A future config need with
the same “module mechanism, story-specific concrete data” shape should
follow this same three-layer split rather than stretching the two-layer
override pattern to fit.
game/utils.py — every get_* catalogue accessor calls story_catalogue.
clear_json_cache() also clears the merged-catalogue cache.game/graphics/story_assets.py — _load() resolves via story_path;
_materials() merges via story_catalogue.game/audio/sound_board.py / music.py — load audio-core/audio.json at
construction; SpaceScreen.__init__ then calls sound_board.apply_story()
/ music.apply_story() to layer a story’s own audio.json.A save records game_state["module_versions"] ({name: version} from each
module’s module.json) — the full resolved tree, dependencies included.
On load, main.py’s
warn_if_module_version_mismatch() warns — non-blocking, alongside the story
version check — when a module the story now uses has changed version since the
save. Bump a module’s module.json version whenever a change to it would
make an existing save’s stored state mean something different (the same
criteria as bumping story.json’s version — see
SAVE_SYSTEM.md). A module change affects
every story that lists it, so bump deliberately.
depends on: line linking to those modules, so you can jump
straight to the kit. It also has new story… / new module… buttons (needs
the repo open read-write).story_catalogue returns a cached, shared dict. Callers that mutate an
entry already dict(...)-copy it first (same contract as load_json);
keep it that way.mkdir config/modules/<name>/, add module.json (with its own
"modules": [...] if it builds on another kit)."<name>" to each consuming story’s story.json "modules".python run_tests.py, then run each consuming story in-game
(WORKFLOW.md) and eyeball the affected art/audio.commodities.json is deliberately still per-story — the two real stories’
economies barely overlap (only ore, and its text differs).