Skip to content

Enemy Codex / Bestiary Implementation Plan

Enemy Codex / Bestiary Implementation Plan

Section titled “Enemy Codex / Bestiary Implementation Plan”

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: A cross-run enemy encyclopedia — a freeze-to-show card on first-ever encounter (enemy attributes + counters + a live scripted preview), plus a browsable codex from the pause and start menus.

Architecture: Render-only by construction. First-encounter detection scans sim.enemies.type_id (already read every frame); the only persisted state is a seen_enemies set in the meta save. Authored prose lives in a new data/bestiary.json (kept out of the sim’s bible.json); numeric stats are pulled live from ContentDB. The deterministic sim is never modified.

Tech Stack: Godot 4.6.3, typed GDScript, GUT 9.6.0 (headless tests). Design spec: docs/superpowers/specs/2026-06-29-enemy-codex-design.md.

  • Zero /sim changes that touch the tick path. The pinned determinism baseline is snapshot_string().hash() = 1405185210, state_checksum() = 3122397125 (tests/test_determinism_checksum.gd). Every task must leave it byte-identical — re-run and confirm.
  • /sim purity: the one sim-dir file touched (sim/meta_state.gd) is NOT in the tick path (apply_to runs render-side at run start). It must stay extends RefCounted with no Node/Engine/Time/File APIs. All other new code is render-side (content/, render/, ui/).
  • Do NOT bump MetaState.SCHEMA_VERSION. Adding seen_enemies is additive (forward+backward compatible). Bumping to 2 would make older builds see schema 2 > 1 in from_dict and reset the entire save (lose banked gold). Keep it at 1. (This supersedes the spec’s “bump SCHEMA_VERSION” line.)
  • GUT gotchas: an un-asserted push_error FAILS the test — validators expose a non-erroring validate() seam. Test typos like assert_le (real name assert_lte) silently drop a whole file — trust scripts/check-test-count.sh, not just “all passed”. After adding a class_name in a new dir, run godot --headless --path . --import before tests or the new class won’t register.
  • Content copy rules (in-game flavor is exempt from the no-em-dash marketing rule, but keep it tight and human): short, plain, no filler.
  • macOS note: timeout is not on PATH — it is gtimeout. A timeout-wrapped smoke check silently no-ops.

Per-task verification ritual (bh-dev-chunk)

Section titled “Per-task verification ritual (bh-dev-chunk)”

Every task ends with this sequence (commands assume cwd /Users/chris/Claude/bullet-heaven):

Terminal window
# 1. If a new class_name file was added in a new dir, refresh the class cache FIRST:
godot --headless --path . --import 2>&1 | grep -i "error" || echo "import clean"
# 2. Run this task's test file(s):
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/<file>.gd -gexit
# 3. Full suite + test-count guard (catches silent-dropped files):
scripts/check-test-count.sh
# 4. Boot smoke — boots + ticks the real sim; must show no SCRIPT ERROR:
godot --headless --path . --quit-after 120 2>&1 | grep "SCRIPT ERROR" && echo "BOOT FAIL" || echo "boot clean"
# 5. Determinism — MUST stay 1405185210 / 3122397125 (no /sim tick change):
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexit
# 6. Commit (see each task's message).

Expected at step 5 for every task: both baseline asserts PASS, unchanged.


File Responsibility Task
sim/meta_state.gd (modify) Persist the cross-run seen_enemies set 1
data/bestiary.json (create) Authored prose + boss stat blocks, keyed by codex key 2
content/bestiary_loader.gd (create) Load + validate bestiary.json, merge with live ContentDBBestiaryDB 2
content/bestiary_db.gd (create) Merged-entry lookup, TYPE_*→key LUT, first_encounters detection 2,3
render/codex_preview.gd (create) Scripted “flying + shooting” animation for one entry 4
ui/codex_card.gd (create) Read-only detail Control hosting the preview 5
ui/codex_overlay.gd (create) Two-mode overlay (first-encounter freeze + browse) 6
main.gd (modify) Instantiate, detect, freeze-gate, toggle 7
ui/pause_menu.gd + ui/start_menu.gd (modify) “Bestiary” menu entries 8

Task 1: Persist seen_enemies in the meta save

Section titled “Task 1: Persist seen_enemies in the meta save”

Files:

  • Modify: sim/meta_state.gd
  • Test: tests/test_meta_state.gd (extend if present; else create)

Interfaces:

  • Produces: MetaState.seen_enemies: Dictionary (key→true set); MetaState.has_seen(key: String) -> bool; MetaState.mark_seen(key: String) -> void. Round-trips through to_dict/from_dict as a JSON array under key "seen_enemies".

  • Step 1: Write the failing test. Append to tests/test_meta_state.gd (create with the header below if the file does not exist):

extends GutTest
# (only add this header block if creating the file fresh)
# func _make() -> MetaState: return MetaState.new()
func test_seen_enemies_defaults_empty() -> void:
var m := MetaState.new()
assert_false(m.has_seen("ghost"), "fresh save has seen nothing")
func test_mark_and_has_seen() -> void:
var m := MetaState.new()
m.mark_seen("ghost")
assert_true(m.has_seen("ghost"))
assert_false(m.has_seen("tank"))
func test_seen_enemies_round_trips() -> void:
var m := MetaState.new()
m.mark_seen("ghost")
m.mark_seen("tank")
var d := m.to_dict()
var m2 := MetaState.new()
m2.from_dict(d)
assert_true(m2.has_seen("ghost"))
assert_true(m2.has_seen("tank"))
assert_false(m2.has_seen("eye"))
func test_old_save_without_field_migrates_empty() -> void:
# A save written before this feature has no "seen_enemies" key.
var m := MetaState.new()
m.from_dict({"schema": 1, "banked_gold": 50, "levels": {}})
assert_false(m.has_seen("ghost"), "missing field → empty seen set, no crash")
assert_eq(m.banked_gold, 50, "other fields still load")
  • Step 2: Run it, verify it fails.

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_meta_state.gd -gexit Expected: FAIL — has_seen/mark_seen not defined.

  • Step 3: Implement. In sim/meta_state.gd:

Add the field after var selected_decoy ... (line ~19):

# Codex: set of enemy codex keys the player has ever encountered (key -> true).
# Additive save field — absent in older saves (loads as empty). Render-only use.
var seen_enemies: Dictionary = {}
func has_seen(key: String) -> bool:
return seen_enemies.has(key)
func mark_seen(key: String) -> void:
seen_enemies[key] = true

In to_dict() add "seen_enemies" (store the keys as a JSON array):

func to_dict() -> Dictionary:
return {"schema": SCHEMA_VERSION, "banked_gold": banked_gold, "levels": levels.duplicate(),
"tutorial_done": tutorial_done, "selected_decoy": selected_decoy,
"seen_enemies": seen_enemies.keys()}

In from_dict() (after the levels block) add:

seen_enemies = {}
var se: Variant = d.get("seen_enemies", [])
if se is Array:
for k in se:
seen_enemies[str(k)] = true
  • Step 4: Run the test, verify it passes.

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_meta_state.gd -gexit Expected: PASS (4 new tests green).

  • Step 5: Ritual + commit. Run the full ritual (count guard, boot, determinism — baseline unchanged), then:
Terminal window
git add sim/meta_state.gd tests/test_meta_state.gd
git commit -m "feat(codex): persist cross-run seen_enemies set in the meta save
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 2: bestiary.json + loader + DB (data, LUT, merge, lookup)

Section titled “Task 2: bestiary.json + loader + DB (data, LUT, merge, lookup)”

Files:

  • Create: data/bestiary.json
  • Create: content/bestiary_loader.gd
  • Create: content/bestiary_db.gd
  • Test: tests/test_bestiary_loader.gd

