| name | engine-how-to |
| description | Use when adding or modifying DungeonEngine content (items, affixes, weapons, enemies, bosses, enemy roles, skills, materials, levels) or generating assets, and to check known pitfalls/gotchas before touching combat, loot, networking, split-screen, or entity handles. |
DungeonEngine — How To & Pitfalls
Recipes for extending the DungeonEngine and the known gotchas to avoid. Extracted from
CLAUDE.md, whose slim core keeps build + architecture + directory map + conventions.
Reference material (types/constants, game loop, data lifecycles, JSON schemas, networking,
debug keys) lives in the engine-reference skill.
Asset Conventions
- Always use tools to generate assets. Meshes are generated via
tools/gen_mesh.py, textures via tools/gen_texture.py, skins via tools/gen_skin.py, skill icons via tools/gen_skill_icons.py. Run tools/build_assets.py to rebuild all assets. Never hand-author .obj or .png files — the tools ensure correct format, naming, and sizing.
- Steam store capsules (marketing, not runtime assets) are generated by
tools/gen_steam_capsules.py → store/steam/*.png at Steam's exact sizes. It composites a key-art background with the re-drawn gold/red wordmark (the shipped logo_*.png are baked on black and won't composite). Capture real "hero shots" in-engine with F8 (F10 hides the HUD, F2 noclip frames; writes screenshot_*.png to the CWD) and pass them: --landscape <png> --portrait <png>. To skip the menu when capturing, launch with the CLI flags (engine-reference → "CLI launch options"), e.g. DungeonEngine --host --new sorcerer lands straight in-game. With no shots it falls back to the logo's own dungeon scene art (gen_logo.draw_scene_art, refactored to be text-free) so the full set always emits at logo quality. Not part of build_assets.py (release/marketing task, run on demand).
- Textures: tiles are 32×32 px; the filename's
_NN suffix is the gen_texture.py SEED, not the size (most use seed 42 → stone_wall_42.png, but stone_wall_moss_7.png is seed 7). Unlike meshes, generated PNGs ARE committed. A new tile needs: a generator + registry rows in gen_texture.py, a row in build_assets.py's textures list, and (if the engine uses it) a materials.json entry.
- Materials (
assets/materials.json): each entry has id (must match array index), name, optional texture (path under assets/textures/), optional tint [r,g,b,a]. Material 0 is the default fallback. Code looks up by name via MaterialSystem::getIdByName.
- Meshes (
assets/meshes/*.obj): triangulated, +Y up, units in metres. Loaded at engine init by name into the Engine::m_meshDefs registry; mesh 0 is always a unit cube fallback. Names referenced from JSON (item mesh field, enemy meshName).
- Shaders (
assets/shaders/*.{vert,frag}): basic is the lit textured shader for level/entities/items. unlit is for HUD/billboards. debug for DebugDraw. vignette (reuses unlit.vert) is the fullscreen radial red damage vignette drawn in Engine::renderPostOverlays via drawScreenQuad; intensity rides in the quad's alpha and the fragment shader does the corner-weighted falloff — it never flashes (photosensitivity-safe).
- Audio SFX (
assets/audio/sfx_*.wav, 44100 Hz / 16-bit / mono; gitignored, regenerated). Source pool = CC0 packs in build/audio_cache/ (populate with tools/fetch_audio.py --cache-only). Two generation paths: tools/fetch_audio.py auto-maps every SfxId from the packs by keyword + a fixed per-sound pitch, and tools/gen_audio.py is the procedural (sfxr-style) fallback. To hand-pick the 15 weapon/reload sounds, use tools/pick_sfx.py — a local web app (Python stdlib http.server; needs only numpy/scipy/ffmpeg) that ranks candidates per slot (keyword + acoustic features: duration/brightness/attack/transients), auditions them with a full effect chain (pitch, stretch, gain, reverb, lowpass, trim), and on save writes the chosen WAV plus a git-tracked manifest tools/sound_selection.json. python3 tools/pick_sfx.py --apply re-renders every manifest slot headlessly. fetch_audio.py is manifest-aware: it skips the hand-picked slots (never keyword-maps or pitch-shifts them) and self-applies the manifest via pick_sfx.py --apply in a final Phase 4 — so a single fetch_audio.py run yields correct sounds and committing tools/sound_selection.json is all that's needed for hand-picks to ship un-pitched everywhere (incl. CI) (--apply needs only ffmpeg — numpy/scipy are lazy-loaded for ranking only). gen_audio.py --all likewise skips manifest slots (explicit --type <slot> still regenerates one). The engine loads sfx_<name>.ogg before .wav, so the tool deletes any shadowing .ogg when it writes a slot.
How to Add Things
New item: use the dedicated create-item skill (items.json append-only discipline, def-shape recipes — gear vs unrollable/sentinel/consumable, icon + tooltip wiring, MAX_ITEM_DEFS cap, despawn rules). Short version: append to assets/config/items.json (NEVER insert — defId is the array index and is saved in ItemInstance), generate any new mesh via the asset tools + register in asset_manifest.h AND build_assets.py, cap is MAX_ITEM_DEFS (224).
New pet consumable: use the dedicated create-pet skill (petSummon def → summon path → follow AI → net use-path → beacon/icon; per-enemy pets are generated from enemies.json and pinned by a sync test).
New affix type: use the dedicated create-affix skill (enum → loader → recalculateStats → consumption → affixes.json sync chain).
New weapon: use the dedicated create-weapon skill (items.json + generated mesh/skin/material; or a new WeaponSubtype + firing behavior).
New enemy type: use the dedicated create-enemy skill — it covers the full pipeline (generated mesh + skin via the Python tools, material, kMeshes registration, the enemies.json entry, and the behavioral gimmick incl. adding a new EnemyRole). Don't hand-roll the steps here.
New boss: use the dedicated create-boss skill (bosses.json: floor/roles/personality/skill/projectile/loot; optional dedicated mesh + limb config).
New enemy role: covered by the create-enemy skill's gimmick path (new EnemyRole constant + u8→u16 widening + parseRole mapping + behavior branch in enemy_ai_roles.cpp).
New skill: use the dedicated create-skill skill (SkillId → SkillDef → loader → tryActivate branch → optional per-tick → skills.json → HUD icon → granting legendary item).
New quest (an act beat). Quests are authored C++ data, not JSON — Quest::QUESTS[] in
game/quest_def.h, with the rules in the pure game/quest_state.h. The recipe:
- APPEND a
QuestDef row. Never insert, never resort. A row's POSITION is its slot in
Quest::Progress::state[] and its bit in the derived completion mask, so moving a row silently
reassigns every saved hero's progress. The table is walked in order by the road, the Journal and
giverOutstanding, so append in the order the quest is meant to be reached.
- Author the row:
zoneFloor (which zone it belongs to — Quest::forZone/indexForZone key off
it, so exactly one quest per zone), name, blurb (the one-line chat offer), narration (the
Journal body — unbounded, the panel word-wraps it), giverIdx into GIVERS[], and up to
MAX_OBJ (4) objectives.
- Objective 0 is always
TALK. Every quest opens by speaking to somebody, and it gives the
Journal a first row that is true the moment the quest is known. It is pinned by test — and it is
never a prerequisite: reevaluate() excludes TALK from the completion test, so the quest
completes on its DEED alone. Do not "fix" that. There is no walk-back in this design, the onward
gate reads quest completion, and the act soak's bot cannot talk to anyone — a blocking TALK seals
the road for every bot and for any player who does not return to the hub.
- Pick a deed trigger the ENGINE CAN ALREADY SATISFY, and land both in the SAME COMMIT.
CLEAR_ZONE (polled off the live entity pool), SLAY (hooked at handleDeathPreamble, the one
choke every death funnels through), REACH (zone entry), ACTIVATE (N world fixtures, stored as
a bitmask). ZoneRoute::linkOpen gates the onward road on the host zone's quest, so a trigger
nothing implements SEALS the act for anyone playing that commit — this was actually done once and
had to be reverted. "every quest's deed trigger is one the engine can satisfy" in
tests/game/test_quest_state.cpp now fails the build instead.
- A NEW trigger kind needs three things, not one: the enum value; a mutator —
Quest::satisfy
already handles any BOOLEAN trigger generically (it matches on trigger kind and writes
required), so a counting/accumulating one needs its own, the way noteActivate sets a bit; and
a ZoneRoute::Task plus a branch in () so the BOT
can complete it — with no task it falls to , whose next hop from the zone you are standing
in is NONE, so reports no goal and the driver logs STRANDED and ends the run. The
Journal needs nothing: it prints and appends whenever
. Prefer composing shipped triggers.
New material: edit assets/materials.json. ID must equal array index. Look up at runtime by name with MaterialSystem::getIdByName. Tint blends with sampled texture color (1,1,1,1 = unmodified).
Armor visuals on the player body (inspect screen). Equipped armor renders on the class body mesh in the Character inspect screen (T or K / LB++) via Engine::submitPlayerEquipment(). Each armor ItemDef resolves to a per-tier mesh at init: armorTierFromMaterial() maps the material name suffix (_light / _medium / _heavy) to ArmorTier::LIGHT/MEDIUM/HEAVY, and the matching ItemDef.tierMeshId is filled by ItemLoader::resolveVisuals. The 3-D model render path lives in engine_render_character.cpp::renderInspectModelToFbo() — it creates an offscreen FBO (square, same aspect as the panel), renders via the normal Renderer::submit/flush pipeline with a standalone orbit camera (yaw driven by mouse drag / right-stick), then composites the result into the 2-D overlay in renderCharacterInspect(). FBO size is platform-gated: kInspectFboSize = 320 on __SWITCH__ (weaker GPU), 512 on desktop — the panel upscales either way so quality loss is minimal. The camera projection aspect is always 1.0 regardless of the FBO size; only the pixel dimensions change.
New Steam achievement: use the dedicated create-achievement skill (trigger design around what each machine knows, event-driven vs 1 Hz polled unlock, generated 64×64 achieved/locked icon pair via tools/gen_achievement_icons.py, DEPLOYMENT.md row, the mandatory build-steam compile check, and the partner-site define/upload/publish steps).
New level layout: LevelGen::generate (world/level_gen.cpp) is the production path — pass a seed, grid dimensions, and a LayoutStyle. Five structural styles exist (BSP_ROOMS classic, CAVERN cellular-automata cave, GAUNTLET serpentine arena chain, HUB central chamber + spoke vaults + ring, VERTICAL_HALL the two-story "Stacked Loop" — a Quake location-based topology (NOT one arena): nine distinct areas in a 3×3, stacked across two stories and circled by a route that spirals up/down. Four CORNER ground rooms (cover pillars), four MID-SIDE CELL_PLATFORM BALCONIES @ 3 m (walk on / walk under the arcade), and a central open VOID every balcony overlooks + drops into. A PINWHEEL of four graduated-slab RAMPS climbs each corner→balcony; two CATWALKS cross the void (one intact, one broken-jump) so the upper story loops too; JUMP-PADS fling you up. Spawn/exit on opposite sides AND stories (coin-flip ascend/descend); the lower story is one fully-connected floor so reachability is guaranteed. floor-6+ non-boss; carveVerticalHall records the four ramps as StoryPortals for the cross-story enemy chase + the sniper nests, --vhall dev door; FOUR_STORY the four-story "Descent" MAZE — a braided recursive-backtracker maze (3-wide corridors, 1-cell walls, ~34% wall) carved ONCE as full-height CELL_SOLID and shared by all four stories (L0 + slabs @ 3/6/9 m), so each level is the same labyrinth at a new height. Descent is one-way through the floor and the drop/jump split is DERIVED from movement physics (6 m/s → 2.4 m jump reach, 0.6 m body): ≥2-cell DROP HOLES are uncrossable = a committed one-story fall, 1-cell JUMP GAPS are crossable, dead-end JUMP PADS lift ~2 stories. portalCount stays 0 (no ramps/stairs). Hole density thins with depth (18/12/7%). Express shafts are impossible by construction: a hole is punched at level L only where the slab at L+1 is intact, so the GRID is the ledger and the rule can't drift. Spawn L3 one corner, exit L0 diagonally opposite; --fourstory dev door); LevelGen::pickLayoutStyle(dungeonSeed, floor) picks one per floor with per-tier weights (floors 1-3 always classic; caves peak in Spider Caverns, gauntlets in Hellforge, hubs in Catacombs). Adding a style: write a carve<Style>(grid, rng, result) that emits rectangular DungeonRooms + corridor addAdjacency links, dispatch it in generate, add a weights column, and let the shared finalizeDungeon handle spawn/exit/bbox-adjacency (pass forcedSpawn/forcedExit only for a style with a mandatory flow, like the gauntlet's start→end). : rooms are RECTANGLES fully inside the 1-cell border (every consumer — enemy/chest/boss/light/portal placement — reads room rects and centers, and — on a style with interior walls a naive rect centres on a wall surprisingly often, so snap the rect onto known-open geometry; VERTICAL_HALL scattered a cover PILLAR onto a room centre and FOUR_STORY centred rooms on maze walls, both caught only once they were added to ); everything must be reachable from spawn; carve ONLY through with integer/compare-only math — and host + client must carve bit-identical grids from the shared seed; a degenerate carve (<5 rooms) falls back to BSP deterministically inside . pins all of this per style — run it before anything else. is a hardcoded fallback. Hand-authored levels can be loaded via () but no level JSON files ship by default.
Adding vertical variety (raised floors, tiers, jump-ledges). A cell's floorHeight (quarter-units, ×0.25 = m) raises its walkable floor; Collision::moveAndSlide snaps a body up onto a higher floor as it walks, so a plain raised floor is walkable at any height (unlimited walk-up — good for tiers/ramps/daises the boss + enemies traverse). Two supports make this usable: (1) the mesher (level_mesh.cpp) now emits the vertical riser face between two OPEN cells of different floorHeight (drawn once, by the higher cell, toward each lower open neighbour) — without it a raised platform is a floating quad with a gap at its edge; and (2) a CELL_LEDGE flag (level_grid.h) marks a raised floor as jump-only: Collision::overlapsLedgeAbove (used in player AND entity moveAndSlide) treats it as a wall when the body's feet are more than STEP_UP_HEIGHT (0.4 m, collision.h) below its floor, so you must JUMP onto it (the 0.8 m apex clears ~0.75 m ledges). Enemies never jump → a CELL_LEDGE is always a wall to them, so use it ONLY where enemies shouldn't follow (the PvP arena — buildArenaLevel's raise() lambda) and use plain raised floors for boss-arena tiers (enemies follow, no cheese). CELL_LEDGE is opt-in per cell, so every existing level + every walkable tier stays on the unlimited walk-up path — zero regression. Pinned by tests/world/test_collision_push.cpp (overlapsLedgeAbove grounded-blocked / mid-jump-allowed / plain-floor-never-gated). Traps: the A* pathfinder + clearance field are 2-D and DON'T know about ledges, so an enemy would path toward a CELL_LEDGE and stall at its base (fine in the enemy-free PvP arena; don't drop a CELL_LEDGE into an enemy's route); and don't raise the cell a boss/portal/prop is pinned to (its Y is stamped from the base floor).
Jump pads (CELL_JUMPPAD, Quake/Combat-Hall launchers). A third opt-in flag (level_grid.h, 1 << 4) turns a walkable floor cell into a launch pad: Collision::onJumpPad detects a body RESTING on it (any overlapped pad cell whose floor is within STEP_UP_HEIGHT at/below the feet), and at the end of both moveAndSlide overloads — after the floor snap sets onGround — a grounded body on a pad has its velocity.y replaced with JUMPPAD_LAUNCH (17 m/s, apex ~3.6 m; collision.h) and onGround cleared. You can't stand on a pad — you get flung and air-steer the arc (horizontal velocity is rewritten from input every tick in applyMovement, so full air control composes; a directional pad wouldn't — the boost would be overwritten next tick). Why it needs zero wire change / no PROTOCOL bump — the key insight: the launch is a pure velocity.y impulse exactly like the jump, and moveAndSlide is the ONE choke every movement path funnels through (local prediction engine_update.cpp, the server's per-input remote drain via a temp Player in serverNetPre, AND reconcile replay). SnapPlayer carries posY + onGround (not velY), and the trigger is deterministic geometry identical on client and server, so a client predicts its own launch, the server produces the same posY, reconcile agrees (self-correcting within a tick like any impulse — never rubber-bands), and observers see the arc via posY interpolation. Pads are PvP-only (enemies never jump → a pad in an enemy route is a dead end; boss arenas use plain walkable tiers). Local launch SFX (SkillId-free — reuses SfxId::SKILL_DASH) fires in engine_update.cpp by detecting the fresh velocity.y crossing of JUMPPAD_LAUNCH after moveAndSlide. Pad cells render distinctly via a floorMaterialId (the arena_pad glowing material) — no mesher change (a pad is just a CELL_FLOOR cell; risers between a raised pad ring and its neighbours draw like any tier). Pinned by ( detect/plain/below-feet + launches / plain-floor-doesn't).
Two-story cells (CELL_PLATFORM) — the rules that keep them honest. (0) A slab may ONLY
sit over a floorHeight-0 ground story: a platform over a RAISED floor freezes bodies on those
cells solid (measured twice building the arena Pit — 116 telemetry samples, 2 distinct
positions, with and without CELL_LEDGE on the cell). No shipped level combines the two; use a
raised LEDGE walkway where a deck-over-raised-ground shape is wanted. (1) A platform cell must keep CELL_FLOOR — its ground story is real and walkable; setting CELL_LEDGE on it would wall off the arcade beneath. (2) Adjacent slab tops may differ by at most 1 qu (0.25 m) where players walk between them — a bigger step reads as a wall (overlapsPlatformBand). Slab STAIRS are just graduated platHeight runs. (3) Never put a CELL_JUMPPAD on or under a slab — on top it launches over the arena walls; underneath it bounces the body against the underside forever. (4) Any code that means "the floor under THIS body" must call effectiveFloorHeight(x,z,feetY), never getFloorHeight — the raw getter is the ground story and will teleport-snap a balcony walker down (or an arcade walker up). (5) World-item ground snapping and enemy AI still read the base floor by design — don't put loot or PvE fights on platforms until those consumers are converted.
- Adding a HUD status effect (buff/debuff icon). Two halves that MUST stay in lockstep, because the icon is keyed by ROW INDEX, not by the effect: (1) append a
HUD::StatusEffect row to the statuses[] array in engine_hud.cpp (label, colour, timer — a timer of 0 hides it), and (2) append its 8x8 glyph in the SAME position to the icons[] table in hud_status.cpp. Get the order wrong and the HUD shows the wrong icon for the effect — a silent, purely visual lie that nothing catches. A static_assert pins the table against STATUS_ICON_COUNT. The art is generated, never hand-written: author it as ASCII in tools/gen_status_icons.py (. = transparent, 1-4 = palette shades, row 0 = top) and re-run it (or tools/build_assets.py, which invokes it). The script emits src/renderer/status_icons_data.h — icons AND palettes together, so art and colour cannot drift apart — and validates the art (wrong row count, wrong width, bad pixel char, or an entirely empty icon all fail loudly rather than shipping a blank box). Unlike assets/meshes/*.obj, this header IS committed, so it can't vanish on CI.
How autoplay drives the player
The Autoplay bot (--autoplay, or the main-menu row) plays a lane-0 singleplayer run by injecting
synthetic input at the ACTION layer, not the wire. The flow, per sim tick, all in
engine_autoplay.cpp: Engine::updateAutoplay(dt) → (1) m_autoplayControl.tick(humanActivity, uiOpen, dt)
runs the takeover/resume latch; (2) if the bot is in control and botMayAct(), buildBotView()
snapshots live state into a pure Autoplay::BotView (self / effective weapon / nav flow-field +
per-style vertical goal / nearest hostiles with WORLD-ONLY LOS); (3) Autoplay::decide(view) (the pure
brain, composing doctrine+combat+nav) returns a BotIntent; (4) driver backstops adjust it
(loot-settle dwell, low-HP globe detour, descend pulse, a stalled-fight break-off, the exit-progress
watchdog, and the escalating geometry escape: nudge → 8-dir search → A* leg); (5) applyBotIntent writes yaw/pitch and arms the held
GameActions via Input::setBotHeld. To CHANGE bot behavior, edit the pure core (src/game/autoplay_*)
and add a TEST_CASE on a hand-built BotView (they're engine-free) — tests/game/test_autoplay_*.cpp
tests/world/test_autoplay_nav.cpp. The doctrine (how a build cell fights) is the doctrineFor table;
the priority order is in brain.cpp (survive > fight > descend > travel). Add a new synthetic action by
just setBotHeld-ing it in applyBotIntent — it flows through the human consumer automatically.
v1 scope, honestly: flat floors (rooms/cavern/gauntlet/hub) run unattended for every
archetype. The stacked (VERTICAL_HALL/FOUR_STORY) and lava floors route correctly and never leave
the bot permanently wedged, but they are dense and vertical — traversal there can be slow and a hard
floor may not complete quickly (VH balcony story-routing oscillates on some seeds). Bench a bot change
on a flat floor for the regression and on --vhall/--fourstory/--lava for the limits.
Autoplay pitfalls:
--bot-walk is a NETCODE probe, not this bot. It injects deterministic movement on the WIRE
(NetInput) for divergence testing and is DEAD in singleplayer. Autoplay injects at the ACTION layer
(Input::setBotHeld → checkActionRaw) so it drives the real human code paths in SP. Don't confuse them.
- Descend by HOLDING
GameAction::PICKUP, never by writing m_descendRequested. updatePlayerPickup
resets that flag every tick and re-derives it from the button's tap/hold arbitration — a direct write is
erased. And a CONTINUOUS hold fires Interact::poll exactly once (it latches consumed) and a HOLD
reaches a shrine sharing the exit's interact range before the exit, so the driver must PULSE PICKUP
(Autoplay::descendPulseHeld) — one cycle spends the shrine, the next descends. A plain hold wedges the
bot beside a used shrine forever (it never releases, so the latch never clears).
WeaponDef::range is 0 for EVERY projectile weapon. items.json authors baseRange only for
melee and hitscan; wands/bows/staves/crossbows carry a projectile SPEED instead (the shot flies until it
hits or its 3 s lifetime expires — the tooltip even prints "Proj Speed", not "Range"). The bot's whole
doctrine is ×weaponRange, so feeding the raw 0 in collapses the engagement band to zero and the bot
can NEVER fire a caster/archer weapon — it just backpedals and dies (the "sorcerer stuck on floor 1"
bug). Always fill BotView.weaponRange through Autoplay::botWeaponRange(range, projSpeed).
- The bot's decision to FIRE is made from the DESIRED aim, but the crosshair only EASES there. Any
new fire path in
applyBotIntent must gate on Autoplay::aimOnTarget(actualYaw, actualPitch, in.aimYaw, in.aimPitch, melee) — the actual (post-stepAngle) aim vs the intent. Arming FIRE straight off
in.fire sprays every wall the crosshair is still sweeping across ("ranged is shooting through walls":
22% of shots blocked by geometry, mean yaw error 27°). The tolerance is squeezed from both sides — it
must exceed the ease's steady-state tracking lag + wobble (~0.078 rad) or a strafing enemy MUTES the
bot, and MELEE needs the separate wide/pitch-free tolerance because its swing is a 70° HORIZONTAL cone.
- Aim SHAKE is a source-switching bug, not a jitter bug. The bot's camera IS the player camera. When
the aim looks shaky, instrument the DESIRED yaw per tick TAGGED BY WHICH BRANCH PRODUCED IT before
touching any smoothing: measured, the shake was the brain flipping FIGHT↔TRAVEL 23-28×/s on a
(45-57 of every 60 ticks) with ~55° of swing each time, while the raw target
bearing moved <2°/tick — i.e. lead-point jitter, target thrash and the wobble were all innocent. The
three guards that exist now ( on the sticky target, the driver's 0.4 s travel-heading
commit, ) all damp the SOURCE; any new aim producer must be equally hysteretic or it
re-creates the shake. Do NOT "fix" it by low-passing the desired aim — a second lag stage in series with
pushes the steady-state tracking error past and MUTES fire on any crossing
target, and it does nothing about how OFTEN the source changes.
Run a Balance Report
The balance lab (tests/balance/, spec: docs/superpowers/specs/2026-07-22-balance-lab-design.md) models typical-equipment player power against the enemy/boss curves by driving the REAL engine code — ItemGen drops, BuildScore scoring, Inventory equipping, the GameConst spawn multipliers — per (difficulty, floor, build cell). Its always-on sanity pins run with the normal test suite; the full sweep is env-gated:
BALANCE_REPORT=out.csv ./build/tests/dungeon_tests -tc="*balance report*"
python3 tools/balance_chart.py out.csv -o out.html # CSV → one-page HTML curve report
- Model parameters live in
tests/balance/balance_lab.h — DROPS_PER_FLOOR (12), WINDOW_FLOORS (4), TRIALS (200), and columnClass (the representative class per damage column: SORCERER magic / WARRIOR melee / MARKSMAN ranged). They are declared model assumptions, not engine truth — the header documents the reasoning behind each; tune them there (with the why) if the model drifts from real play, never by patching engine constants to make a chart look right.
- Four single-source extractions exist FOR the lab — the sustained-DPS cycle (
game/weapon_dps.h, shared with build_score.h), Combat::armorMitigation (inline in combat.h), kClassDefs (game/class_defs.cpp), and enemyTierForFloor (enemy_def.h, shared with the spawner). Never re-inline any of them into a single caller — a private copy is exactly the scorer-drift bug the 2026-07-22 loot fixes cleaned up: the lab and the engine silently computing different numbers from the "same" formula.
- Phase 2 (pending): once target bands are chosen, they become
REQUIREs in tests/balance/test_balance_lab.cpp — one why-comment per number, the game_constants.h discipline — so CI fails when a content or constant change knocks a floor out of band.
- Pitfall: a new weapon family or any drop-band change should be sanity-checked against the lab's weapon pin (
"typical gear: every build cell fields a weapon from mid-game windows"). That pin FAILING is the feature working, not test noise — the lab's first run caught levels 39-50 shipping no non-legendary wand (fixed by adding the Void Scepter). Run the pins before shipping the content, and fix the content gap, not the pin.
Pitfalls / Gotchas
-
The BOMBER death blast is XZ-only, on purpose. engine_death.cpp's suicide explosion (3.5 m,
damage * 3.25) ignores vertical separation. That is required for FLYING bombers — a Plague Bat
hovers 1.5-2.5 m above its target by design, so a 3D distance test would make it whiff the player it
just dived at. The accepted cost is that on VERTICAL_HALL / FOUR_STORY a suicider can catch a player
through a floor from the storey below. Known and deliberately unfixed (Aaron, 2026-07-29); switching
to a 3D test without special-casing flyers would silently disable flying bombers entirely.
-
A multi-story cell has N slabs — a scalar read of it is a phantom. Anything that inspects
CELL_PLATFORM must loop platformCount() and use the INDEXED getters. The single-slab reads
(highest top, lowest underside) describe a phantom full-height band that walls off the legal
standing space between two slabs; the rising head-clamp must take a running MIN over qualifying
undersides (last-wins pops a body under L1 through onto L2); a descending raycast must walk tops
top-down and, when a higher top's crossing lands out-of-cell, CONTINUE to the next slab rather
than dropping to the base floor (otherwise a shot threading a drop-hole snaps to the ground). And
pick the right authorer: setPlatform (replace) for every legacy single-slab writer — they
genuinely double-write cells, so addPlatform would fabricate a phantom slab there and the
determinism memcmp would NOT catch it (both peers generate the same wrong geometry).
-
MAX_ENTITIES is WIRE layout, not just a memory budget. WorldSnapshot carries
SnapEntity[MAX_ENTITIES] and delta-encodes one "unchanged" bit per pool slot, so changing it
changes the packet layout and REQUIRES a PROTOCOL_VERSION bump. Keep the bitmask width DERIVED
(ENTITY_MASK_BYTES), never a literal: a mask narrower than the pool silently no-ops for the high
slots, which can never be marked unchanged and are therefore resent every tick forever. That exact
bug shipped once already (a 64-bit mask over a 128 pool ≈ 123 KB/s of avoidable resend).
-
The entity pool is shared, and spawns fail SILENTLY when it's full. Decorations, friendly NPCs,
boss adds, pets, summons and breeders all draw from it alongside floor enemies. An area-driven
per-room enemy count applied across a multi-story floor will quietly eat the pool — the four-story
floor seeded 92 enemies and hit 122/128 on its first boot, starving everything spawned after it.
Reserve headroom in and check spawn return values.