Skip to content

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.

  1. Cross-run bestiary. First-EVER sighting pops the card; the entry stays unlocked forever. Persisted in the meta save (seen_enemies set).
  2. Freeze to show. On first encounter the sim freezes (same idiom as the existing WeaponInfoOverlay); a button press dismisses and resumes.
  3. Scripted render-side preview. No mini-sim. A per-archetype canned animation drives the silhouette’s movement + scripted shots. Render-only.
  4. 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.json has drifted ahead of tools/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.

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_NAMES is 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.

  • 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; uses FileAccess/JSON, so it lives outside /sim) — loads + validates data/bestiary.json, merges prose with live ContentDB stats → a BestiaryDB.

    • validate(raw: Dictionary) -> Array returns a problem list with no push_error (test seam — avoids the GUT push_error-fails-the-test trap).
    • load(content: ContentDB) -> BestiaryDB push_errors the problem list + still returns a usable DB (fail-loud but never crash the game).
    • Tolerant fallback: a type_id with no bestiary entry yields a generic placeholder entry (name from ContentDB, generic desc/counter) + a console warning. A new enemy added later never crashes the codex.
  • content/bestiary_db.gd (BestiaryDB, plain RefCounted, render-side) — merged data holder:

    • entry_for_type(type_id) -> Dictionary and entry_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) -> Array returns 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.
  • 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 a CodexPreview. The structural twin of WeaponDetailView. Neon styling via NeonTheme.

  • ui/codex_overlay.gd (CodexOverlay, CanvasLayer) — two modes sharing one neon panel + tvOS-safe nav (stick/d-pad/keys, debounced — mirrors WeaponInfoOverlay):

    • 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() -> bool for the freeze gate.
  • meta/meta_state.gd — add seen_enemies: Dictionary (used as a set), bump SCHEMA_VERSION, add mark_seen(key) / has_seen(key), round-trip in to_dict/from_dict. Old saves with no field migrate to an empty set.

  • main.gd

    • Instantiate CodexOverlay + build the BestiaryDB once in _ready (persistent across _new_run’s child sweep, like meta/quality_manager).
    • In _process: scan BestiaryDB.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_process freeze gate (exactly like weapon_info.is_open()).
    • Add a toggle input to open browse mode in-run (keyboard + a controller button not already bound).
  • 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.

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.

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

  • tests/test_bestiary_loader.gd — real data/bestiary.json validates; every spawnable type_id (excluding CUSTOM) resolves to an entry; ref-integrity to ContentDB; validate() returns problems with no push_error; a key with a missing entry yields the tolerant fallback (no crash).
  • tests/test_meta_state.gd (extend) — seen_enemies round-trips through to_dict/from_dict; mark_seen/has_seen; migration from a pre-field save (defaults to empty). Do not exercise any MetaStore.save_state path (it writes the real user://meta.json — see the cycle-18 gotcha).
  • tests/test_codex_detection.gd — the pure first_encounters helper: excludes TYPE_CUSTOM, excludes already-seen keys, returns each new key once, preserves pool order.
  • Determinism — no /sim change, so the pinned baseline (1405185210 / 3122397125) must be byte-identical; re-run tests/test_determinism_checksum.gd and confirm (zero expected movement).
  • Test-count guardscripts/check-test-count.sh after adding the new test files (new class_name in new files → run --headless --import first to avoid the stale-class-cache silent-drop).
  • Boot smoke--headless --quit-after + grep stderr for SCRIPT ERROR.
  • 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.
  • 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-bible so it round-trips through the export pipeline (currently render-only authored content).
  • Web demo / tvOS sync handled by the normal bh-deploy ritual after merge.