Interfaces:

  • Consumes: ContentDB (ContentLoader.load_from_path("res://data/bible.json")), ElementPalette.color_for(content, idx), EnemyPool.TYPE_* constants.

  • Produces:

    • BestiaryLoader.validate(raw: Dictionary) -> Array — problem strings, NO push_error.
    • BestiaryLoader.load_from_dict(raw: Dictionary, content: ContentDB) -> BestiaryDB
    • BestiaryLoader.load_from_path(path: String, content: ContentDB) -> BestiaryDB
    • BestiaryDB.key_for_type(type_id: int) -> String ("" = excluded/custom/unknown)
    • BestiaryDB.entry_for_key(key: String) -> Dictionary (merged entry, see contract)
    • BestiaryDB.entry_for_type(type_id: int) -> Dictionary
    • BestiaryDB.all_keys() -> Array (stable display order)
  • Merged-entry contract (every consumer reads exactly these keys): { "key": String, "name": String, "threat": int, "desc": String, "counter": String, "element": String, "element_color": Color, "hp": float, "speed": float, "contact_damage": float, "armor": float, "move": String, "attack": String, "raw": Dictionary }

  • Step 1: Create data/bestiary.json with all 22 entries:

{
"schemaVersion": 1,
"entries": {
"swarmer": { "name": "Swarmer", "threat": 1, "move": "walk", "attack": "melee",
"desc": "A weak fire chaser that always comes in packs and dies in a hit or two.",
"counter": "Trivial alone. Keep moving so a swarm can't surround you." },
"tank": { "name": "Tank", "threat": 3, "move": "walk", "attack": "missiles",
"desc": "Slow, armored fire wall that launches homing missiles from range.",
"counter": "Shoot the missiles down (they're killable) or juke them, then chip the armor with elemental reactions." },
"shooter": { "name": "Shooter", "threat": 2, "move": "walk", "attack": "bolt",
"desc": "Hangs back and fires lightning bolts at you.",
"counter": "Close the gap or strafe perpendicular to its line of fire." },
"splitter": { "name": "Splitter", "threat": 2, "move": "walk", "attack": "melee",
"desc": "A poison blob that bursts into smaller blobs when it dies.",
"counter": "Expect the split — have an AoE or a wide weapon ready to clear the children." },
"elite": { "name": "Elite", "threat": 3, "move": "dash", "attack": "melee",
"desc": "A void dasher: it charges, telegraphs a line, then lunges fast.",
"counter": "Watch the brightening telegraph and sidestep the lunge — it commits to a straight line." },
"spider": { "name": "Spider", "threat": 3, "move": "dash", "attack": "web",
"desc": "A fast decay dasher that lays a trail of slowing webs.",
"counter": "Don't get pinned in the webs. Kill it quickly and kite off the sticky ground." },
"skirmisher": { "name": "Skirmisher", "threat": 3, "move": "skirmish", "attack": "bolt",
"desc": "An aether mini-boss that strafes at mid-range, fires aimed shots, and drops a powerup when killed.",
"counter": "Pressure it between shots and close in to break its strafe — the powerup is worth it." },
"brute": { "name": "Brute", "threat": 4, "move": "walk", "attack": "melee",
"desc": "A huge, slow blood wall with heavy armor and a deep health pool.",
"counter": "Kite it or focus it with your strongest weapon. Reactions bypass armor — lean on them." },
"rusher": { "name": "Rusher", "threat": 3, "move": "rush", "attack": "melee",
"desc": "A relentless aether charger that re-aims and bursts at you with no recovery pause.",
"counter": "Move perpendicular to its charge — it overshoots and has to re-aim before it can hit." },
"zapper": { "name": "Zapper", "threat": 3, "move": "rush", "attack": "bolt",
"desc": "A fast lightning rusher that fires a bolt mid-charge.",
"counter": "Dodge sideways from the charge and punish the brief window while it re-aims." },
"scatterer": { "name": "Scatterer", "threat": 3, "move": "walk", "attack": "fan",
"desc": "A blood gunner that fires a wide fan of pellets.",
"counter": "Never stand in the cone. Flank it from the side where the spread is thin." },
"bomber": { "name": "Bomber", "threat": 3, "move": "walk", "attack": "bomb",
"desc": "A slow fire lobber that drops a delayed bomb on your position.",
"counter": "Keep moving — step off the telegraphed blast circle before it detonates." },
"orbiter": { "name": "Orbiter", "threat": 3, "move": "walk", "attack": "orbit",
"desc": "A cold walker ringed with spinning shards that hurt on contact.",
"counter": "Don't touch the ring. Hit it from range and never melee into the shards." },
"lancer": { "name": "Lancer", "threat": 3, "move": "walk", "attack": "beam",
"desc": "A light walker that charges up a long sweeping beam.",
"counter": "Step out of the telegraphed beam line before it fires — the wind-up gives you time." },
"ghost": { "name": "Ghost", "threat": 4, "move": "ghost", "attack": "melee",
"desc": "A void wraith: it drifts, shows a silhouette where it will strike, then teleports and dashes through that line.",
"counter": "When the silhouette appears, move PERPENDICULAR to the strike line. Running straight away won't escape it." },
"accumulator": { "name": "Accumulator", "threat": 4, "move": "dash", "attack": "melee",
"desc": "A fire dasher that grows bigger, faster and deadlier the longer it stays alive.",
"counter": "Kill it early. Every second you ignore it, it becomes a harder problem." },
"tank_missile": { "name": "Tank Missile", "threat": 2, "move": "homing", "attack": "melee",
"desc": "A killable homing missile fired by Tanks. Low health, fast, but a limited turn rate.",
"counter": "Shoot it down, or juke it — it can't corner tightly enough to follow a sharp turn." },
"warden": { "name": "Warden", "threat": 5, "move": "hover", "attack": "missiles",
"stats": { "hp": 1500, "speed": 40, "contact_damage": 40, "armor": 0, "element": "void" },
"desc": "A domain boss. It approaches, telegraphs, then swings, barrages, fires homing missiles, or rotates a spiral of shots — and enrages below 40% health.",
"counter": "Read each telegraph and dodge. Drop a decoy to pull its aggro, and try to burst it down before it enrages." },
"sentinel": { "name": "Sentinel", "threat": 5, "move": "hover", "attack": "beam",
"stats": { "hp": 1800, "speed": 45, "contact_damage": 44, "armor": 0, "element": "kinetic" },
"desc": "A second-tier boss that randomly cycles five attacks: a sweeping cutter beam, arcing artillery, shockwave rings, a charge slam, and summons.",
"counter": "Identify the attack from its telegraph each cycle. Keep distance and dodge through the gaps in the rings." },
"funzo": { "name": "FunZo", "threat": 5, "move": "hover", "attack": "melee",
"desc": "A zone-flooding clown boss. It floods the arena with growing damage zones and alternates a slow drift with fast dashes.",
"counter": "Never stop moving. Stay clear of the spreading zones and kill it before they cover the floor." },
"graviton": { "name": "Graviton", "threat": 5, "move": "hover", "attack": "fan",
"desc": "A gravity-and-darkness boss. It pulls you toward it, fires radial blobs with safe gap-lanes, and spawns orbiting satellites.",
"counter": "Steer against the pull — your input still works. Thread the gaps in its blob rings and don't let it drag you into contact." },
"eye": { "name": "The Eye", "threat": 5, "move": "hover", "attack": "beam",
"desc": "A predictive-sight boss. It fires beams that LEAD your movement, blinks to the arena edge, and lazily dashes through.",
"counter": "Break your pattern — stutter-step so its leading shots miss, and dodge back into the space you just left." }
}
}
  • Step 2: Write the failing test tests/test_bestiary_loader.gd:
