| name | engine-reference |
| description | Use when you need DungeonEngine internals — the type/constant cheat sheet and MAX_* caps, the per-frame game loop, data lifecycles (hit feedback, entity/projectile/item drop, server-authoritative loot), JSON config schemas (items/affixes/skills/weapons/enemies/bosses), or networking details (server/client tick, snapshot quantization, packet sizing). Load before editing net/, snapshots, combat lifecycles, or any assets/config/*.json. |
DungeonEngine — Reference
Internals lookup for the DungeonEngine C++ engine: type/constant cheat sheet, the
per-frame game loop, data lifecycles, JSON config schemas, networking internals, and
in-game debug keys. Extracted from CLAUDE.md, whose slim core keeps build + architecture
- directory map + conventions. Add-things recipes and pitfalls live in the
engine-how-to
skill.
Key Types and Constants (cheat sheet)
| Type | Header | Notes |
|---|
Engine | engine/engine.h | Owns all pools, defs, networking state |
Player | game/player.h | Local-only struct (camera, lock, health). Singleplayer authority |
NetPlayer | net/net_player.h | Server-authoritative player. Slot 0 = host. eyePos() adds eyeHeight |
Entity | game/entity.h | Enemy NPC. flags bitmask: ENT_ACTIVE/FLYING/DEAD. aiState: IDLE/CHASE/ATTACK/FLYBY/DEAD. enemyRole is a u16 bitmask (see EnemyRole namespace — the original 8 roles filled a byte; ROUT/SPLITTER forced the widening. Not on the wire, so it cost no protocol bump). bossDefIdx links to BossDefTable. |
BossDef / BossDefTable | game/boss_def.h | Boss definition loaded from bosses.json. Stats, role bitmask, AI personality, skill, projectile, loot guarantee. |
BossPersonality | game/boss_def.h | BERSERKER/KITER/TELEPORTER/DUELIST — overrides FSM state selection for bosses |
Projectile | game/projectile.h | projFlags: PROJ_ORB/ORB_SHARD/GRAVITY/SPLASH/SPARK/VOID/BOUNCE (u8 FULL — new booleans get fields, not flags). swept (bool, THROWING_KNIFE only): entity collision samples the whole per-tick travel segment instead of the post-move endpoint — knives are fast+thin enough to tunnel grazes otherwise (walls were always swept; entities weren't). Knives also carry the game's ONLY ranged aim assist: Engine::applyKnifeLeadAssist (BOTH fire paths — local + server remote) bends the throw at most 12° toward the intercept of a target already within 7° of the crosshair; pure math in game/lead_assist.h, pinned by tests/game/test_lead_assist.cpp. Rest of the ranged arsenal untouched by design. PROJ_BOUNCE (chakram) reflects off walls v'=v-2(v·n)n using the raycast normal, up to bouncesLeft times (server-authoritative, runtime-only — NOT on the wire; clients interpolate the bounced path). The reflection is DEFERRED to the loop tail (pendingBounce): on a bounce frame the disc's travel is capped to the wall and it falls through to the entity/player sweep (so a target between the disc and the wall is hit THIS frame), then reflects only if nothing consumed it — the old code continued at the bounce and silently dropped every bounce-frame hit (chakrams "sometimes not hitting players in the arena"). The CLIENT re-simulates the bounce on its own predicted ghosts (the p.predicted tick in tickSharedSystems, engine_update.cpp) rather than replicating it: the reflection is deterministic (an axis-aligned face flips exactly one velocity component, so both sides agree on the outgoing direction) and the client holds the same LevelGrid, so the ghost follows the real path instead of sailing through the wall — and that is also the only way the client hears the ricochet SFX, since ProjectileSystem::update (which plays it) is gated off on CLIENT. That tick must ALSO mirror the PROJ_INFINITE_BOUNCE lifetime rule (counts UP as an age, never times out) or the Infinity Chakram's ghost dies a few seconds in and takes its bounces with it. Ghosts exist only for the local player's own projectiles, so OTHER players' chakrams are covered by a second, complementary mechanism: a snapshot-side bounce DETECTOR in clientNetPost (engine_net.cpp). It needs no wire change — SnapProjectile already carries a stable , the full byte and the velocity, and takes velocity straight from the newer snapshot instead of lerping it, so a reflection shows up as one sharp direction flip; the detector tracks each bounce-projectile's unit velocity per render slot and plays the SFX when it swings past ~25°. It runs AFTER the ghost merge, which is what keeps it from double-playing: predicted ghosts are skipped explicitly (), and our own authoritative copy is while match-and-keep holds its ghost canonical. |
ItemDef / ItemInstance | game/item.h | Static template vs rolled runtime item. defId == 0xFFFF ⇒ empty |
AffixDef / Affix | game/item.h | validSlots is a bitmask of ItemSlot values |
WeaponDef / WeaponState | game/weapon.h | WeaponType: MELEE/HITSCAN/PROJECTILE selects path in Combat |
SkillDef / SkillState | game/item.h | One SkillState per player (cooldown + energy) |
LevelGrid / GridCell | world/level_grid.h | Cell flags CELL_SOLID/FLOOR/CEILING + opt-in verticality CELL_LEDGE (jump-gated) / CELL_JUMPPAD (launch pad, strength in jumpPadQ) / CELL_PLATFORM (walk-under stories, up to 3 slabs) / CELL_LAVA (Hellforge: walkable, burns the player only). Heights in quarter-units |
WorldSnapshot, SnapPlayer/Entity/Projectile | net/snapshot.h | Quantized server-to-client state |
NetInput | net/net_player.h | Client→server input: INPUT_FORWARD/BACKWARD/LEFT/RIGHT/JUMP/FIRE/LOCK flags |
Quest::QuestDef / GiverDef | game/quest_def.h | The authored act chain. QUESTS[] is APPEND-ONLY — a row's POSITION is its slot in Progress::state[] and its bit in the derived completion mask, so resorting it reassigns every saved hero's progress. Each quest: zoneFloor, name, blurb (chat), narration (Journal body, unbounded), giverIdx into GIVERS[], and ObjectiveDef objectives[4]. Trigger: CLEAR_ZONE (live pool poll, never stored) / SLAY (named enemy) / REACH / TALK / ACTIVATE (N fixtures, stored as a BITMASK). |
Quest::Progress / State | game/quest_state.h | Per-character quest state — pure, engine-free, and the save payload at SAVE_VERSION 7. state[32] (LOCKED/OFFERED/ACTIVE/COMPLETE) + obj[32][4]. completionMask() DERIVES the u64 that ZoneRoute, the gate refusals and the autoplay act branch consume, so Engine::m_questMask is a CACHE with one home (refreshQuestMask at every mutation site) — never a second stored copy. reevaluate() EXCLUDES TALK from the completion test (a quest completes on its DEED alone) and treats COMPLETE as TERMINAL (a v6 hero migrates in COMPLETE with obj zeroed, so re-deriving would un-complete them). migrateFromMask() is the v6→v7 reconstruction. |
AABB | renderer/frustum.h | Min/max box for collision and frustum culling |
Important caps (search the header for the constant if you need to grow it):
MAX_PLAYERS=4, MAX_ENTITIES=192 (raised from 128 at PROTOCOL 24 — it is WIRE layout: SnapEntity[MAX_ENTITIES] + a derived ENTITY_MASK_BYTES delta bitmask), MAX_PROJECTILES=1024 (512 on Switch), MAX_ITEM_DEFS=224,
MAX_AFFIX_DEFS=32, MAX_AFFIXES_PER_ITEM=4, MAX_INVENTORY_ITEMS=24,
difficultyHealthBump(difficulty) (game/game_constants.h) — flat per-tier ENEMY HP multiplier, the twin of difficultyDamageBump. Nightmare 2.0, Normal/Hell 1.0. It exists because there was no per-tier HP lever at all (health came only from floorHealthMult compounding over the effective floor), so "double Nightmare's HP" was otherwise inexpressible. Applied at EVERY floorHealthMult site in engine_spawn.cpp (trash, nests, boss, summons) so a tier's HP is uniform. Keep it in step with difficultyDamageBump — the curve-wide invariant is that enemy HP outscales enemy damage, and scaling both by the same factor preserves that ratio exactly (which is why doubling Nightmare's damage to 4.70 was safe only alongside the matching HP doubling).
RESURRECT_MAX=10 (game/entity.h) — how many times ONE corpse may be raised before it is spent for good. It caps Entity::timesRevived (raises SUFFERED), which is a different field from Entity::resurrectCount (raises PERFORMED by a necromancer — uncapped, and reused elsewhere as a scratch counter for goblin bleed pockets and the Engine's wave index). Enforced through the shared corpseRaisable() predicate used by both the SUMMONER role and the HEALER no-one-to-heal fallback; host-side only, so it is NOT on the wire.
Quest::MAX_QUESTS=32, Quest::MAX_OBJ=4 (game/quest_def.h — both are SERIALIZED sizes in the v7 tail, so growing either costs a SAVE_VERSION bump plus another pair of legacy readers; they were sized for growth up front while v7 was unreleased and it was free. A static_assert pins MAX_QUESTS <= 64, because completionMask()/actComplete() carry completion in a u64), Quest::GIVER_COUNT=4,
MAX_SKILL_DEFS=64, , , , ,
(= in , which s that the mesh table fits — an overflow makes the load loop , silently dropping the TAIL of the table), , ,
cells, , , .
Player movement/jump physics (world/collision.h): PLAYER_HALF_WIDTH=0.3, PLAYER_HEIGHT=1.8,
GRAVITY=-40 (m/s²; integrated ONCE in Collision::moveAndSlide — never re-add it to
applyMovement, that was the double-gravity bug), JUMP_SPEED=8 (m/s) → apex v²/2|g|=0.8 m, air
time 2v/|g|=0.4 s. Jump forgiveness lives in the pure JumpAssist (game/jump.h): COYOTE_TIME=0.1
s (jump still fires just after leaving a ledge) + BUFFER_TIME=0.1 s (a press just before landing
fires on touchdown); its JumpState (two timers) sits on both Player and NetPlayer, mirrored by
syncLocal/NetPlayer… — NOT on the wire (the jump result is velocity.y, already replicated via
SnapPlayer.posY + onGround; velY is not sent). Arc + grace pinned by tests/game/test_jump.cpp.
Vertical-level cell flags (opt-in, level_grid.h, zero-regression): CELL_LEDGE (1<<3) = a
jump-gated raised floor — Collision::overlapsLedgeAbove walls it off until the feet are within
STEP_UP_HEIGHT=0.4 m of its floor (so ≤~0.75 m ledges are jump-reachable; plain raised floors keep
unlimited walk-up). CELL_JUMPPAD (1<<4) = a Quake launch pad — Collision::onJumpPad fires
velocity.y = JUMPPAD_LAUNCH (17 m/s, apex ~3.6 m) at the end of both moveAndSlide overloads when a
body rests on it; a velocity.y impulse like the jump, so it replicates in co-op with no wire change
(deterministic geometry on both sides; posY+onGround snapshotted). Both PvP-only (enemies never
jump). Pinned by tests/world/test_collision_push.cpp.
CELL_PLATFORM (flag bit 5) is the real multi-story cell. A cell carries up to
MAX_PLATFORMS_PER_CELL (=3) slabs → 4 walkable stories: GridCell.platHeight[] (quarter-units)
are the slab TOPs, STRICTLY ASCENDING, with platMaterialId[] alongside and platCount the
count; thickness PLATFORM_THICKNESS_Q (=2 qu, 0.5 m), each underside clamped UP to the next-lower
surface (so stacked bands never overlap — that clamp is what makes per-slab band-subtraction work in
the raycast rim and mesher). Canonical byte-form: slots ≥ platCount MUST be zero — GridCell is
calloc'd per floor and never serialized, and test_level_gen's determinism check is a raw memcmp.
Three authorers, and picking the wrong one silently changes shipped geometry:
setPlatform = REPLACE-to-single (byte-identical to the old scalar overwrite — every legacy
single-slab writer uses this, because those writers DO double-write a cell and addPlatform would
fabricate a phantom slab there), addPlatform = ACCUMULATE sorted-insert (FOUR_STORY only),
removePlatform = the build-time hole puncher. sizeof(GridCell) is 14 (all-u8) and static_assert-
pinned. platCount<=1 on every shipped VERTICAL_HALL/arena cell is regression-tested. The cell's floorHeight remains the walkable GROUND story beneath. Story selection is
LevelGridSystem::effectiveFloorHeight(grid,x,z,feetY) (slab top iff feet within
PLATFORM_STEP_TOLERANCE = STEP_UP_HEIGHT = 0.4 m below it — static_assert-pinned in
collision.cpp). Consumers, all now looping EVERY slab: Collision::moveAndSlide (story-aware landing/snap keyed on
PRE-move feet Y, overlapsPlatformBand per-slab XZ gate, and a running-MIN underside head clamp —
clamp to the LOWEST qualifying underside among slabs the body started below, or a body under L1 pops
through onto L2), Raycast::cast (slab tops top-down, undersides bottom-up, per-slab rim with
band-subtraction; a higher top whose crossing lands out-of-cell CONTINUES to the next slab down rather
than falling through to the base floor — that's what lets a shot thread a drop-hole), the mesher
(per-slab top/underside/owned-rim quads; ceiling skipped on a full MAX_PLATFORMS_PER_CELL stack as
pure overdraw, and now WARNS once per on scratch overflow instead of silently
dropping invisible geometry),
(lands at the victim's story). (enemy_ai.cpp + the twin) uses
, so enemies climb ramps onto a gallery, stand under one, and drop off its
edge. Melee chase ACROSS stories via ramp ( /
, ) redirecting the CHASE goal to the nearest ramp end;
ranged snipers hold the balcony (their keep-away branch has no cross-story routing). Off
(no platform cells) this is inert — enemies stay ground-only, and pads/ledges stay
PvP-only. No wire/save change (the grid is seed-built, not serialized).
Autoplay bot input overlay (platform/input.{h,cpp}, game/bot_input.h). Synthetic-input seam
for Autoplay: Input::setBotOverlayActive(bool) arms the overlay (and clears held bits on disarm),
Input::setBotHeld(GameAction,bool) / Input::clearBotHeld() set/drop the bot's per-action held
bits, and checkActionRaw OR's them into isActionDown/isActionPressed ABOVE the real-device read
(so the bot drives every existing consumer and a human keypress still wins the same frame).
Input::humanActivityThisFrame() reports real gameplay-device activity this frame — the takeover
trigger. Pressed-edges roll once per render frame (s_botInput.rollEdges() in consumePressedState,
mirroring the device previous←current roll). The takeover/resume latch is AutoplayControl
(game/autoplay_control.h), RESUME_SECONDS = 2.0f (idle time before the bot resumes; UI-open
freezes the latch). The rest of the pure decision core lives in game/autoplay_* (see CLAUDE.md
"Autoplay mode"); the engine driver is engine_autoplay.cpp.
Window-focus input gate (platform/input_focus.h — pure rules, tests/platform/test_input_focus.cpp).
Window::pollEvents() pushes SDL_GetWindowFlags() & SDL_WINDOW_INPUT_FOCUS into
Input::setWindowFocused() once per frame (polled, not latched off FOCUS_GAINED/LOST, so a missed
event can't strand the gate and nothing needs seeding at startup). Effects, all in input.cpp:
Input::update() zeroes s_currentKeys / s_currentMouseButtons / the mouse delta while unfocused
(one choke — it gates isActionDown/isActionPressed, isKey*, isMouseButton*, getMouseDelta
AND the humanActivityThisFrame latch at once), s_mouseX/Y FREEZE at their last in-window value,
and SDL_GetRelativeMouseState is still CALLED and discarded (SDL drains its accumulator on read —
skipping it would bank out-of-window motion and dump it into the aim on the first focused frame).
setRelativeMouseMode/setCursorVisible now record what the GAME wants; applyMouseMode() pushes
want && focused / want || !focused to SDL, so an unfocused window never holds relative mode
(SDL's X11 XI_RawMotion handler gates only on mouse->relative_mode, never on focus — that is the
actual "it captures my mouse" bug) and never hides the cursor. Both focus edges drop the pending
delta. The bot overlay is NOT gated and the frame loop does NOT pause: unfocused, Autoplay keeps
playing at 60 FPS on a second screen. Gamepads are NOT gated (background devices by design).
s_everFocused fails the gate OPEN until focus is seen once (headless X / no WM).
Architecture deep-dive: split-screen & shared systems
(Full mechanics behind the condensed Split-screen principle in CLAUDE.md.)
Split-screen (couch co-op). Up to MAX_LOCAL_PLAYERS (=2, static_assert-locked) local players, mutually exclusive with networking (m_splitPlayerCount forced to 1 when NetRole != NONE). Per-player state lives in m_localPlayers[]/m_cameras[]/… and is copied into "active aliases" (m_localPlayer/m_camera/…) by swapInPlayer(idx) before each player's gameUpdate, then copied back by swapOutPlayer(idx). The alias↔array field list is single-sourced by the LOCAL_PLAYER_SWAP_FIELDS X-macro in engine.cpp — add a per-player field there (and the matching array in engine.h) and both swap directions follow automatically; the m_classSkillStates array is the one manually-paired exception. m_localPlayerIndex (set by swapInPlayer) is the single "current player" index — there is no separate m_activePlayerIndex. Shared world systems run exactly once per frame in Engine::tickSharedSystems (engine_update.cpp), called AFTER the per-player loop — AI, projectiles, entity timers, world items, shared FX, meteors, particles, enemy speech/chat decay. It picks the first alive local player as the AI/projectile primary and passes the other living locals (or, on SERVER, remote Player views) as extras, so nothing freezes when one local player is dead. Per-viewport rendering loops the same swapInPlayer(sp) and interpolates each player's camera inside the loop (engine_render.cpp); keyboard+mouse are gated to player 0 (Input::getActivePlayer()==0), controllers route per-slot via Input::setActivePlayer.
Inventory (Tab) screen — skill bars
The Tab screen draws the class skill bar + equipment skill bar at the SAME anchor the in-game HUD
uses (bottom, left of the quickbar), so they don't move when you open it. Hovering a slot with the
mouse — or selecting it with the D-pad — pops a skill tooltip (name, description, and the stats read
straight off SkillDef, printed only when non-zero so the block can't claim a cost the skill doesn't
charge).
- Geometry is single-sourced in
InventoryUI::skillBarLayout / skillSlotAt
(game/inventory_ui.{h,cpp}, pure + unit-tested in tests/game/test_skill_bar_hit.cpp). Both
renderSkillsHUD and the inventory screen anchor off it — the panel/slot math on this screen was
already copy-pasted in four places, so do not add a fifth.
- Draw order IS the layering.
renderInventoryHUD draws the bars BEFORE HUD::drawInventoryScreen;
HUD primitives are batched in submission order, so the item tooltips (drawn last, inside it) paint
OVER the bars. That is deliberate — while an item tooltip is up the player is reading the item.
There is no repositioning or z-logic; if you reorder those calls the bars will cover the tooltips.
- THE MENU IS ONE SCREEN WITH THREE PAGES (Diablo 2 style):
MENU_TAB_INVENTORY /
MENU_TAB_CHARACTER / MENU_TAB_QUESTS. m_inventoryOpen means "the menu is up" (it kept its
name — ~40 call sites read it) and m_menuTab is the single fact for WHICH page. There is no
second bool per page: m_characterScreenOpen was exactly that and is gone, replaced by the views
characterTabUp() / questTabUp(). The behaviours that keyed off it — the singleplayer world
PAUSE, the autoplay hard-freeze (botMayAct), the paper-doll FBO pass — key on the CHARACTER page
specifically, not on the menu, or flipping tabs would start and stop pausing.
m_menuTab is per-lane, so every writer must set the alias AND m_menuTabArr[lane]:
swapInPlayer re-reads the aliases from their arrays each per-player pass, so an alias-only write
made outside that pass is silently undone one frame later.
openMenu(page) / closeMenu() / cycleMenuTab(±1) own the open-close ritual (mouse mode,
cursor-mode seeding, tutorial dismissal, drag reset, stash-cursor parking). Every hotkey and the
tab strip go through them — the worldClearLevelFlags argument applied to the menu.
- Page switching — the primary route is NAVIGATION, not a hotkey. The strip is a cursor
position (
INV_PANEL_MENUTAB, outside the cycle like INV_PANEL_STASH): UP off the top of any
page lands on it, LEFT/RIGHT walk the pages, DOWN re-enters. So WASD and the D-pad reach it with
no new keys. It draws a gold ring + carets while it holds the cursor (cursor mode only — in mouse
mode the pointer already says where you are). DOWN is inert on the CHARACTER page, which has no
cursor panels to enter. Shortcuts on top: controller ; keyboard or
(never NAMED on screen — scancodes are physical positions, so is on QWERTZ);
mouse (, tested first — the strip draws over every
page so it must hit-test over every page). Direct hotkeys: (inventory / close), or
(character — NOT , which is ), (quests). All raw scancodes or an
existing , never a NEW one: ordinals ARE 's on-disk
format, so appending one means a migration for a convenience key.
( → "LB / RB",
else "PgUp / PgDn") — a fixed controller hint is why the first cut read as unswitchable on
keyboard. It names because SDL scancodes are physical key
POSITIONS: is on US and on German QWERTZ.
Quickbar
It is a WEAPON-SWAP bar, not a consumable hotbar. There are no consumable items in the game
(ItemSlot has no CONSUMABLE, ItemInstance has no stack count); the healing flask is a separate,
item-less infinite heal on a cooldown (GameConst::POTION_*, GameAction::POTION = Q / pad B).
QuickbarState (game/item.h) is QUICKBAR_SLOTS (=4) refs into the backpack/equipment
(BACKPACK_REF/EQUIPPED_REF, UID-validated), and the bar's only verb is equip.
- Controls. KB/M: mouse wheel selects, middle-click (
GameAction::QUICKBAR_USE) equips.
Gamepad: L + D-pad Up/Right/Down/Left = slots 1-4, selecting AND equipping in one press —
four directions, four slots, and no pad "use" button is needed. Same direction order as the
bare-D-pad class skills. There are no number-key bindings (1-4 are the class skills).
Engine::useQuickbarSlot(slot) is the single equip path both routes call.
sendInventorySync() is mandatory on every equip. The server fires a client's weapon from
its OWN copy of that client's inventory (handleWeaponFireForPlayer), so an equip that doesn't
push leaves the guest dealing the OLD weapon's damage while their screen shows the new one.
(CL_EQUIP_ITEM in net.h is a dead enum value — never sent, never handled. Don't reach for it;
CL_INVENTORY_SYNC is the live path.)
- Geometry is single-sourced in
InventoryUI::quickbarLayout (game/inventory_ui.{h,cpp},
pure + unit-tested in tests/game/test_quickbar.cpp). HUD::drawQuickbar, InventoryUI::hitTest
and skillBarLayout all derive from it. drawQuickbar deliberately has no xShift parameter —
a caller-supplied offset is exactly how the drawn bar and the click rects drifted apart before.
Quickbar::syncWeaponSlot also reclaims BACKPACK_REFs whose item is gone. Without that a
dropped item's slot still reads BACKPACK_REF (so it draws blank but the type == EMPTY free-slot
scan skips it) and permanently jams one of the four slots.
Input bindings — the enum ordinal IS the file format
Input::saveBindings writes controls.json as one row per action keyed by GameAction ordinal.
So: rename members in place, append new ones before COUNT, never insert or remove — any shift
silently re-maps every existing player's bindings onto the wrong actions. (DODGE,
CHARACTER_SCREEN, QUICKBAR_SLOT_3/4 sit out of thematic order at the tail for this reason.)
Two further constraints:
- Only actions
0..INVENTORY appear in the rebind UI (REBIND_COUNT, engine_render_menus.cpp).
An action past that is unrebindable — fine for chords like the quickbar slots, wrong for anything
a player expects to remap.
- Changing an action's DEFAULT binding needs a migration.
loadBindings overwrites defaults
row-by-row, so a previously-saved file's stale row silently beats the new default. Bump
BINDINGS_REV (written as the CFG_BINDINGS_REV sentinel row, index 1005) and repair the
affected actions at the tail of loadBindings — restoring only the controller or keyboard half
so the player's other customizations survive. Sentinel rows are ignored by readers that guard
idx < COUNT, so the format stays backward-compatible. BINDINGS_REV is now 3: rev 2 ADDED the
PC keyboard quickbar keys on the row below WASD (QUICKBAR_SLOT_1..4 = Z/X/C/V, direct per-slot use
alongside the still-present mouse wheel + middle-click) and, because C was taken, moved
CHARACTER_SCREEN off C to K; rev 3 moved its primary keyboard key K → T while keeping
K as a secondary alias (both open the character screen). The alias lives in a new in-memory-only
InputBinding.key2 field — NOT serialized (the format stays a fixed 7 tokens, no wire change) and
seeded by buildDefaults, checked alongside key in checkActionRaw; it suits FIXED, non-rebindable
convenience keys only (a rebindable action would lose it on save). Each migration repairs only the
affected actions' keyboard half, so an existing file gets the new keys without a double-bind and
without losing any other custom key.
- The quickbar HUD shows each slot's key as a glyph, like the skill bar (
drawQuickbar in
hud_portraits.cpp calls HUD::drawKeySymbol): Z/X/C/V on keyboard, L+D-pad directions (Up/Rt/Dn/Lt)
on a gamepad — device-picked via Input::activeDeviceIsGamepad(), hardcoded like the skill bar's
1..4 / Up..Lt (a rebind won't relabel it). In split-screen activeDeviceIsGamepad() resolves
per-viewport: the render loop calls Input::setGlyphLane(sp) so every device-picked glyph
(this bar, the skill bars, drawKeySymbol, interaction prompts) shows the device THAT player uses,
not the global last-used one; it clears to (→ global) after the loop. Where you hold a lane
index directly, use . (Pitfall: engine-how-to → per-lane glyphs.)
Champions, Floor Events & Shrines
Champions (game/champion.h) — elite pack leaders with rolled, behavioural affixes, escorted by
buffed minions of the same type. They exist because the enemy pool is tier-gated to 6-9 types per
10-floor band: champions multiply a small pool into fights the player must read.
Entity.champAffixes (u8 bitmask) + champLeaderIdx. These land in what was tail padding, so they
cost zero bytes — static_assert(sizeof(Entity) == 544) pins the layout (512 at the time of
writing; ccResist and then ROUT's routTimer each added an aligned slot). EnemyRole was a FULL
u8 bitmask and is now a u16 — widened for the overworld's ROUT/SPLITTER, local-only since
role is not serialized. ENT_CHAMPION rides in Entity.flags,
which is copied verbatim into SnapEntity, so it replicates for free.
- Affixes are LEADER-ONLY (Diablo 2 style); minions are buffed copies with none.
HEALTH_LINK is
the one affix whose effect still reaches them (damage is split onto living minions).
- Each affix hangs off an EXISTING hook:
Combat::applyDamage (Vampiric/Shielding/Health Link),
Engine::handleDeathPreamble (Molten/Frozen death novas), Engine::tickChampions
(Molten eruptions / Thundering novas / Teleport blinks — timed off animTimer phase, so they need
no new per-entity state).
- Roll rules are pure and unit-tested (
tests/game/test_champion.cpp): affix count by depth
(1/2/3), an exclusion table (EXTRA_FAST + TELEPORTING is unfightable; HEALTH_LINK needs minions),
and tintFor total over all 256 masks.
- Rate:
SPAWN_CHANCE 3%/enemy, ≤2 packs/floor, floor ≥3. That compounds to ~54% of early floors and
~73% of deep ones — tune there, not at the call site.
- Guaranteed drop (
handleChampionLootDrop), leader only. Minions are excluded for the reason the
Engine's wave-adds already are: guaranteed drops per pack member saturate the world-item pool.
Floor events (game/floor_event.h, assets/config/events.json) — a weighted table, 0-1 per floor.
The chance roll happens BEFORE the weighted pick, so adding an event makes floors more varied, not
more eventful. An unknown id in the JSON degrades to "no event" and logs. spawnFloorEvents runs
after spawnFloorBoss + buildClearanceField — the boss call mutates the boss room's geometry and
rebuilds the level mesh, so anything placed earlier can be swallowed by the arena expansion.
Loot goblin — AIState::FLEE (never attacks, no exit from the state; RETREAT could not be reused
because it auto-exits to CHASE inside detectionRange). It stands motionless over its hoard until
first hit, channeling an escape portal — the portal is a pure render-side line effect
(engine_render_effects.cpp, beside the Source portal) keyed on replicated state (ENT_LOOT_GOBLIN +
aiState==IDLE + yaw/pos, all in SnapEntity): no entity slot, no wire change, not interactable, and
it vanishes by construction the instant the goblin leaves IDLE (flees/dies/escapes).
FLEE is a frantic serpentine, not a beeline: away-from-nearest-player re-swerved every
JINK_MIN..MAX s by ±JINK_ARC (heading persists in velocity; flybyTimer is the free jink clock),
with the probe fan biased toward open floor via the clearance field — do NOT reintroduce the exit
flow-field steering; it marched every goblin to the exit door and parked it there. Chase
breadcrumbs (goblin pass in tickSharedSystems, right after the ambient cries): every sharp turn
(>~30°/frame from its velocity heading) rattles a positional GOBLIN_JINGLE (30 m, hand-picked
slot via pick_sfx.py) and, at most every 4 s, drops a gold chat line with the goblin's direction
relative to the nearest player's facing. Pure per-machine detection — ENT_LOOT_GOBLIN and
velocity are both in SnapEntity, so clients detect from m_renderInterp; nothing on the wire.
While it still SITS (aiState IDLE), the same pass runs hoard chatter within 22 m earshot: the
sack clinks every 5-11 s (per-machine) and, on the authoritative sim, the goblin mutters to itself
through the normal entity-speech pipeline (bubble + auto gold "Loot Goblin" chat line — the
speech-to-chat loop special-cases the name/color). Escape taunt: the final Goblin::TAUNT_WINDOW (1.5 s) of the escape clock, the FLEE state plants the goblin, faces it at the nearest player, and fires a gloat line through the same speech pipeline (one-shot: the 2.4 s bubble outlives the window) — a farewell + one last stand-still burst chance; a kill during the gloat still pays the full death pile. Spawn picks the
farthest room from spawn excluding the exit room (same failure mode). It bleeds random loot
while chased and drops 3 guaranteed LEGENDARIES if caught ( forces the
rarity, boss/champion style), plus a — a consumable with infinite uses
(; the def is unrollable: minLevel 255 + dropWeight 0, so the jackpot roll is its
ONLY source, pinned by ).
Pet consumables (generalized). EVERY normal enemy also has a "Mini " pet item (COMMON,
1-in-10000 roll per kill at the end of handleNormalLootDrop, using Entity.enemyDefIdx →
Engine::m_petItemForEnemy[]). The 38 defs are generated from enemies.json into items.json
(petEnemy: "<name>" → ItemDef.petEnemyIdx, resolved after both tables load in
engine_init_assets.cpp; the cross-JSON sync is pinned by test_pet_item.cpp — adding an enemy
without its pet def fails the suite). Pet drops never despawn (def-aware exemption in
WorldItemSystem::update — they roll COMMON, the tier the 60 s trash timer exists for), sit out
the Q drop-all sweep (pet check at the top of the Q loop in engine_inventory.cpp; a
deliberate single-item drop still works), and are marked by a rarity-colored tri-beam beacon
(3 crossed-quad light rays on a slowly rotating triangle — near-white for COMMON minis, gold for
the goblin jackpot — batched into the rarity-disc pass in renderWorldItems). Icons: goblin face (24) for the jackpot,
paw (25) for enemy minis (gen_item_icons.py). "Using" one (double-click / A / quickbar — every
equip path calls Engine::tryUsePetItem first, and Inventory::equip refuses it as backstop)
toggles a cosmetic follower: NpcClass::PET + ENT_FRIENDLY|ENT_UNTARGETABLE, damage-immune in
Combat::applyDamage, follow-only branch in updateFriendlyNPC, the source enemy's mesh/material
at half halfExtents (the renderer scales the mesh — no new asset; the goblin wears gold_trim).
Same item toggles off, different item swaps — one companion per player. The toggle is
server-authoritative (Engine::togglePetCompanion(slot, defId)); a guest's use is a reliable
CL_USE_PET packet (header + u16 defId → Engine::onUsePet, validated against the synced
inventory; replaced the payload-less INPUT_EX_PET bit when pets stopped being singular —
the v14 bump). Pets do not survive floor transitions — re-using the item is one click.
Menagerie: every pet ever summoned is recorded profile-wide (recordPetSummon in
tryUsePetItem — the one use entry every role crosses) into (u32 ver + u64
enemy-def bitmask + u8 goblin flag; sibling of , deliberately NOT the
frozen save format, and listed in the Steam Auto-Cloud patterns). (u32 ver=1 + u32 totalKills) beside its save slot — written by every , read on /, zeroed by ; absent = 0 (a copied save without it just restarts counters). Deliberately NOT in save_NN.dat (frozen format). Feeds the floor transition's "Enemies deleted" line (lifetime total, lane 0). The pause menu grows a
row only once (≥1 summon — an empty museum row would
spoil the collection's existence); it opens a view-only page (, drawn LAST
in renderHUD) listing the goblin + every mini in enemy-table order, uncollected as "???".
Escape uses the generic , ticked in
; expiry sets directly and does call ,
which always fires the death/loot callback — so an escaped goblin pays nothing, which is the whole
point of the chase.
Shrines (game/shrine.h) — walk-up, press-E, 45 s buff (power/speed/vitality). They are WorldItem
sentinels (SHRINE_*_ID; note 0xFFFC was already SOURCE_SHARD_ID; the overworld's are
WAYPOINT_ID 0xFFF5, ZONE_GATE_ID 0xFFF4 and CAIRN_STONE_ID 0xFFF3 — a Cairn Stone carries WHICH
of the five it is in ItemInstance::affixCount, and like the waypoint it is NEVER consumed, because
the five of them are the monument. A fixture is exempt from the 60 s despawn by the DERIVED rule "any
sentinel except the globe", so a sentinel added tomorrow is exempt by default), which inherits spawn +
snapshot replication + the server-validated pickup path. They are exempt from the 60 s world-item
despawn or they would evaporate before the player found them. The co-op replication chain is the hard
part — see the buff pitfall in engine-how-to.
MAX_WORLD_ITEMS is 64 (was 32). Delta encoding means empty slots cost nothing on the wire, so
this is a memory cost only — and WorldItemSystem::spawn fails silently on a full pool, which for
a guaranteed drop means loot that simply never existed.
Game Loop (per frame)
Clock::update, reset frame allocator + alloc tracker.
Window::pollEvents → SDL pump.
Net::poll (if networked) — fires onSnapshot/onInput/onEvent/onPlayerJoin/onPlayerLeft callbacks set during Engine::init.
- While
accumulator >= 1/60: Input::update, then update(1/60) — dispatches by GameState/NetRole.
render(alpha) — single render pass with interpolation alpha for visual smoothing.
- Profiler frame record + per-second stats log.
update steps (in singleplayerUpdate, with server/client variants similar):
PlayerController → Collision::moveAndSlide → target lock → weapon fire → EnemyAI::update → ProjectileSystem::update → EntitySystem::tickTimers → WorldItemSystem::update → SkillSystem::update/updateOrbProjectiles/updateMeteors → skill activation → pickup checks → Minimap::updateVisited.
Data Lifecycles
Game start. Engine::startGame(GameStart mode, bool lanesPrepared=false) (engine_startgame.cpp) builds the level and resets per-floor state. The mode makes player-progression intent explicit instead of inferring it from floor/difficulty/inventory: NEW_GAME wipes inventory/skills/quickbar, grants the class starting loadout via equipStartingLoadout(playerIdx), and resets HP to class base; CONTINUE leaves inventory/skills/HP untouched (a loadGame already restored them — used by menu Continue, network client join, and death-screen reload); DESCEND keeps the current run's gear/HP into the next floor (used by FLOOR_TRANSITION, including the floor-50→1 difficulty loop). Never reintroduce the old "infer from empty weapon slot" heuristic — pass the right mode at the call site. lanesPrepared=true (couch co-op only) means the menu already populated every local lane — Continue'd heroes were loaded, fresh lanes were equipped via equipFreshLane(lane) (the extracted per-lane NEW_GAME body: wipe + loadout + class base + per-lane energy) — so startGame skips the NEW_GAME inventory wipe/grant and HP reset and ONLY builds the world (per mode). This is what lets a couch game mix New and Continue lanes without the NEW_GAME wipe erasing a loaded Player 2; the per-lane NetPlayer HP then syncs from m_localPlayers[lane] on the first per-player frame. startCouchGame() (engine.cpp) is the one entry that preps a fresh P1 lane if needed, flips m_splitPlayerCount=2, and calls startGame(p1Continue?CONTINUE:NEW_GAME, /*lanesPrepared=*/true) on Player 1's floor.
Persistence — per-character saves. A save file (save_NN.dat, slots 1-20) now holds exactly ONE character (playerCount=1). The format is versioned: SAVE_VERSION is 7. The chain, newest first — v7 = per-character QUEST PROGRESS (Quest::Progress — state[32] + obj[32][4], 160 B) appended to the per-player tail; v6 = the overworld WAYPOINT mask + the old quest-completion u64, likewise appended to the per-player tail; v5 = Inferno/MYTHIC (a VALUE-range bump on an unchanged layout — without it an older binary opens an Inferno hero, clamps them to Normal and writes that back); v4 = Auto Loot & Equip (autoMode+buildCell+2 reserved bytes appended to PlayerInventory, 1676→1680); v3 = GLOVES slot + bonusAttackSpeedPct, readable via LegacyPlayerInventoryV3. Both overworld tails go on the PER-PLAYER block, never the header — the header is read by THREE sites (scanSaveSlots, loadGame, loadCharacterInto) including the slot-list scan that never wants the field, while the tail has one writer and two readers. A tail that is merely ABSENT still needs a migration. A v6 file carries quest completions as one u64 and nothing else, so reading the missing v7 tail as zeros would silently un-complete BOTH ACTS for every hero who has played them — and the next autosave writes that loss back, unrecoverably. Quest::migrateFromMask runs on the legacy branch instead; its bit-for-bit behaviour is pinned by tests/game/test_quest_state.cpp, and a real v6 save (questMask = 1023, no v7 tail) is checked in as tests/fixtures/save_v6_fixture.dat for the manual end-to-end load check — no test reads that file yet. The v5-and-newer versions share the DIRECT-READ path: keying that on == SAVE_VERSION alone is a trap whenever a bump adds no fields to an existing struct. v2 files stay readable through LegacyPlayerInventoryV2 (a byte-exact mirror of the old struct in engine_persist.cpp) and migrate to v3 on their next save. v2 itself shipped in TWO flavors (SkillId was narrowed u32→u8 without a bump — the old class-byte bug); the reader tells them apart by per-player block size (v2HasWideSkillId). s pin // sizes: any layout change must bump + add a legacy mirror. writes one local lane; writes each active lane to its own (lane 0 mirrors = P1; lane 1 = P2's own slot, chosen in the couch lobby) — called from the pause "Save & Quit", floor-descent auto-save, and difficulty-loop reset. is a thin legacy wrapper = . A (generalized from the old R13 CLIENT-only one) keeps a slot's higher (), so a high-floor hero dropped into a lower world — or a joined CLIENT — never loses on-disk progress. clearing Hell floor 50 saves the incremented floor (51) before the victory check, and () keys the post-clear on exactly — the no-downgrade guard is what keeps the marker alive through later low-floor sessions. NEVER clamp or "repair" it at a persist boundary (that erases the cleared status and the floor chooser with it). The only place it must not leak into play is the death-screen reload, which therefore restarts the floor the player , not the header floor ( GAME_OVER Tab handler). is the P1 load (sets floor/seed/difficulty, restores lane 0; a legacy bundle still restores both lanes and hands P2 a to migrate to per-character on next save). loads a file's first character into WITHOUT touching the world (used to seat a Continue'd P2 — the dungeon stays Player 1's floor). main-menu confirm resets split state to 1 (the fix for "Continue after a couch session spawns a 2nd player"); P1 New/Continue → slot → couch lobby (subState 4); P2 presses A → their own New/Continue chooser (11) → slot select (12, P2's pad, can't pick P1's slot) → class (5, for New) → . saves//prefs live under via () so Steam Auto-Cloud syncs them (); Switch keeps CWD (). is the single choke-point (caller buffers are ). writes to then s it (guards ) so an interrupted write never corrupts a slot. (once, in ) copies pre-relocation files from CWD the exe dir () into the pref dir, only when the destination is absent — non-destructive, so relocating never orphans/overwrites existing saves.
Hit feedback. Combat::applyDamage classifies every hit into an ImpactTier (LIGHT/HEAVY/CRIT/KILL — game/hit_feedback.h) and fires the matching recipe from the tunable kHitTiers table: camera shake + blood/spark/debris/smoke particles inline (combat holds the FX pointers via setFXTargets) and a tier-tagged damage number (crit/kill styled). Knockback is applied authoritatively in applyDamage (size/boss-resisted, only with a damageOrigin) so it syncs over the network; EnemyAI::update yields while Entity.knockbackTimer > 0 so the impulse is visible, and tickTimers decays it. Crits are weapon data (WeaponDef.critChance/critMult, set per weapon subtype in buildWeaponDef — daggers highest, all others a 5% baseline) rolled inside Combat::fireMelee/fireHitscan/fireProjectile, which pass isCrit to applyDamage (projectiles carry Projectile.isCrit to their direct hit). No crit logic in engine_combat. Melee is LOS-gated: fireMelee/pvpCone take the LevelGrid and pass it to CombatQuery::queryConeSorted's opt-in losGrid, so a swing (and the client's melee-predict cone, and Warrior Cleave) can't reach through a wall/floor/platform slab — the slab-aware Raycast::cast occludes an enemy above/below even though the melee cone is judged horizontally. AoE novas/explosions leave losGrid null on purpose (radial, wrap cover). Taking damage (applyDamageToPlayer) drives the red Player.hurtVignette (per-hit, damage-scaled, decays each frame) rendered as a radial edge vignette (vignette.frag via renderPostOverlays, intensity in the quad alpha) — combined at render time with a steady (non-flashing) low-HP glow computed from current HP. No oscillation anywhere — photosensitivity-safe (WCAG 2.3.1); never a full-screen red sheet. Plus the pre-existing camera kick / hit sound / hitIndicators directional arcs, and (incl. a light low-HP nag). The vignette is cleared on death so none lingers into respawn. Tune all feel from the table. Hit-stop is deferred (the field is reserved, 0 in v1). Full spec/plan: .
Entity. EntitySystem::spawn returns EntityHandle{index, generation}. Use handleValid / handleGet (free helpers in entity.h) — never index the pool directly across frames; entity slots get reused and generation invalidates stale handles. Combat::applyDamage flips ENT_DEAD and starts deathTimer; tickTimers later calls the death callback (Combat::setDeathCallback) and frees the slot. The engine sets a callback in Engine::init that rolls a 40% loot drop via ItemGen::rollItem and spawns a WorldItem.
Projectile. Combat::fireProjectile reserves a slot in ProjectilePool. ProjectileSystem::update integrates motion (with optional gravity), DDA-collides against the grid, and AABB-tests every active entity (and the player if !fromPlayer). On hit, applies damage + splash if PROJ_SPLASH. Frozen-Orb projectiles are special-cased: SkillSystem::updateOrbProjectiles ticks subTimer and spawns shards.
Enemy navigation (Phase-1 nav rework). Ground enemies/NPCs navigate clearance-aware, not by steering a thin ray. LevelGrid.clearance (a u8* per-cell Chebyshev distance-to-nearest-wall, 0 = solid) is built once per floor by LevelGridSystem::buildClearanceField (multi-source 8-connected BFS from every solid cell) — call site is in engine_startgame.cpp right after the grid geometry is final (post boss-arena expansion), alongside buildFlowField. Pathfinder::findPath(grid, start, goal, out, maxWaypoints, bodyRadius, maxSearch) is now 8-connected A* (octile heuristic, cardinal cost 10 / diagonal 14) with corner-cut prevention (a diagonal step needs both shared orthogonal cells walkable), a soft wall-hug penalty that biases paths toward open cells via the clearance field, and string-pulling: the raw cell path is collapsed to long straight legs using a width-aware segment test (samples the AABB footprint), so a wide body follows direct diagonals instead of a cell-center staircase and stops clipping inside corners. bodyRadius drives the min-clearance gate and the string-pull width; 0 = legacy point agent. Callers pass navRadius(e), not e.halfExtents.x — navRadius (enemy_ai_internal.h) caps the body at ENTITY_NAV_RADIUS_CAP (0.45 m) so an oversized boss (the Butcher = 0.8 m half-width / 1.6 m AABB) still fits the 1 m grid and routes around corners instead of failing A* (which needs clearance ≥2 for a 0.8 m body, so every neighbour near a corner is unwalkable → no path → dumb straight-line steering into the wall = the "boss stuck in the corner" bug). The boss keeps full halfExtents for collision/hits/render (it may clip wall faces a bit — the trade for never wedging); the anti-wedge nudge + teleport backstop test navExtents(e) for the same reason. MAX_PATH_WAYPOINTS is 16 (Entity.pathWaypoints[16]). In CHASE the "walk straight at the target" decision now uses hasWidthLOS(from, to, radius, grid) (centre + two shoulder rays offset by radius) instead of the thin hasLOSToPoint, so an enemy only commits to a direct charge when its whole width fits — otherwise it falls through to A*. entityMoveAndSlide keeps axis-separated sliding but adds a clearance-gradient nudge when both axes block (concave corner): it slides toward the most open neighbour cell so the entity walks itself out. The old teleport-to-cell-center stuck recovery is (was 0.8 s). All AI is server-only; no wire change.
Enemy tactics (Phase-2, on the nav foundation). In CHASE, three behaviours layer on top (enemy_ai_states.cpp, helpers in the anon namespace at the top): (1) Encircle — melee skirmishers aim at a coordinated angular attack-slot around the target (encircleGoal → LevelGridQuery::getSurroundPosition, slot = the entity's rank among the living skirmishers in its Squad, count = that many) instead of the target itself, so a pack surrounds rather than single-files into a corner. Only within ENCIRCLE_ENGAGE_DIST (8 m); a lone attacker or a slot that lands in a wall falls back to a direct approach. The ATTACK transition still keys off the real targetDist. (2) Archetype-distinct motion — isEncirclingMelee excludes CHARGER and SHIELD_BEARER (and bosses/ranged/flyers), so chargers commit straight and shield-bearers hold a frontal line — the opt-out is their signature. (3) Ranged cover/kiting — any ground enemy with attackRange > 5 plays keep-away: kite back via RETREAT when crowded (targetDist < attackRange*0.55), STRAFE-and-fire when it has a clear shot at range, or reposition to a findFlankCell via FLANK to peek when LOS is blocked — instead of marching into melee. All reuse existing FSM states (SURROUND/STRAFE/FLANK/RETREAT) and squad roles; still server-only, no wire change. (4) Authored combat openers — EnemyDef.aiPreference (enemies.json) is stamped onto Entity.aiPreference at spawn and consumed at the AGGRO transitions (IDLE-detect in enemy_ai_states.cpp, damage-wake in combat.cpp): a strafer opens firing-and-sidestepping, a flanker computes a flank path at entry (IDLE path only — the damage wake has no grid), a surrounder takes an encircle slot; retreat/dormant/unauthored open with classic CHASE. preferredCombatState() (entity.h, pure) gates mis-authored prefs to CHASE (STRAFE fires → ranged only; SURROUND → grounded melee only) and tests/game/test_ai_preference.cpp lints enemies.json stat-fit, so an authored opener can't silently no-op. The field was parsed-and-discarded for months — every enemy opened CHASE, which most hurt tier 3 (7 of 8 defs non-chase). (5) Open-layout detection comp — CAVERN-style floors (LevelState.layoutStyle) spawn enemies with detectionRange ×1.5: the authored 12-22 m bubbles were tuned for corridor floors where walls hid unaware enemies, and in a giant open cave the player can watch monsters ignore them from 35 m.
Dormant disguise (weeping-angel rule). Two enemies spawn AIState::DORMANT and pose as scenery: the mimic (chest mesh, EnemyType::MIMIC, spawned by spawnFloorChests — 20% of chest placements; the other 80% are real chests, CHEST_ID (0xFFF8) world-item sentinels rendered as the mimic's pixel-exact twin: same mesh, same chest-brown, same 0.8 m fixture pose, same "Open Chest" prompt. A real chest stores only a loot LEVEL in itemLevel; Engine::openChest (engine_death.cpp) rolls the item at open time on the authoritative sim and spawns it via the normal loot path. Chests never despawn (WorldItemSystem::update exemption — a vanishing "chest" beside a permanent mimic is a free mimic detector; pinned by tests/game/test_chest.cpp). NOTE: direct-constructed sentinel uids (pool.nextUid) start at 0x80000000 so they can never collide with ItemGen's low-range rolled-item uids — CL_PICKUP_ITEM matches by uid, first hit wins, and both counters used to restart at 1 every floor) and the Tomb Gargoyle (EnemyRole::AMBUSH, statue at a doorway — AIState::AMBUSH is no longer used by any spawn path; it rotated its yaw to track the player). The DORMANT state (enemy_ai_states.cpp) wakes an entity only when a living un-smoked player is inside its trigger bubble (gargoyle: full detectionRange; mimic: MIMIC_TRIGGER_DIST 2.5 m) AND no player is watching it — watch = ~60° view cone (EnemyAI::inViewCone, pure, pinned by tests/game/test_dormant_watch.cpp — forward convention MUST match engine.cpp's aim vector) + LOS, checked across the whole watch set (s_watchPlayers: primary + extras, rebuilt each EnemyAI::update; one co-op player staring pins it for everyone). While DORMANT an AMBUSH-role enemy is fully invulnerable — Combat::applyDamage returns before flash/damage/knockback, deliberately, because flashTimer is the DORMANT "combat nearby" wake cue and statues must not be shootable awake; mimics keep taking damage (hitting the chest springs it). All wakes funnel through EnemyAI::wakeAmbusher (chomp/silent presentation, attack-immediately). Disguise presentation is derived from replicated state only (aiState + enemyType are on the wire): stone-grey tint override + suppressed champion/aura tints in engine_render_entities.cpp, no target-bar nameplate while DORMANT (). Mimics are also ("Open Chest" prompt): item-class tap target in / (real loot outranks the chest); SP/host wakes directly, a guest sends (u8 pool index) and re-validates type/state/reach (+1 m slack) against the authoritative NetPlayer.
Item drop. Enemy dies → death callback rolls ItemInstance — ItemGen::rollItem picks rarity first, then a def whose [minRarity, maxRarity] window contains that tier (weighted by dropWeight in the level band): the LEGENDARY tier draws exclusively from the named unique defs ("minRarity": "legendary" in items.json — all skill-bearing, plus the Infinity Chakram whose bounce is its identity), and uniques never drop below legendary. Legendary affix band is 3-4 (rare stays 2-4). Base rarity odds are ItemGen::rollRarity: legendary = 1% + 0.25%/level under a difficulty-scaled ceiling — Normal 1.5%, Nightmare 2.25%, Hell 3%, Inferno 3.75% (halved 2026-08-04; all four constants live in item.h and the ceiling is ItemGen::legendaryCeiling(tier), shared with the test that pins it). MYTHIC is carved out of that slice at 20%, Inferno only. enemyLevel is the effective floor (floor + difficulty*50, capped 255), so the tier is (level-1)/50: Normal climbs to 3% within a few floors then holds; Nightmare/Hell sit flat at their ceilings. Guaranteed drops (boss lootGuarantee, champion, goblin death pile, first-kill MAGIC) pass a rarityFloor to rollItem — the old "re-roll 50× until def.maxRarity fits, force-upgrade, re-roll affixes" loops are gone; on a floor the level band widens before the tier degrades, so a floor-2 goblin still pays real uniques. Then rollAffixes (filtered by ItemSlot) → WorldItemSystem::spawn puts it in the world shared from the instant it drops — exclusiveSeconds defaults to 0, so either co-op partner may take any drop (the old 3 s killer-exclusive window read as “we don't get the same loot”; the ownerSlot/exclusiveTimer mechanism and its wire fields are kept dormant for a future FFA mode — pass a nonzero window per spawn site to re-arm). Inventory::addToBackpack moves it to backpack on pickup; Inventory::equip swaps backpack ↔ slot and calls recalculateStats. Stats are cached on PlayerInventory.bonus* fields and consumed by Inventory::getEffectiveWeapon (which builds a per-call WeaponDef merging base + affixes) and .
Loot is SERVER-AUTHORITATIVE (N5). Only NetRole::NONE (SP/split) and NetRole::SERVER (host) roll/spawn drops: the death-callback orchestrator in engine_init_callbacks.cpp runs the cosmetic preamble for everyone but returns early on NetRole::CLIENT before any loot phase (handleFirstKillDrop/handleBossLootDrop/handleNormalLootDrop/handleOnKillRingPassives), so clients never roll their own std::rand drops. World items are replicated in the snapshot (SnapWorldItem, see Snapshot quantization) and the client mirrors them into its local m_worldItems each frame via Client::mirrorWorldItems (called in clientNetPost) — the renderer and pickup-aim code read m_worldItems directly, so loot appears/disappears in lockstep with the server (no interpolation; items are static). Pickups are server-validated: the client's updatePlayerPickup (CLIENT branch) picks the aimed item and sends CL_PICKUP_ITEM (header(4)+uid u32(4), reliable) via Engine::sendPickupRequest instead of removing it locally; the server dispatches it through Net::setOnPickup → serverHandlePacket → Engine::onPickup → Engine::handlePickupRequest, which re-checks proximity (≤3.5 m XZ vs the authoritative NetPlayer.position) + ownership, moves the item into that slot's inventory (auto-equipping an empty weapon slot), and frees the world slot — the removal propagates to all clients in the next snapshot. Globes stay auto-pickup: the host services a remote client's globe pickup in serverNetPost (the client is a "remote" slot there); clients never consume globes locally. SP/host local pickup behavior is unchanged.
Networking (server tick). Receive NetInput from clients into per-slot InputRingBuffer. For each player slot, apply latest input via PlayerController::updateNetPlayerFromInput → Collision::moveAndSlide → handle skills against authoritative state. Weapon fire is event-driven now, not derived from the FIRE bit: clients send CL_FIRE_WEAPON (reliable; payload = clientTick u32 + posX/Y/Z packed + yawQ + pitchQ = 14 B) on every local fire trigger, the server's handleFireWeaponRequest clamps the claimed origin to within 2 m of np.eyePos() (was 1 m; widened in the 2026-07-18 audit so a 300 ms-RTT long-haul client's RTT/2-stale claim isn't spuriously snapped to the server origin) and queues a per-slot PendingFire, then handleWeaponFireForPlayer (called per-tick from serverNetPre) consumes it and fires authoritatively from the client's claimed yaw/pitch — sidesteps the prior bug where drain-derived np.yaw could be seconds stale under input queue jitter and produced "fires along aim from seconds ago" shots. The FIRE bit in NetInput is now unused for fire (kept on the wire for the 3rd-person trigger-held visual state only). V2 client-side fire prediction: when the client locally fires (engine_combat.cpp handleWeaponFire), it ALSO spawns the projectile into its own m_projectiles with predicted=true + clientTick = m_clientTick (M1.8; was m_serverTick). The CLIENT branch of tickSharedSystems ticks position/lifetime on predicted projectiles (the rest of ProjectileSystem::update is gated off on CLIENT). clientNetPost merges surviving predicted ghosts into m_renderInterp.projectiles (allocating top-down so they don't collide with snapshot slot ordering) so the existing render path picks them up alongside snapshot projectiles. When the matching authoritative projectile arrives in a snapshot, the match-and-KEEP pass identifies it by ownerSlot == myslot && (clientTick & 0xFFFF) == SnapProjectile.clientTickLow and — because the authoritative is interpolated to now - interp_delay and so lags ~1 m+ behind the smooth client-rate ghost — keeps the ghost as the canonical render and HIDES the authoritative (.active=false) while they agree (distance ≤ a speed-relative tolerance ), rather than swapping to it (which looked like the projectile "jumping back" — the user-reported ghost-replacement flicker, worst on WiFi where interp_delay widens). Ghost and authoritative fire from the same claimed origin/aim with identical speed/gravity so trajectories match; a divergence beyond tolerance despawns the ghost and shows the authoritative (a real correction). A matched ghost is flagged and has reset every frame it still matches, so it despawns ~ (0.1 s) after the authoritative is GONE (real impact/expiry) — clean handoff, no fly-through-walls. Safety cap for an ghost (its match never arrived — server rejected the fire, or UDP loss): auto-despawn at 0.5 s. (This is the M10/M11 "predicted projectile is canonical until reconciliation" direction; the old behaviour despawned the ghost on arrival.) Update entities and projectiles. Every tick (60 Hz, ), builds and broadcasts a . rides along so clients know which input has been processed. the AI + projectile systems target /, but remotes are s, so on the SERVER builds throwaway of each active, non-dead remote NetPlayer ( in — copies position/velocity/eyeHeight/health/maxHealth + shared status timers; Wanderer-only fields the NetPlayer lacks default to zero = no stealth/deflect/curse, the correct graceful behavior), passes them as extras to AND (same views, built once), then copies the mutated health/status back into the NetPlayers (). targets the nearest player — both the primary AND each extra are health-checked (), falling back to the primary only if everyone is dead — so enemies retarget off a corpse instead of attacking it (the primary can itself be the dead host: picks "first alive local, fallback P0", and a host has one local slot). excludes a remote by — the health guard closes the one-frame window where isn't set until (which runs this AI pass), so a just-killed remote can't be attacked for a frame. Enemy likewise skip corpses — () returns for any player with (single guard covering the primary + all extras), so a projectile passes through a dead player and can still strike a living one behind them (no wasted damage / to a dead slot). then ticks DoT/death and the snapshot carries the result to the owning client. This SERVER branch is mutually exclusive with the split-screen branch (networking forces ), so SP/split-screen are untouched.
Netcode hardening pass (2026-07, post-M14). Four load-bearing changes; all verified live with the adversity rig below. (1) Rollback-replay reconciliation (engine_net.cpp clientNetPost): on a >2 cm mispredict at the acked tick, the client seeds a scratch NetPlayer from the wire state (pos, velX/Z, onGround bit; velY keeps the predicted value — not on the wire), replays every stored input newer than the ack via PlayerController::updateNetPlayerFromInput(…, movementOnly=true) + the same temp-Player moveAndSlide step the server drain uses, REWRITES the replayed ring entries (PredictionRingOps::findMut) so the next ack compares corrected history, and commits to both mirrors — m_localPlayer (persists via swapOutPlayer) AND m_players[slot] (survives next tick's syncNetPlayerToLocalPlayer; an alias-only write is erased). >5 m = server teleport: no replay, ring reset (matches Client::reconcile's loose snap, also raised 1 m→5 m — at 1 m it false-fired on healthy fast movers, whose current-vs-acked diff is ~speed×RTT). The snapshot player is found by Snapshot::findPlayerByPoolIndex (players[] is PACKED; indexing by slot reads the wrong player once slots have gaps). (2) Input dry-out coasting (serverNetPre drain): a starved remote (dry InputRingBuffer) coasts on getLatest() with edge bits stripped for ≤15 ticks (STARVE_REPEAT_CAP), and each coast CLAIMS its tick (np.lastProcessedInputTick++) so snapshots stay time-consistent and late-arriving real inputs are dropped by the ordinary monotonic check — do NOT "fix" the claim away: coasting position without advancing the ack makes the client compare a coasted position against the wrong ring entry and double-count movement (measured: 237 snaps/90 s vs 0). A per-slot m_lastActivationTick watermark fires activation edges riding dropped-late inputs exactly once. (3) Ack-driven delta compression (finally engaged — the client never stamped NetInput.ackedSnapshotTick before, so production had only ever sent full snapshots): client stamps its newest decoded tick, server deltas against its 64-deep global m_snapHistory ring (ONE copy per tick — payloads are recipient-independent), every delta NAMES its baseline tick on the wire (+4 B after isFull) and the client decodes against exactly that snapshot from its own 64-deep ring (SNAP_BUFFER_SIZE — must stay ≥ server depth or acked baselines age out and deltas stall in miss-bursts). serializeDelta returns 0 on overflow (truthfully — it used to truncate silently with lying counts); entity unchanged-mask is 128-bit (, covers all of MAX_ENTITIES); world items are budget-clamped BEFORE projectiles (a projectile storm used to starve loot to zero and the client read absence as despawn); the full-path player record writes shrineTimerQ/reserved0 ONCE (was doubled: 66 B vs the declared 64). 19 (v14: CL_USE_PET; v15: SV_EVENT::SPEECH; v16: SnapEntity's pad byte became — boss identity for guest nameplates, since is a host-side pointer; v17: CL_INTERACT_ENTITY — mimic chest E-interact by u8 server pool index, valid cross-machine because the client entity mirror is written in-place at that index; v18: the whole client→server REQUEST family — CL_PICKUP_ITEM/CL_DROP_ITEM/CL_USE_PET/CL_METEOR/CL_INTERACT_ENTITY — carries a target-slot byte validated by peer ownership via , completing v6's CL_INPUT/CL_FIRE work: before it, an online-couch client's P2 had every request attributed to P1 — pickups range-checked against P1 and credited to P1's server inventory, shrines buffing P1, drops removing from P1's bag. , and its client sender must stamp from inside the per-lane swap. Same rule engine-side: has NO defaults because the old defaults made every in-game equip push lane 0 — P2's gear never updated on the server; a rejected pickup's rollback reads the lane from the pending-pickup ring entry (SV_PICKUP_RESULT lands during Net::poll, when is stale). The dead lock-on bit became (1<<6, flags byte layout unchanged): derives from it, opens the perfect window () on the raise edge, ticks the timer on live drains only (never during a movement-only replay), and applies the same 0.4× slow the client predicts — before v19 a guest's block did nothing authoritatively (full damage + rubber-band). Legendary shield procs dispatch on the BLOCKER's (cached like : tickPassiveEquipment locally, serverNetPost + seedRemoteView for remotes) in the perfect-block callback, which now carries ; Mirror Aegis projectile reflects happen at the projectile-hit site via (BlockOutcome::PERFECT from ) — the parried shot is fired along the blocker's (yaw/pitch look direction, at the incoming speed), not straight back the way it came, so a Mirror Aegis is an aimed counter (PvE + PvP; a degenerate/zero aim falls back to a plain reverse). Static Charge stacks ride (pose byte; bits 0/1/3/4 = active/onGround/reloading/blocking) — NOT , whose bits 5–6 are the shrine-buff type; the guest adopts them in clientNetPost for the CHG status row (row index 11; is 12). Killing the Dungeon Engine spawns a replicated gold exit portal ( 0x08, , visible+usable by guests — contrast the host-only Source ENTRY portal); entering it (host lanes via the arbitrated /, guests via 's exit-portal branch in ) fires → (0x09, u8 engineSlain) → every machine runs → (rows + scroll-end single-sourced in ) → VICTORY, which tears down the net session on its way to MENU. The Hell-complete ending uses the same beginCreditsSequence; the old direct flip was host-local and hung every co-op client on a frozen world — never reintroduce a local flip for run-wide state. Guest speech-bubble hygiene (the "old and wrong bubbles" class): replicated lines are parked in — one buffer PER POOL SLOT, never a shared ring (a ring wrap rewrote a line under a live bubble); the client entity mirror () drops speechText/speechTimer whenever a slot's replicated identity (enemyType/enemyDefIdx/meshId) changes (server recycled the pool index); the client speech-decay loop scans ALL MAX_ENTITIES interp slots, not the snapshot-rebuilt active list (a speaker dropping out of snapshot coverage would freeze its timer); and clears speech on recycled slots so the HOST can't inherit a corpse's line either. (UNRELIABLE sends only — dropping reliable pre-ENet simulates an impossible network), (one-way ms, both directions when passed to both processes), (per-packet random [0,ms] delay added ON TOP of at the single delay-queue choke, both directions — the ONLY rig knob that exercises the client's adaptive interp buffer, since a latency leaves it parked at the 33 ms floor), (deterministic movement bot driving BOTH the wire and the local sim from one stash — wire-only would manufacture fake divergence). Lag-comp over-cap rewinds clamp to instead of collapsing to 0 (the old cliff alternated full/none compensation per shot for high-RTT players). is now (was 150) — the ONE shared ceiling for the client jitter buffer AND the server movement + fire lag-comp rewind (all derive their target tick from ); 250 ms = 15 ticks, with headroom vs the 64-tick server pose history and the 64-slot client snapshot ring (both rings 32→64 in the 2026-07-18 audit — a delta survives a 300+ ms-RTT baseline age that used to force a full-snapshot fallback 12–25% of the time). Raised for long-haul links (~150 ms one-way + jitter) where the old 150 ms cap clipped the jitter buffer and stuttered remotes; soak it with . Soak-test pattern: host , client , then grep (rtt/div/idelay/in-KB/s/snap-Hz/bage) + count and lines. Healthy at 15%/100 ms: 0 snaps, div≈0, deltas ~9 KB/s. ( 21→22): outbound is ed right after the update loop (, ~33 ms of hidden RTT recovered), and the delay-queue's oversized/full case now falls through to an immediate send (with a ) instead of silently dropping the packet; the snapshot history rings grew 32→64 both sides, the fire rewind cap 15→24 ticks, 8→15, and PvP victims gained the player-pose lag-comp ring (see the lag-comp and interp notes below).