Enemy Codex / Bestiary — Design
Enemy Codex / Bestiary — Design
Section titled “Enemy Codex / Bestiary — Design”Date: 2026-06-29 Status: Approved (design); ready for implementation plan Mode: Survival + Story (mode-agnostic; the codex reads the live enemy pool, which both modes populate)
Give the player an in-game encyclopedia of enemies. The first time the player ever encounters a new enemy (across all runs), the game pauses and shows a card with everything they need to know — what it is, its stats, how it attacks, and how to counter it — alongside a live scripted preview of the enemy flying around and shooting. Every encountered enemy is then browsable from the pause menu (in-run) and the start menu (between runs). Encounters persist across runs.
Decisions (locked with Chris)
Section titled “Decisions (locked with Chris)”- Cross-run bestiary. First-EVER sighting pops the card; the entry stays
unlocked forever. Persisted in the meta save (
seen_enemiesset). - Freeze to show. On first encounter the sim freezes (same idiom as the
existing
WeaponInfoOverlay); a button press dismisses and resumes. - Scripted render-side preview. No mini-sim. A per-archetype canned animation drives the silhouette’s movement + scripted shots. Render-only.
- Browse from pause menu AND start menu. Plus the auto-popup on first sight.
Load-bearing constraint — zero /sim changes
Section titled “Load-bearing constraint — zero /sim changes”First-encounter detection is a render-side scan of sim.enemies.type_id
(already exposed and read every frame). The deterministic sim is a read source
only; the sole state written is the meta save. Therefore the pinned determinism
baseline (snapshot_string().hash() = 1405185210, state_checksum() = 3122397125)
must be byte-identical after this work — it is re-run and confirmed as part
of the build ritual, with zero expected movement. Any change that would touch
/sim is out of scope for this feature.
Where the data lives — data/bestiary.json (not bible.json)
Section titled “Where the data lives — data/bestiary.json (not bible.json)”The codex prose is authored, presentational content. It does not go in the
sim’s bible.json, for two reasons:
- It is render/UI content, not sim content (keeps the boundary clean).
data/bible.jsonhas drifted ahead oftools/design-bible/src/seed.js; adding fields there deepens the drift and a future re-export would silently drop them.
So a new dedicated data/bestiary.json, keyed by a stable codex key per
enemy/boss, holding ONLY prose. Numeric stats are pulled live from
ContentDB.enemy(id) so they never drift from balance changes. Bosses (which are
not in the bible enemies array) carry an explicit stats block in their
bestiary entry.
data/bestiary.json schema (schemaVersion 1)
Section titled “data/bestiary.json schema (schemaVersion 1)”{ "schemaVersion": 1, "entries": { "<codex-key>": { "name": "Display Name", // optional; falls back to ContentDB name "threat": 3, // 1..5 tier (UI dots) "desc": "What it is and how it attacks. 1–2 sentences.", "counter": "How to beat it. 1 sentence.", "stats": { // REQUIRED only for keys with no ContentDB enemy (bosses) "hp": 1500, "speed": 40, "contact_damage": 40, "armor": 0, "element": "void" } } }}desc/counter/threat are required for every entry. stats is required
only when the key does not resolve to a bible enemies entry.
Codex keys & the TYPE_* → key map
Section titled “Codex keys & the TYPE_* → key map”The enemy pool’s type_id maps to a codex key (stable, matches bible enemies
ids where one exists). Defined as a LUT in BestiaryDB:
| TYPE_* | key | stats source |
|---|---|---|
| SWARMER..LANCER, GHOST, ACCUMULATOR, TANK_MISSILE (mobs) | bible id (swarmer, tank, …, lancer, ghost, accumulator, tank_missile) |
live ContentDB.enemy(id) |
| BOSS (6) | warden |
stats block in bestiary.json |
| BOSS2 (15) | sentinel |
stats block in bestiary.json |
| FUNZO (19), GRAVITON (20), EYE (21) | funzo / graviton / eye |
bible enemies entry if present, else stats block |
| CUSTOM (22) | — (excluded; no codex entry) | — |
Note:
EnemyPool.TYPE_NAMESis a telemetry label array and is not the bible id (e.g. index 1 = “pyromancer”, not “tank”). Do not use it for codex keys — use the explicit LUT.
Components
Section titled “Components”New files
Section titled “New files”-
data/bestiary.json— authored prose, ~22 entries (20 mobs +warden+sentinel; the 3 Chasm bosses already have type ids). Hand-edited (tab-indented python round-trip for clean diffs). -
content/bestiary_loader.gd(BestiaryLoader, render-side; usesFileAccess/JSON, so it lives outside/sim) — loads + validatesdata/bestiary.json, merges prose with liveContentDBstats → aBestiaryDB.validate(raw: Dictionary) -> Arrayreturns a problem list with nopush_error(test seam — avoids the GUTpush_error-fails-the-test trap).load(content: ContentDB) -> BestiaryDBpush_errors the problem list + still returns a usable DB (fail-loud but never crash the game).- Tolerant fallback: a
type_idwith no bestiary entry yields a generic placeholder entry (name fromContentDB, generic desc/counter) + a console warning. A new enemy added later never crashes the codex.
-
content/bestiary_db.gd(BestiaryDB, plainRefCounted, render-side) — merged data holder:entry_for_type(type_id) -> Dictionaryandentry_for_key(key) -> Dictionary(merged prose + stats + element color + behavior + attack descriptor).key_for_type(type_id) -> String(the LUT;""for excluded/custom).all_keys() -> Array(stable display order for the browse list).- Pure detection helper (static, no nodes — unit-testable headless):
first_encounters(type_ids: PackedInt32Array, count: int, seen: Dictionary) -> Arrayreturns each new codex key present (excluding CUSTOM and already-seen), de-duplicated, in pool order.
-
render/codex_preview.gd(CodexPreview,Node2D) — the scripted “flying + shooting” loop for one entry. Render-only, no sim, no RNG that affects gameplay.- Draws the enemy silhouette using
ArchetypeRenderer’s polygon point-helpers (or a local equivalent), tinted by the element color, with the additive-glow look consistent with the game. - Movement archetype from
behavior: WALK → drift across the box; DASH → charge (telegraph) then lunge; SKIRMISH → strafe at mid-range; RUSH → zigzag bursts; GHOST → fade + teleport; HOMING → curving arc; BOSS → slow hover. - Scripted shots from the attack data present:
proj_*→ single bolt;pellets/spread→ fan;bomb_*→ lobbed bomb with a ground telegraph;beam_*→ sweeping beam;orbit_*→ orbiting shard ring;web→ trail; none → melee lunge only. Aims at a faux “player” marker that the enemy circles. - Loops indefinitely; bounded to the preview viewport box.
- Draws the enemy silhouette using
-
ui/codex_card.gd(CodexCard) — builds the read-only detail Control for one merged entry: name, threat-tier dots, element, stat rows (HP / speed / contact / armor),desc,counter, and hosts aCodexPreview. The structural twin ofWeaponDetailView. Neon styling viaNeonTheme. -
ui/codex_overlay.gd(CodexOverlay,CanvasLayer) — two modes sharing one neon panel + tvOS-safe nav (stick/d-pad/keys, debounced — mirrorsWeaponInfoOverlay):- First-encounter mode (
show_new(keys: Array)): a single “NEW ENEMY” card with the preview; freezes the sim; dismiss (remote click / Esc / toggle) advances to the next queued key, then resumes. Queue-driven if several new types appear in the same frame. - Browse mode (
open_browse(seen, db)): left list of all entries (seen = name; unseen =???placeholder); right card for the selection; close to return. Opened from the pause menu and start menu. is_open() -> boolfor the freeze gate.
- First-encounter mode (
Changed files
Section titled “Changed files”-
meta/meta_state.gd— addseen_enemies: Dictionary(used as a set), bumpSCHEMA_VERSION, addmark_seen(key)/has_seen(key), round-trip into_dict/from_dict. Old saves with no field migrate to an empty set. -
main.gd- Instantiate
CodexOverlay+ build theBestiaryDBonce in_ready(persistent across_new_run’s child sweep, likemeta/quality_manager). - In
_process: scanBestiaryDB.first_encounters(sim.enemies.type_id, sim.enemies.count, meta.seen_enemies). If non-empty AND no other overlay is up (not _paused_for_levelup and not _paused_for_menu and not weapon_info.is_open() and not _story_won), freeze +codex.show_new(keys),meta.mark_seen(k)for each,MetaStore.save_state(meta). - Add
codex.is_open()to the_physics_processfreeze gate (exactly likeweapon_info.is_open()). - Add a toggle input to open browse mode in-run (keyboard + a controller button not already bound).
- Instantiate
-
ui/pause_menu.gd— add a “Bestiary” entry →codex.open_browse(...)(hide the pause overlay while it’s up, restore on close — same pattern the pause menu already uses for the shop entry). -
ui/start_menu.gd— add a “Bestiary” entry →codex.open_browse(...)between runs.
Data flow
Section titled “Data flow”sim.enemies.type_id ──(render scan, _process)──▶ BestiaryDB.first_encounters(seen) │ │ new key? │ freeze sim + CodexOverlay.show_new(keys) │ meta.mark_seen(k) → MetaStore.save ▼ContentDB.enemy(id) ─┐data/bestiary.json ──┴─▶ BestiaryLoader ─▶ BestiaryDB ─▶ CodexCard ─▶ CodexPreview (scripted)One-way and render-only. The sim is read; the only write is seen_enemies.
Freeze semantics
Section titled “Freeze semantics”Reuse the weapon_info idiom exactly: a codex.is_open() check in
_physics_process halts ticking; dismiss resumes. First-encounter and browse
share the same freeze. Stacking is guarded — the auto-popup will not fire
while a level-up, pause, weapon-info, or victory overlay is up; it waits until the
field is clear. mark_seen runs only when the card is actually shown, so an
encounter deferred behind another overlay is simply re-detected on later frames
until it can be shown (no risk of a missed enemy being silently marked seen).
Testing (TDD, per bh-dev-chunk)
Section titled “Testing (TDD, per bh-dev-chunk)”tests/test_bestiary_loader.gd— realdata/bestiary.jsonvalidates; every spawnabletype_id(excluding CUSTOM) resolves to an entry; ref-integrity toContentDB;validate()returns problems with nopush_error; a key with a missing entry yields the tolerant fallback (no crash).tests/test_meta_state.gd(extend) —seen_enemiesround-trips throughto_dict/from_dict;mark_seen/has_seen; migration from a pre-field save (defaults to empty). Do not exercise anyMetaStore.save_statepath (it writes the realuser://meta.json— see the cycle-18 gotcha).tests/test_codex_detection.gd— the purefirst_encountershelper: excludesTYPE_CUSTOM, excludes already-seen keys, returns each new key once, preserves pool order.- Determinism — no
/simchange, so the pinned baseline (1405185210/3122397125) must be byte-identical; re-runtests/test_determinism_checksum.gdand confirm (zero expected movement). - Test-count guard —
scripts/check-test-count.shafter adding the new test files (newclass_namein new files → run--headless --importfirst to avoid the stale-class-cache silent-drop). - Boot smoke —
--headless --quit-after+ grep stderr forSCRIPT ERROR.
Scope guard (YAGNI)
Section titled “Scope guard (YAGNI)”- No new sim mechanics; no kill-count / encounter-count stats per enemy.
- Preview is a scripted approximation — not faithful boss attack patterns.
- No audio for the popup (reuse an existing UI cue at most, if trivial).
- Custom (dev-built,
TYPE_CUSTOM) enemies get no codex entry. - Locked browse entries show as
???silhouettes (a filling-up bestiary reads better than a hidden list) — they are listed, not omitted.
Deferred / future
Section titled “Deferred / future”- Per-enemy encounter stats (kills, deaths-to, first-seen timestamp).
- Faithful mini-sim preview (if the scripted version ever feels too canned).
- Boss attack-pattern variety in the preview.
- Authoring the bestiary copy into
tools/design-bibleso it round-trips through the export pipeline (currently render-only authored content). - Web demo / tvOS sync handled by the normal
bh-deployritual after merge.