extends GutTest
func _content() -> ContentDB:
return ContentLoader.load_from_path("res://data/bible.json")
func test_real_file_validates_clean() -> void:
var f := FileAccess.open("res://data/bestiary.json", FileAccess.READ)
assert_not_null(f, "bestiary.json must exist")
var raw: Variant = JSON.parse_string(f.get_as_text())
assert_true(raw is Dictionary, "bestiary.json parses to a dict")
var problems := BestiaryLoader.validate(raw)
assert_eq(problems.size(), 0, "no validation problems: %s" % str(problems))
func test_every_spawnable_type_has_an_entry() -> void:
var db := BestiaryLoader.load_from_path("res://data/bestiary.json", _content())
# Every type id from SWARMER..EYE (excluding CUSTOM) resolves to a non-empty merged entry.
for tid in range(EnemyPool.TYPE_CUSTOM): # 0..21
var key := db.key_for_type(tid)
assert_ne(key, "", "type %d must map to a codex key" % tid)
var e := db.entry_for_type(tid)
assert_true(e.has("desc") and String(e["desc"]) != "", "type %d entry has desc" % tid)
assert_true(e.has("counter") and String(e["counter"]) != "", "type %d entry has counter" % tid)
func test_custom_type_excluded() -> void:
var db := BestiaryLoader.load_from_path("res://data/bestiary.json", _content())
assert_eq(db.key_for_type(EnemyPool.TYPE_CUSTOM), "", "custom enemies have no codex key")
func test_mob_stats_pulled_live_from_content() -> void:
var content := _content()
var db := BestiaryLoader.load_from_path("res://data/bestiary.json", content)
var e := db.entry_for_key("swarmer")
assert_almost_eq(float(e["hp"]), float(content.enemy("swarmer")["hp"]), 0.001, "mob hp is live from bible")
func test_boss_stats_from_block() -> void:
var db := BestiaryLoader.load_from_path("res://data/bestiary.json", _content())
var e := db.entry_for_key("warden")
assert_gt(float(e["hp"]), 0.0, "warden hp comes from its stats block")
func test_missing_entry_falls_back_not_crash() -> void:
# A raw bestiary missing 'ghost' should still load a usable DB (placeholder entry).
var raw := {"schemaVersion": 1, "entries": {}}
var db := BestiaryLoader.load_from_dict(raw, _content())
var e := db.entry_for_key("ghost")
assert_true(e.has("name"), "fallback entry still has a name")
func test_validate_flags_bad_schema() -> void:
var problems := BestiaryLoader.validate({"schemaVersion": 99, "entries": {}})
assert_gt(problems.size(), 0, "wrong schemaVersion is a problem")
func test_all_keys_stable_order() -> void:
var db := BestiaryLoader.load_from_path("res://data/bestiary.json", _content())
assert_eq(db.all_keys().size(), 22, "22 codex keys")
assert_eq(String(db.all_keys()[0]), "swarmer", "ordered by type id, swarmer first")
  • Step 3: Run it, verify it fails.

Run: godot --headless --path . --import (register the new classes), then godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_bestiary_loader.gd -gexit Expected: FAIL — BestiaryLoader/BestiaryDB not found.

  • Step 4: Implement content/bestiary_db.gd:
class_name BestiaryDB
extends RefCounted
# Merged enemy-codex data: authored prose (bestiary.json) + live stats (ContentDB).
# Render-side (plain RefCounted — no Node/Engine APIs). The TYPE_*→key LUT and the
# first-encounter detection helper live here so they're unit-testable headless.
# Indexed by EnemyPool.TYPE_* (0..TYPE_CUSTOM). "" = no codex entry (custom/unknown).
const TYPE_KEYS: Array[String] = [
"swarmer", # 0 TYPE_SWARMER
"tank", # 1 TYPE_TANK
"shooter", # 2 TYPE_SHOOTER
"splitter", # 3 TYPE_SPLITTER
"elite", # 4 TYPE_ELITE
"spider", # 5 TYPE_SPIDER
"warden", # 6 TYPE_BOSS
"skirmisher", # 7 TYPE_SKIRMISHER
"brute", # 8 TYPE_BRUTE
"rusher", # 9 TYPE_RUSHER
"zapper", # 10 TYPE_ZAPPER
"scatterer", # 11 TYPE_SCATTERER
"bomber", # 12 TYPE_BOMBER
"orbiter", # 13 TYPE_ORBITER
"lancer", # 14 TYPE_LANCER
"sentinel", # 15 TYPE_BOSS2
"ghost", # 16 TYPE_GHOST
"accumulator", # 17 TYPE_ACCUMULATOR
"tank_missile", # 18 TYPE_TANK_MISSILE
"funzo", # 19 TYPE_FUNZO
"graviton", # 20 TYPE_GRAVITON
"eye", # 21 TYPE_EYE
"", # 22 TYPE_CUSTOM — excluded
]
var _by_key: Dictionary = {} # key -> merged entry Dictionary
func _init(merged: Dictionary) -> void:
_by_key = merged
func key_for_type(type_id: int) -> String:
if type_id < 0 or type_id >= TYPE_KEYS.size():
return ""
return TYPE_KEYS[type_id]
func entry_for_key(key: String) -> Dictionary:
return _by_key.get(key, {})
func entry_for_type(type_id: int) -> Dictionary:
return entry_for_key(key_for_type(type_id))
func all_keys() -> Array:
# Stable order = type id order (skip the empty custom slot + any unbuilt key).
var out: Array = []
for k in TYPE_KEYS:
if k != "" and _by_key.has(k):
out.append(k)
return out
# Pure first-encounter detection: the codex keys present in the pool that aren't in
# `seen`, de-duplicated, in pool order. Excludes custom/unknown (key == "").
func first_encounters(type_ids: PackedInt32Array, count: int, seen: Dictionary) -> Array:
var out: Array = []
var added: Dictionary = {}
for i in range(count):
var key := key_for_type(type_ids[i])
if key == "" or seen.has(key) or added.has(key):
continue
added[key] = true
out.append(key)
return out

(Note: first_encounters body is delivered here but only tested in Task 3 — Task 2’s tests don’t exercise it.)

  • Step 5: Implement content/bestiary_loader.gd:
class_name BestiaryLoader
extends RefCounted
# Loads data/bestiary.json (authored prose), merges with live ContentDB stats,
# and produces a BestiaryDB. Render-side (FileAccess/JSON → outside /sim).
# Fail-loud (push_error on load) but never crash: a missing/short entry yields a
# tolerant placeholder so a newly-added enemy can't break the codex.
const SCHEMA := 1
# Validate WITHOUT push_error (test seam). Returns a list of human-readable problems.
static func validate(raw: Variant) -> Array:
var problems: Array = []
if not (raw is Dictionary):
return ["bestiary.json is not a JSON object"]
if int(raw.get("schemaVersion", 0)) != SCHEMA:
problems.append("schemaVersion must be %d (got %s)" % [SCHEMA, str(raw.get("schemaVersion"))])
var entries: Variant = raw.get("entries", null)
if not (entries is Dictionary):
problems.append("entries must be an object")
return problems
for key in entries:
var e: Variant = entries[key]
if not (e is Dictionary):
problems.append("entry '%s' is not an object" % key); continue
for req in ["threat", "desc", "counter"]:
if not e.has(req):
problems.append("entry '%s' missing required '%s'" % [key, req])
# Every spawnable type id (excluding custom) must have an entry.
for tid in range(EnemyPool.TYPE_CUSTOM):
var k: String = BestiaryDB.TYPE_KEYS[tid]
if k != "" and not entries.has(k):
problems.append("no entry for spawnable type '%s' (type id %d)" % [k, tid])
return problems
static func load_from_path(path: String, content: ContentDB) -> BestiaryDB:
var f := FileAccess.open(path, FileAccess.READ)
if f == null:
push_error("BestiaryLoader: cannot open %s" % path)
return load_from_dict({"schemaVersion": SCHEMA, "entries": {}}, content)
var raw: Variant = JSON.parse_string(f.get_as_text())
return load_from_dict(raw if raw is Dictionary else {}, content)
static func load_from_dict(raw: Variant, content: ContentDB) -> BestiaryDB:
var problems := validate(raw)
if not problems.is_empty():
push_error("BestiaryLoader problems: %s" % str(problems))
var entries: Dictionary = {}
if raw is Dictionary and raw.get("entries", null) is Dictionary:
entries = raw["entries"]
var merged: Dictionary = {}
for tid in range(EnemyPool.TYPE_CUSTOM):
var key: String = BestiaryDB.TYPE_KEYS[tid]
if key == "":
continue
merged[key] = _merge_one(key, entries.get(key, {}), content)
return BestiaryDB.new(merged)
static func _merge_one(key: String, prose: Dictionary, content: ContentDB) -> Dictionary:
# Stats: live from the bible enemy if present, else the entry's own stats block.
var stats: Dictionary = prose.get("stats", {})
var has_bible := content != null and content.has_enemy(key)
var bible: Dictionary = content.enemy(key) if has_bible else {}
var src: Dictionary = bible if has_bible else stats
var element: String = String(src.get("element", prose.get("element", "")))
var el_color := ElementPalette.NEUTRAL
if content != null and element != "":
el_color = ElementPalette.color_for(content, content.element_index(element))
return {
"key": key,
"name": String(prose.get("name", bible.get("name", key.capitalize()))),
"threat": int(prose.get("threat", 1)),
"desc": String(prose.get("desc", "An enemy.")),
"counter": String(prose.get("counter", "Stay mobile and focus it down.")),
"element": element,
"element_color": el_color,
"hp": float(src.get("hp", 0.0)),
"speed": float(src.get("speed", 0.0)),
"contact_damage": float(src.get("contact_damage", 0.0)),
"armor": float(src.get("armor", 0.0)),
"move": String(prose.get("move", "walk")),
"attack": String(prose.get("attack", "melee")),
"raw": src,
}
  • Step 6: Run the test, verify it passes.

Run: godot --headless --path . --import then godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_bestiary_loader.gd -gexit Expected: PASS (8 tests green).

  • Step 7: Ritual + commit. Full ritual (count guard, boot, determinism unchanged), then:
Terminal window
git add data/bestiary.json content/bestiary_loader.gd content/bestiary_db.gd tests/test_bestiary_loader.gd
git commit -m "feat(codex): bestiary.json + loader/DB (prose + live stats, TYPE_*→key LUT)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Files:

  • Modify: content/bestiary_db.gd (the first_encounters method shipped in Task 2 — now under test)
  • Test: tests/test_codex_detection.gd

Interfaces:

  • Consumes: BestiaryDB.first_encounters(type_ids: PackedInt32Array, count: int, seen: Dictionary) -> Array (already implemented in Task 2).

  • Produces: confidence that detection excludes custom/seen, de-dupes, preserves order.

  • Step 1: Write the failing test tests/test_codex_detection.gd:

extends GutTest
func _db() -> BestiaryDB:
return BestiaryLoader.load_from_path("res://data/bestiary.json",
ContentLoader.load_from_path("res://data/bible.json"))
func test_returns_new_keys_in_order() -> void:
var db := _db()
var ids := PackedInt32Array([EnemyPool.TYPE_SWARMER, EnemyPool.TYPE_TANK])
var got := db.first_encounters(ids, 2, {})
assert_eq(got, ["swarmer", "tank"], "new keys in pool order")
func test_excludes_already_seen() -> void:
var db := _db()
var ids := PackedInt32Array([EnemyPool.TYPE_SWARMER, EnemyPool.TYPE_GHOST])
var got := db.first_encounters(ids, 2, {"swarmer": true})
assert_eq(got, ["ghost"], "seen key skipped")
func test_dedupes_within_a_frame() -> void:
var db := _db()
var ids := PackedInt32Array([EnemyPool.TYPE_SWARMER, EnemyPool.TYPE_SWARMER, EnemyPool.TYPE_SWARMER])
var got := db.first_encounters(ids, 3, {})
assert_eq(got, ["swarmer"], "duplicate type reported once")
func test_excludes_custom() -> void:
var db := _db()
var ids := PackedInt32Array([EnemyPool.TYPE_CUSTOM, EnemyPool.TYPE_EYE])
var got := db.first_encounters(ids, 2, {})
assert_eq(got, ["eye"], "custom has no codex key")
func test_honors_count_bound() -> void:
var db := _db()
# Array has a trailing TANK but count=1 → only the first slot is scanned.
var ids := PackedInt32Array([EnemyPool.TYPE_SWARMER, EnemyPool.TYPE_TANK])
var got := db.first_encounters(ids, 1, {})
assert_eq(got, ["swarmer"], "count bounds the scan")
  • Step 2: Run it, verify it passes immediately (the method already exists from Task 2 — this task is a dedicated test gate for the determinism-adjacent detection logic):

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_codex_detection.gd -gexit Expected: PASS (5 tests). If any FAIL, fix first_encounters in content/bestiary_db.gd until green.

  • Step 3: Ritual + commit. Full ritual (count guard, boot, determinism unchanged), then:
Terminal window
git add tests/test_codex_detection.gd content/bestiary_db.gd
git commit -m "test(codex): cover first_encounters detection (dedupe, seen, custom, bound)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 4: CodexPreview — scripted flying + shooting animation

Section titled “Task 4: CodexPreview — scripted flying + shooting animation”

Files:

  • Create: render/codex_preview.gd
  • Test: tests/test_codex_preview.gd

Interfaces:

  • Consumes: a merged entry Dictionary (Task 2 contract) — reads element_color, move, attack, raw.

  • Produces: CodexPreview (extends Node2D); CodexPreview.new(entry: Dictionary); advance(dt: float) -> void (steps the canned animation); draws via _draw(). Bounded to a BOX rect around the origin.

  • Step 1: Write the failing test tests/test_codex_preview.gd:

extends GutTest
func _entry(move: String, attack: String) -> Dictionary:
return {"element_color": Color(1,0,0), "move": move, "attack": attack, "raw": {}}
func test_constructs_for_every_archetype() -> void:
for move in ["walk", "dash", "skirmish", "rush", "ghost", "homing", "hover"]:
for attack in ["melee", "bolt", "fan", "bomb", "beam", "orbit", "web", "missiles"]:
var p := CodexPreview.new(_entry(move, attack))
add_child_autofree(p)
# Advance a few seconds of canned animation; must not error or escape the box.
for _i in range(180):
p.advance(1.0 / 60.0)
assert_lt(p.enemy_pos().length(), CodexPreview.BOX * 2.0,
"enemy stays roughly bounded for %s/%s" % [move, attack])
func test_advance_progresses_time() -> void:
var p := CodexPreview.new(_entry("walk", "bolt"))
add_child_autofree(p)
var t0 := p.elapsed()
p.advance(0.5)
assert_almost_eq(p.elapsed(), t0 + 0.5, 0.001, "advance accumulates time")
  • Step 2: Run it, verify it fails.

Run: godot --headless --path . --import then godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_codex_preview.gd -gexit Expected: FAIL — CodexPreview not found.

  • Step 3: Implement render/codex_preview.gd:
class_name CodexPreview
extends Node2D
# A self-contained, scripted "flying + shooting" loop for one codex entry.
# Render-only: no sim, no gameplay RNG. The enemy silhouette moves per its `move`
# archetype inside BOX and emits scripted shots per its `attack` type, aimed at a
# slowly-drifting faux target. Drawn in the entry's element color. advance(dt) is
# driven by the hosting overlay each frame (NOT _process — the overlay owns timing).
const BOX := 140.0 # half-extent of the preview play area (px around origin)
const ENEMY_R := 16.0 # drawn silhouette radius
const SHOT_SPEED := 220.0
const SHOT_LIFE := 1.4
var _color: Color
var _move: String
var _attack: String
var _raw: Dictionary
var _t := 0.0
var _epos := Vector2.ZERO # enemy position
var _target := Vector2(0.0, 90.0) # faux player marker
var _shots: Array = [] # [{pos:Vector2, vel:Vector2, life:float, kind:String}]
var _shot_cd := 0.0
func _init(entry: Dictionary) -> void:
_color = entry.get("element_color", Color(1, 0.32, 0.46))
_move = String(entry.get("move", "walk"))
_attack = String(entry.get("attack", "melee"))
_raw = entry.get("raw", {})
func elapsed() -> float:
return _t
func enemy_pos() -> Vector2:
return _epos
func advance(dt: float) -> void:
_t += dt
_target = Vector2(sin(_t * 0.7) * BOX * 0.55, 80.0 + cos(_t * 0.5) * 22.0)
_step_move(dt)
_step_shots(dt)
queue_redraw()
func _step_move(dt: float) -> void:
var to_target := (_target - _epos)
var dir := to_target.normalized() if to_target.length() > 1.0 else Vector2.RIGHT
match _move:
"walk":
_epos += dir * 42.0 * dt
"hover":
_epos = Vector2(sin(_t * 0.8) * BOX * 0.5, -30.0 + cos(_t * 0.9) * 26.0)
"skirmish":
# orbit the target at mid-range
var ang := _t * 1.4
_epos = _target + Vector2(cos(ang), sin(ang)) * 95.0
"rush", "dash":
# charge windows: dwell, then a fast burst toward the target
var phase := fmod(_t, 1.6)
if phase < 1.1:
_epos += dir * 18.0 * dt
else:
_epos += dir * 300.0 * dt
"homing":
_epos += dir * 120.0 * dt # curving approach (dir re-evaluated each frame)
"ghost":
# drift, then teleport across to the far side every ~2s
if fmod(_t, 2.0) < 1.7:
_epos += dir * 30.0 * dt
else:
_epos = -_target.normalized() * BOX * 0.6
_:
_epos += dir * 42.0 * dt
# Keep it in the box (bounce-clamp).
_epos.x = clampf(_epos.x, -BOX, BOX)
_epos.y = clampf(_epos.y, -BOX, BOX * 0.4)
func _step_shots(dt: float) -> void:
_shot_cd -= dt
if _shot_cd <= 0.0 and _attack != "melee":
_shot_cd = 1.1
_fire()
for s in _shots:
s["life"] -= dt
s["pos"] += s["vel"] * dt
_shots = _shots.filter(func(s): return s["life"] > 0.0)
func _fire() -> void:
var aim := (_target - _epos).normalized()
match _attack:
"bolt", "missiles":
_shots.append({"pos": _epos, "vel": aim * SHOT_SPEED, "life": SHOT_LIFE, "kind": _attack})
"fan":
for k in range(-2, 3):
var v := aim.rotated(deg_to_rad(14.0 * k)) * SHOT_SPEED
_shots.append({"pos": _epos, "vel": v, "life": SHOT_LIFE, "kind": "fan"})
"bomb":
_shots.append({"pos": _target, "vel": Vector2.ZERO, "life": SHOT_LIFE, "kind": "bomb"})
"beam":
_shots.append({"pos": _epos, "vel": aim * 0.0, "life": 0.6, "kind": "beam"})
"orbit", "web":
pass # rendered as a persistent ring/trail in _draw, no projectiles
_:
pass
func _draw() -> void:
# Faux target marker.
draw_circle(_target, 6.0, Color(0.55, 0.85, 1.0, 0.5))
# Shots.
for s in _shots:
var kind: String = s["kind"]
if kind == "bomb":
draw_arc(s["pos"], 26.0, 0, TAU, 24, Color(1.0, 0.6, 0.2, 0.7), 2.0)
elif kind == "beam":
draw_line(_epos, _target, Color(_color.r, _color.g, _color.b, 0.7), 4.0)
else:
draw_circle(s["pos"], 4.0, Color(_color.r, _color.g, _color.b, 0.9))
# Orbit ring / web trail decoration.
if _attack == "orbit":
for k in range(6):
var a := _t * 3.0 + float(k) / 6.0 * TAU
draw_circle(_epos + Vector2(cos(a), sin(a)) * 30.0, 4.0, _color)
# Enemy body: a glowing diamond silhouette (the codex preview reads shape via color+size).
var pts := PackedVector2Array([
_epos + Vector2(0, -ENEMY_R), _epos + Vector2(ENEMY_R * 0.7, 0),
_epos + Vector2(0, ENEMY_R), _epos + Vector2(-ENEMY_R * 0.7, 0)])
draw_colored_polygon(pts, _color)
draw_circle(_epos, ENEMY_R * 0.5, _color.lerp(Color.WHITE, 0.6)) # hot core

NOTE for the implementer: the body silhouette above is a generic diamond so the task is self-contained. If you want the per-archetype silhouette, you may instead call into ArchetypeRenderer’s point-helpers, but that is optional polish — the diamond + element color + scripted motion satisfies “flying around and shooting”. Do not block the task on it.

  • Step 4: Run the test, verify it passes.

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_codex_preview.gd -gexit Expected: PASS (2 tests).

  • Step 5: Ritual + commit. Full ritual (count guard, boot, determinism unchanged), then:
Terminal window
git add render/codex_preview.gd tests/test_codex_preview.gd
git commit -m "feat(codex): CodexPreview — scripted flying+shooting animation per archetype
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Files:

  • Create: ui/codex_card.gd
  • Test: tests/test_codex_card.gd

Interfaces:

  • Consumes: a merged entry Dictionary (Task 2); CodexPreview (Task 4); NeonTheme.

  • Produces: CodexCard.build(entry: Dictionary) -> Control (static) — a read-only VBox with name, threat dots, element, stat rows, desc, counter, and a hosted CodexPreview (inside a SubViewportContainer or a clipped Control so it sits in the card). Mirrors WeaponDetailView.build.

  • Step 1: Write the failing test tests/test_codex_card.gd:

extends GutTest
func _entry() -> Dictionary:
return {"key": "ghost", "name": "Ghost", "threat": 4, "desc": "A void wraith.",
"counter": "Move perpendicular.", "element": "void", "element_color": Color(0.6,0.2,0.8),
"hp": 12.0, "speed": 60.0, "contact_damage": 30.0, "armor": 0.0,
"move": "ghost", "attack": "melee", "raw": {}}
func _labels(node: Node, out: Array) -> void:
if node is Label:
out.append((node as Label).text)
for c in node.get_children():
_labels(c, out)
func test_build_returns_control_with_name_and_counter() -> void:
var card := CodexCard.build(_entry())
add_child_autofree(card)
var texts: Array = []
_labels(card, texts)
var joined := " | ".join(texts)
assert_string_contains(joined, "Ghost", "shows the name")
assert_string_contains(joined, "Move perpendicular", "shows the counter")
func test_build_hosts_a_preview() -> void:
var card := CodexCard.build(_entry())
add_child_autofree(card)
# A CodexPreview must exist somewhere in the card subtree.
assert_true(_has_preview(card), "card hosts a CodexPreview")
func _has_preview(n: Node) -> bool:
if n is CodexPreview:
return true
for c in n.get_children():
if _has_preview(c):
return true
return false
  • Step 2: Run it, verify it fails.

Run: godot --headless --path . --import then godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_codex_card.gd -gexit Expected: FAIL — CodexCard not found.

  • Step 3: Implement ui/codex_card.gd:
class_name CodexCard
extends RefCounted
# Builds a read-only detail Control for one merged bestiary entry. Twin of
# WeaponDetailView. Render-only. Hosts a live CodexPreview in a fixed-size pane.
const PREVIEW_SIZE := Vector2(300, 220)
const SUB_COLOR := Color(0.62, 0.70, 0.82)
static func build(entry: Dictionary) -> Control:
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 20)
# Left: the live preview, hosted in a SubViewport so its origin-centred drawing
# sits inside a fixed pane.
var svc := SubViewportContainer.new()
svc.custom_minimum_size = PREVIEW_SIZE
svc.stretch = true
var sv := SubViewport.new()
sv.size = Vector2i(PREVIEW_SIZE)
sv.transparent_bg = true
sv.disable_3d = true
svc.add_child(sv)
var preview := CodexPreview.new(entry)
preview.position = PREVIEW_SIZE * 0.5 # centre the origin-based drawing
sv.add_child(preview)
# Drive the preview from a tiny timer node so it animates while the card is shown.
var driver := _PreviewDriver.new()
driver.preview = preview
sv.add_child(driver)
row.add_child(svc)
# Right: text details.
var box := VBoxContainer.new()
box.add_theme_constant_override("separation", 6)
box.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.add_child(box)
var name_lbl := Label.new()
name_lbl.text = String(entry.get("name", ""))
name_lbl.add_theme_font_override("font", NeonTheme.title_font())
name_lbl.add_theme_font_size_override("font_size", 26)
name_lbl.add_theme_color_override("font_color", entry.get("element_color", NeonTheme.CYAN))
box.add_child(name_lbl)
var threat := Label.new()
var t := int(entry.get("threat", 1))
threat.text = "THREAT " + "●".repeat(t) + "○".repeat(maxi(0, 5 - t)) + " " + String(entry.get("element", "")).to_upper()
threat.add_theme_font_override("font", NeonTheme.mono_font())
threat.add_theme_font_size_override("font_size", 13)
threat.add_theme_color_override("font_color", Color(1.0, 0.7, 0.3))
box.add_child(threat)
var stats := Label.new()
stats.text = "HP %d SPD %d CONTACT %d ARMOR %d" % [
int(entry.get("hp", 0)), int(entry.get("speed", 0)),
int(entry.get("contact_damage", 0)), int(entry.get("armor", 0))]
stats.add_theme_font_override("font", NeonTheme.mono_font())
stats.add_theme_font_size_override("font_size", 13)
stats.add_theme_color_override("font_color", SUB_COLOR)
box.add_child(stats)
box.add_child(_para("desc", String(entry.get("desc", "")), Color(0.82, 0.88, 0.95)))
var counter_hdr := Label.new()
counter_hdr.text = "HOW TO COUNTER"
counter_hdr.add_theme_font_override("font", NeonTheme.mono_font())
counter_hdr.add_theme_font_size_override("font_size", 11)
counter_hdr.add_theme_color_override("font_color", Color(0.45, 1.0, 0.55))
box.add_child(counter_hdr)
box.add_child(_para("counter", String(entry.get("counter", "")), Color(0.7, 0.95, 0.78)))
return row
static func _para(_name: String, text: String, color: Color) -> Label:
var l := Label.new()
l.text = text
l.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
l.custom_minimum_size = Vector2(360, 0)
l.add_theme_font_override("font", NeonTheme.mono_font())
l.add_theme_font_size_override("font_size", 14)
l.add_theme_color_override("font_color", color)
return l
# Tiny node that advances the preview each frame while the card is in the tree.
class _PreviewDriver extends Node:
var preview: CodexPreview
func _process(delta: float) -> void:
if preview != null:
preview.advance(delta)
  • Step 4: Run the test, verify it passes.

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_codex_card.gd -gexit Expected: PASS (2 tests).

  • Step 5: Ritual + commit.
Terminal window
git add ui/codex_card.gd tests/test_codex_card.gd
git commit -m "feat(codex): CodexCard — detail control with hosted live preview
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 6: CodexOverlay — first-encounter + browse modes

Section titled “Task 6: CodexOverlay — first-encounter + browse modes”

Files:

  • Create: ui/codex_overlay.gd
  • Test: tests/test_codex_overlay.gd

Interfaces:

  • Consumes: BestiaryDB (Task 2), CodexCard (Task 5), NeonTheme.

  • Produces: CodexOverlay (extends CanvasLayer):

    • setup(db: BestiaryDB) -> void (call once after instantiation)
    • show_new(keys: Array) -> void — first-encounter queue; freezes via is_open()
    • open_browse(seen: Dictionary) -> void
    • close() -> void; is_open() -> bool
    • signal closed (main resumes / restores the calling menu on this)
  • Step 1: Write the failing test tests/test_codex_overlay.gd:

extends GutTest
func _db() -> BestiaryDB:
return BestiaryLoader.load_from_path("res://data/bestiary.json",
ContentLoader.load_from_path("res://data/bible.json"))
func _overlay() -> CodexOverlay:
var o := CodexOverlay.new()
add_child_autofree(o)
o.setup(_db())
return o
func test_starts_closed() -> void:
var o := _overlay()
assert_false(o.is_open(), "closed on construction")
func test_show_new_opens_and_queues() -> void:
var o := _overlay()
o.show_new(["swarmer", "ghost"])
assert_true(o.is_open(), "first-encounter card is open")
assert_eq(o.queued_count(), 2, "two new enemies queued")
func test_show_new_empty_does_not_open() -> void:
var o := _overlay()
o.show_new([])
assert_false(o.is_open(), "no new enemies → stays closed")
func test_open_browse_lists_all_with_lock_state() -> void:
var o := _overlay()
o.open_browse({"swarmer": true})
assert_true(o.is_open(), "browse open")
assert_eq(o.entry_count(), 22, "all 22 entries listed")
assert_eq(o.seen_count(), 1, "one unlocked")
func test_close_emits_and_clears() -> void:
var o := _overlay()
watch_signals(o)
o.open_browse({})
o.close()
assert_false(o.is_open())
assert_signal_emitted(o, "closed")
  • Step 2: Run it, verify it fails.

Run: godot --headless --path . --import then godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_codex_overlay.gd -gexit Expected: FAIL — CodexOverlay not found.

  • Step 3: Implement ui/codex_overlay.gd:
class_name CodexOverlay
extends CanvasLayer
# Two-mode enemy codex overlay (render-only). FIRST-ENCOUNTER: a single "NEW ENEMY"
# card per queued key, freezes the sim (main gates on is_open()), dismiss advances
# then resumes. BROWSE: a left list of all entries (seen=name, unseen=???) + the
# selected card. tvOS-safe nav mirrors WeaponInfoOverlay (debounced stick/d-pad/keys).
signal closed
const NAV_DEBOUNCE_MS := 180
enum Mode { NONE, NEW, BROWSE }
var _db: BestiaryDB
var _mode: int = Mode.NONE
var _dim: ColorRect
var _title: Label
var _hint: Label
var _list: VBoxContainer
var _detail: PanelContainer
var _last_nav_ms := 0
# NEW mode
var _queue: Array = []
# BROWSE mode
var _seen: Dictionary = {}
var _keys: Array = []
var _sel := 0
func _ready() -> void:
layer = 24
_dim = ColorRect.new()
_dim.set_anchors_preset(Control.PRESET_FULL_RECT)
_dim.color = Color(0.0, 0.01, 0.03, 0.9)
_dim.mouse_filter = Control.MOUSE_FILTER_STOP
add_child(_dim)
var root := MarginContainer.new()
root.set_anchors_preset(Control.PRESET_FULL_RECT)
for m in ["margin_left", "margin_right", "margin_top", "margin_bottom"]:
root.add_theme_constant_override(m, 56)
root.theme = NeonTheme.get_theme()
add_child(root)
var outer := VBoxContainer.new()
outer.add_theme_constant_override("separation", 12)
root.add_child(outer)
_title = Label.new()
_title.add_theme_font_override("font", NeonTheme.title_font())
_title.add_theme_font_size_override("font_size", 32)
_title.add_theme_color_override("font_color", NeonTheme.CYAN)
outer.add_child(_title)
var hb := HBoxContainer.new()
hb.add_theme_constant_override("separation", 24)
hb.size_flags_vertical = Control.SIZE_EXPAND_FILL
outer.add_child(hb)
_list = VBoxContainer.new()
_list.add_theme_constant_override("separation", 4)
_list.custom_minimum_size = Vector2(220, 0)
hb.add_child(_list)
hb.add_child(VSeparator.new())
_detail = PanelContainer.new()
_detail.size_flags_horizontal = Control.SIZE_EXPAND_FILL
var sb := StyleBoxFlat.new()
sb.bg_color = Color(0.04, 0.06, 0.12, 0.92)
sb.set_border_width_all(1)
sb.border_color = Color(0.22, 0.32, 0.48, 0.65)
sb.set_corner_radius_all(8)
sb.set_content_margin_all(16)
_detail.add_theme_stylebox_override("panel", sb)
hb.add_child(_detail)
_hint = Label.new()
_hint.add_theme_font_override("font", NeonTheme.mono_font())
_hint.add_theme_font_size_override("font_size", 12)
_hint.add_theme_color_override("font_color", Color(0.5, 0.6, 0.72))
outer.add_child(_hint)
visible = false
func setup(db: BestiaryDB) -> void:
_db = db
func is_open() -> bool:
return _mode != Mode.NONE
func queued_count() -> int:
return _queue.size()
func entry_count() -> int:
return _keys.size()
func seen_count() -> int:
var n := 0
for k in _keys:
if _seen.has(k):
n += 1
return n
func show_new(keys: Array) -> void:
if keys.is_empty() or _db == null:
return
_queue = keys.duplicate()
_mode = Mode.NEW
visible = true
_title.text = "NEW ENEMY"
_hint.text = "press to continue"
_list.visible = false
_render_new()
func open_browse(seen: Dictionary) -> void:
if _db == null:
return
_seen = seen.duplicate()
_keys = _db.all_keys()
_sel = 0
_mode = Mode.BROWSE
visible = true
_title.text = "BESTIARY"
_hint.text = "↑/↓ select · press again / back to close"
_list.visible = true
_rebuild_browse()
func close() -> void:
_mode = Mode.NONE
_queue.clear()
visible = false
closed.emit()
func _render_new() -> void:
for c in _detail.get_children():
c.queue_free()
if _queue.is_empty():
close(); return
_detail.add_child(CodexCard.build(_db.entry_for_key(String(_queue[0]))))
func _rebuild_browse() -> void:
for c in _list.get_children():
c.queue_free()
for i in range(_keys.size()):
var key: String = _keys[i]
var unlocked: bool = _seen.has(key)
var sel := i == _sel
var lbl := Label.new()
var name := String(_db.entry_for_key(key).get("name", key)) if unlocked else "???"
lbl.text = ("▸ " if sel else " ") + name
lbl.add_theme_font_override("font", NeonTheme.title_font())
lbl.add_theme_font_size_override("font_size", 17)
lbl.add_theme_color_override("font_color",
NeonTheme.CYAN if sel else (Color(0.66, 0.78, 0.92) if unlocked else Color(0.4, 0.44, 0.52)))
_list.add_child(lbl)
_render_browse_detail()
func _render_browse_detail() -> void:
for c in _detail.get_children():
c.queue_free()
if _keys.is_empty():
return
var key: String = _keys[_sel]
if _seen.has(key):
_detail.add_child(CodexCard.build(_db.entry_for_key(key)))
else:
var l := Label.new()
l.text = "??? \n\nYou haven't encountered this enemy yet."
l.add_theme_font_override("font", NeonTheme.mono_font())
l.add_theme_color_override("font_color", Color(0.5, 0.55, 0.62))
_detail.add_child(l)
func _input(event: InputEvent) -> void:
if _mode == Mode.NONE:
return
# Confirm / dismiss.
var confirm := event.is_action_pressed("ui_accept") or event.is_action_pressed("ui_cancel")
if not confirm and event is InputEventJoypadButton:
confirm = event.pressed and (event.button_index == JOY_BUTTON_A or event.button_index == JOY_BUTTON_B)
if _mode == Mode.NEW:
if confirm:
_queue.pop_front()
if _queue.is_empty():
close()
else:
_render_new()
get_viewport().set_input_as_handled()
return
# BROWSE: ui_cancel / B closes; A also closes (toggle idiom).
if confirm:
close()
get_viewport().set_input_as_handled()
return
var fwd := event.is_action_pressed("move_down") or event.is_action_pressed("ui_down")
var back := event.is_action_pressed("move_up") or event.is_action_pressed("ui_up")
if not (fwd or back):
return
var now := Time.get_ticks_msec()
if now - _last_nav_ms < NAV_DEBOUNCE_MS:
get_viewport().set_input_as_handled(); return
_last_nav_ms = now
if not _keys.is_empty():
_sel = wrapi(_sel + (1 if fwd else -1), 0, _keys.size())
_rebuild_browse()
get_viewport().set_input_as_handled()

NOTE: Time.get_ticks_msec() is an Engine API — this is a render-side UI file (ui/), so that is fine (the /sim ban does not apply here; WeaponInfoOverlay uses it the same way).

  • Step 4: Run the test, verify it passes.

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_codex_overlay.gd -gexit Expected: PASS (5 tests).

  • Step 5: Ritual + commit.
Terminal window
git add ui/codex_overlay.gd tests/test_codex_overlay.gd
git commit -m "feat(codex): CodexOverlay — first-encounter freeze + browse modes
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 7: Wire detection + freeze + toggle into main.gd

Section titled “Task 7: Wire detection + freeze + toggle into main.gd”

Files:

  • Modify: main.gd
  • (No new test — covered by boot smoke + determinism + manual play. The detection logic itself is unit-tested in Task 3.)

Interfaces:

  • Consumes: BestiaryLoader/BestiaryDB (Tasks 2-3), CodexOverlay (Task 6), MetaState.has_seen/mark_seen (Task 1), MetaStore.save_state.

  • Produces: var codex_db: BestiaryDB, var codex: CodexOverlay on main; first-encounter freeze; an in-run browse toggle.

  • Step 1: Declare members near the other panel vars (e.g. after the weapon_info declaration, ~line 55):

var codex_db: BestiaryDB
var codex: CodexOverlay # enemy encyclopedia — first-encounter freeze + browse
  • Step 2: Instantiate as persistent singletons in _ready(). After the control_client block (~line 105), add:
codex_db = BestiaryLoader.load_from_path("res://data/bestiary.json",
ContentLoader.load_from_path("res://data/bible.json"))
codex = CodexOverlay.new()
add_child(codex)
codex.setup(codex_db)
codex.closed.connect(_on_codex_closed)
  • Step 3: Keep codex across runs. In _new_run()’s persistent-singleton guard (~line 215), add codex to the keep list:
for c in get_children():
if c == gameplay_telemetry or c == quality_manager or c == control_client \
or c == results or c == meta_shop or c == codex:
continue
c.queue_free()
  • Step 4: Add codex.is_open() to the freeze gate in _physics_process (~line 564). Change:
if sim.game_over or _paused_for_levelup or _story_won or _paused_for_menu or weapon_info.is_open():
return

to:

if sim.game_over or _paused_for_levelup or _story_won or _paused_for_menu \
or weapon_info.is_open() or codex.is_open():
return
  • Step 5: Detect first encounters in _process. In _process after the quality_manager.tick(...) block (and before the player-render section ~line 670), add:
_check_codex_encounters()

Then add the methods (place near _toggle_weapon_info, ~line 911):

func _check_codex_encounters() -> void:
# First-EVER sighting of an enemy type → freeze + pop the codex card. Render-only:
# reads the live pool, writes only the meta save. Won't fire while another overlay is up.
if sim == null or sim.game_over or _paused_for_levelup or _paused_for_menu \
or _story_won or weapon_info.is_open() or codex.is_open() or meta == null:
return
var fresh := codex_db.first_encounters(sim.enemies.type_id, sim.enemies.count, meta.seen_enemies)
if fresh.is_empty():
return
for k in fresh:
meta.mark_seen(String(k))
MetaStore.save_state(meta)
codex.show_new(fresh)
func _on_codex_closed() -> void:
# Browse opened from a menu restores that menu via its own callback; the
# first-encounter path just resumes (the freeze gate clears as is_open()→false).
pass
  • Step 6: Add an in-run browse toggle. In the existing _input keyboard block (the if event is InputEventKey and event.pressed and not event.echo: block that handles KEY_V/KEY_ESCAPE via physical_keycode, ~line 895), add a KEY_C branch alongside the KEY_V one (match the physical_keycode idiom):
if event.physical_keycode == KEY_C:
_toggle_codex()
return

Then add the helper next to _toggle_weapon_info (~line 911):

func _toggle_codex() -> void:
if sim == null or sim.game_over or _paused_for_menu or _paused_for_levelup or _story_won:
return
if codex.is_open():
codex.close()
else:
codex.open_browse(meta.seen_enemies if meta != null else {})

(Controller access to the in-run codex is provided via the pause menu in Task 8 — this key is the desktop convenience.)

  • Step 7: Verify boot + determinism.
Terminal window
godot --headless --path . --import 2>&1 | grep -i error || echo "import clean"
godot --headless --path . --quit-after 180 2>&1 | grep "SCRIPT ERROR" && echo "BOOT FAIL" || echo "boot clean"
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_determinism_checksum.gd -gexit

Expected: import clean, boot clean, determinism 1405185210/3122397125 unchanged.

Headless caveat: the boot path auto-starts survival and MetaStore.load_state reads the real user://meta.json. The first headless boot may legitimately mark + save new seen_enemies (it writes the editor’s user dir, harmless). This does NOT affect determinism (the determinism test builds Sim.new directly and never instantiates main/codex/meta).

  • Step 8: Manual play check. Open the editor, F5. Within the first seconds a “NEW ENEMY” card should freeze the game on the first swarmer; dismissing resumes. Press C to open the browse codex; it lists seen enemies + ??? for the rest.

  • Step 9: Ritual + commit. Full ritual (count guard, boot, determinism), then:

Terminal window
git add main.gd
git commit -m "feat(codex): wire first-encounter freeze + in-run browse toggle into main
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 8: “Bestiary” entries in the pause and start menus

Section titled “Task 8: “Bestiary” entries in the pause and start menus”

Files:

  • Modify: ui/pause_menu.gd
  • Modify: ui/start_menu.gd
  • Modify: main.gd (wire the new signals)

Interfaces:

  • Consumes: CodexOverlay.open_browse (Task 6), main.codex / main.meta (Task 7).

  • Produces: PauseMenu signal bestiary_requested; StartMenu signal bestiary_requested; main opens the browse codex and restores the calling menu on closed.

  • Step 1: PauseMenu — add the signal + button. In ui/pause_menu.gd, add to the signals block (~line 14):

signal bestiary_requested

In _ready(), add a button next to the Shop entry (~line 65):

box.add_child(_make_btn("Bestiary", func() -> void: bestiary_requested.emit()))
  • Step 2: StartMenu — add the signal + footer button. In ui/start_menu.gd, add to the signals block (~line 11):
signal bestiary_requested

The start menu’s footer buttons are hand-built Buttons carrying a set_meta("mode", …) sentinel that its own _input confirm routing reads. Add a Bestiary footer button right after the Shop button is appended (after box.add_child(shop_btn), ~line 79), mirroring the Shop button’s construction exactly:

var codex_btn := Button.new()
codex_btn.text = "Bestiary"
codex_btn.focus_mode = Control.FOCUS_ALL
codex_btn.add_theme_font_override("font", NeonTheme.mono_font())
codex_btn.add_theme_font_size_override("font_size", 14)
var codex_color := Color(0.5, 0.85, 1.0)
codex_btn.add_theme_stylebox_override("normal", _card_box(codex_color, 0.08))
codex_btn.add_theme_stylebox_override("hover", _card_box(codex_color, 0.22))
codex_btn.add_theme_stylebox_override("pressed",_card_box(codex_color, 0.30))
codex_btn.add_theme_stylebox_override("focus", _card_box(codex_color, 0.22))
codex_btn.custom_minimum_size = Vector2(CARD_W, 44)
codex_btn.set_meta("mode", "bestiary")
codex_btn.pressed.connect(func() -> void: bestiary_requested.emit())
_cards.append(codex_btn)
box.add_child(codex_btn)

Then route the "bestiary" sentinel in StartMenu._input’s confirm block (~line 140, where sel_mode == "shop" is handled) — add an elif:

elif sel_mode == "bestiary":
bestiary_requested.emit()
  • Step 3: Wire PauseMenu in main. In the pause-menu setup block (~line 150, where pause_menu.shop_requested.connect(...) is), add:
pause_menu.bestiary_requested.connect(func() -> void:
if pause_menu != null:
pause_menu.visible = false
codex.open_browse(meta.seen_enemies if meta != null else {})
await codex.closed
if pause_menu != null:
pause_menu.visible = true
pause_menu.refocus())

(If PauseMenu has no refocus(), drop that line — check the class; the Shop wiring above shows whether refocus() exists.)

  • Step 4: Wire StartMenu in main. In _show_start_menu() (~line 250, where start_menu.shop_requested.connect(...) is), add:
start_menu.bestiary_requested.connect(func() -> void:
start_menu.visible = false
codex.open_browse(meta.seen_enemies if meta != null else {})
await codex.closed
if start_menu != null:
start_menu.visible = true
start_menu.refocus())

(Match the start-menu’s own restore idiom shown in the adjacent shop_requested connect — use whatever it uses for re-show/refocus.)

  • Step 5: Verify boot + import.
Terminal window
godot --headless --path . --import 2>&1 | grep -i error || echo "import clean"
godot --headless --path . --quit-after 120 2>&1 | grep "SCRIPT ERROR" && echo "BOOT FAIL" || echo "boot clean"

Expected: clean.

  • Step 6: Manual play check. In the editor: from the start menu, open Bestiary → browse → close → returns to start menu. Start a run, open the pause menu (Esc / Start button) → Bestiary → browse → close → returns to pause menu.

  • Step 7: Ritual + commit. Full ritual (count guard, boot, determinism unchanged), then:

Terminal window
git add ui/pause_menu.gd ui/start_menu.gd main.gd
git commit -m "feat(codex): Bestiary entries in the pause and start menus
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

  • Full suite + count guard green: scripts/check-test-count.sh
  • Determinism byte-identical: 1405185210 / 3122397125.
  • Boot smoke clean (no SCRIPT ERROR).
  • Manual: first-encounter card freezes + dismisses; browse from both menus; cross-run persistence (kill the game, relaunch, previously-seen enemies don’t re-pop and show unlocked in the bestiary).
  • Open a PR (do not merge to main without Chris): the work is on feat/enemy-codex.
  • tvOS / web-demo sync is a SEPARATE step via the bh-deploy ritual after merge — out of scope for this plan.
  • Spec coverage: persistence (T1), data/loader/DB (T2), detection (T3), preview (T4), card (T5), overlay both modes (T6), main wiring + freeze + toggle (T7), pause+start menu entries (T8). All spec sections mapped.
  • SCHEMA_VERSION: spec said “bump”; corrected to “do NOT bump” (additive field; bumping resets older saves). Documented in Global Constraints + Task 1.
  • Type consistency: merged-entry contract fixed in Task 2 and consumed identically by CodexPreview (T4), CodexCard (T5), CodexOverlay (T6). first_encounters signature identical across T2 (impl), T3 (test), T7 (call).
  • Determinism: no /sim tick-path change in any task; baseline reaffirmed per task.