# Wolfenstein 3D — art spec Everything currently renders procedurally (see `WolfensteinArt.js` for the placeholder palette/textures). Drop real files in and fill in the matching `path` in `data/wolfenstein-artwork.json` — no code changes needed once a path is set (see `assetManifest.js`'s `wolfenstein` entry). ## Wall textures — `sheets.walls` Per `data/wolfenstein-artwork.json`: `frameWidth: 64, frameHeight: 64`, loaded as a plain Phaser spritesheet (frames read left-to-right, top-to-bottom). **Grew 2026-08-22 from a 256×64 PNG (4 frames, one row) to 256×256 (a 4×4 grid, 16 frames of headroom, frames 0-8 assigned so far, 9-15 still blank).** `WolfensteinView.WALL_FRAME` maps wall type -> sheet frame (kept in sync by hand with `WolfensteinArt.WALL_COLORS` and `WolfensteinEditor.WALL_TOOLS`, all three keyed off the same type values): frame 0 = type 1 (Stone), frame 1 = type 2 (Wood), frame 2 = type 3 (Blue), frame 3 = type 4 (Green), frame 4 = type 5 (Wood Planks — plain vertical-plank timber, distinct from type 2's fancier paneled wood), frame 5 = type 6 (Blue Plaster — pale blue-gray plaster over a wood wainscot), frame 6 = type 7 (Gray Plaster — same plaster-over-wainscot composition as type 6, warm gray instead of blue), frame 7 = type 8 (Red Brick), frame 8 = type 10 (Cream Tile). **Type 9 is permanently skipped** — reserved for `DOOR_WALL_TYPE` (`WolfensteinLogic.js`), never a plain wall type, which is why the type numbering (not the sheet's own frame indices, which have no gap) jumps straight from 8 to 10. Doors are **not** part of this sheet at all — see `sheets.doors` below. ## Wall art decals — `sheets.wallArt` `frameWidth: 64, frameHeight: 64`, same spritesheet convention as walls — but unlike walls/doors this isn't a wall *type*, it's an optional decal (poster/flag/etc.) painted on top of whichever wall type is already there. **Wired up 2026-08-22, extended 2026-08-22.** Painted sheet is `assets/images/wolfenstein/wall-art.png` (512×512, an 8×8 grid = 64 frames of headroom; frames 0-2 assigned so far). Frame *names* (not just indices) live in a separate JSON registry, `data/wolfenstein-wallart.json` — `{ frames: [{ frame, id, name }] }` — read directly by `WolfensteinEditor.js`'s Wall Art tool dropdown at startup (`fetch('data/wolfenstein-wallart.json')`, see `refreshWallArtOptions`). To add a new decal: paint the next tile into `wall-art.png`, append one entry to that registry — no code changes needed on either side. Current frames: `0` = Nazi Flag, `1` = Hitler Pic, `2` = Exit Sign. Level data: `level.wallArt: [{ x, y, frame }]`, a sparse per-cell list (like `doors`/`items`, not a dense grid) — one entry per decorated wall cell, folded into `buildLevelModel`/`validateLevel` in `WolfensteinLogic.js` (an entry must sit on an actual wall cell, `walls[y][x] > 0`) and carried statically on `state.map.wallArt` (round-trips through save/load; a save predating this feature just deserializes to `[]`, no `SAVE_VERSION` bump needed since it's purely cosmetic data with no simulation behavior). Rendering (`WolfensteinView._drawWalls`): sampled with the *exact same* per-column source-texel technique as the wall texture itself (same `srcX` from `hit.textureX`, same vertical crop math), so a decal lines up pixel-for-pixel with the wall face beneath it at any distance — it's drawn directly on top of the base wall draw, before the shared `multiply` shading pass, so it darkens with distance/side exactly like the wall instead of reading as a flat unlit sticker. Falls back to showing nothing (just the plain wall) if the sheet isn't loaded — there's no procedural placeholder for wall art, unlike walls/doors/guard. Editor (`WolfensteinEditor.js`): a "Wall Art" tool category whose dropdown is the one populated dynamically (from the JSON registry above) rather than hardcoded in `CATEGORIES` — every other category's options are static. Clicking an existing wall cell with a frame selected toggles that cell's entry in `level.wallArt` (apply if absent, remove if present, regardless of which frame is currently selected — switching an already-decorated wall to a different decal is two clicks). Repainting a wall's type/color keeps its wall art (`applyTool`'s `WALL_TOOLS` branch); erasing a wall or placing anything that turns the cell to floor clears it (`clearWallArtAt`, called alongside every floor-creating branch). Decorated walls get a persistent yellow outline on the board regardless of which tool is active, the same "always visible" treatment doors get. ## Objects (obstacle props) — `sheets.objects` `frameWidth: 64, frameHeight: 64`, same spritesheet convention as walls — but unlike wall art this is a floor-standing prop, rendered as a billboard sprite (like an enemy/pickup), not baked into a wall column. **Wired up 2026-08-22, extended 2026-08-22.** Painted sheet is `assets/images/wolfenstein/objects.png` (512×512, 8×8 = 64 frames of headroom; frames 0-10 assigned so far). Frame names live in `data/wolfenstein-objects.json` — same `{ frames: [{ frame, id, name }] }` shape and same "paint the next tile, append one entry, no code changes" workflow as `wolfenstein-wallart.json`. Current frames: `0` = Tall Bush, `1` = Gold Eagle, `2` = Nazi Banner, `3` = Suit of Armor, `4` = Oil Drum, `5` = Blue Vase, `6` = Round Topiary, `7` = Floor Lamp, `8` = Round Table, `9` = Wooden Chair, `10` = Podium. Gameplay contract: an object blocks *movement* (player and enemies alike) but not sight or bullets — you can shoot through one, an enemy can see and fire through one, but nothing can walk through it. Level data: `level.objects: [{ x, y, frame }]`, a sparse per-cell list (like `wallArt`/`items`), validated to sit on *open floor* (`walls[y][x] === 0` — the exact opposite requirement of wall art's "must be on a wall"). At runtime it's a top-level `state.objects` list (rendered like `pickups`) that also derives a `state.map.objectBlocked` Set (`"x,y"` keys) purely for movement collision — `WolfensteinLogic.isWallCell` (used only by the player/enemy movement-collision path, `circleHitsWall`/`moveWithCollision`) checks this Set in addition to the wall grid, while the raycaster (`castRay`/`hasLineOfSight`, driving both sight and every bullet's wall check in `stepProjectiles`) never reads it at all — objects are never written into `map.walls`, which is what keeps them see-through/shoot-through by construction rather than by a special-case exception somewhere. Static data — round-trips through save/load, degrading to "no objects" for a save predating this feature (no `SAVE_VERSION` bump needed, same reasoning as wall art, except objects DO have collision behavior — but that's rebuilt fresh from the restored `objects` array on load, never itself serialized). Rendering: pushed into `WolfensteinView._drawSprites`' shared sprite list exactly like a pickup, with `objFrame` selecting the sheet frame the same way `guardFrame` does for guards — occluded against the wall depth buffer (and clipped by a wall corner) through the same shared per-column mechanism every other sprite uses, no special-casing needed. Falls back to a flat placeholder block (`WolfensteinArt.paintObject`, `wolf-object` texture) if the sheet isn't loaded, same convention as every other sheet here. Editor (`WolfensteinEditor.js`): an "Object" tool category, dynamic dropdown like Wall Art's (populated from the JSON registry above). Clicking open floor with a frame selected toggles that cell's entry in `level.objects` (apply if absent, remove if present, regardless of which frame is currently selected — same convention as Wall Art). A click on a wall cell is a no-op (erase the wall first) rather than force-converting it to floor the way door/start/exit/enemy/pickup placement does — an object sitting inside a wall makes no sense, so this tool deliberately doesn't offer that convenience. Placing a wall over a cell (or erasing it) clears any object there via `clearEntitiesAt`; placing another entity type (door/enemy/item) on the same cell as an existing object is not specially prevented — nothing else in this editor enforces mutual exclusivity between entity types either. Rendered on the board as a plain teal square (no per-frame visual distinction — the editor doesn't load real object art for preview, same as every other entity type here). ## Pickups (weapons/ammo/health) — `sheets.pickups` `frameWidth: 64, frameHeight: 64`, same spritesheet convention as objects — a floor-standing item, rendered as a billboard sprite, not baked into a wall column. **Wired up 2026-08-22.** Painted sheet is `assets/images/wolfenstein/pickups.png` (512×512, 8×8 = 64 frames of headroom; frames 0-13 assigned so far). Unlike Wall Art/Objects, there is no separate `data/wolfenstein-pickups.json` frame registry — `data/ wolfenstein-rules.json`'s existing `items[]` array (already the one source of truth for pickup gameplay semantics) carries a `frame` field per entry instead, since a pickup needs kind-specific fields (health amount, ammo type+amount, granted weapon+ammo bonus) that a bare frame-name registry doesn't have anywhere to put. Current frames: `0`=Pistol, `1`=Shotgun, `2`=Machine Gun, `3`=Gatling Gun, `4`=Plasma Rifle, `5`=Pistol Clip (10 `9mm` ammo), `6`=Ammo Box (50 `9mm` ammo), `7`=Shotgun Shells (5 `shells` ammo), `8`=Plasma Cell (30 `plasma` ammo), `9`=Small Medpack (25 HP), `10`=Large Medpack (50 HP), `11`=Blue Key, `12`=Red Key, `13`=Yellow Key. Gameplay contract: unlike an object, a pickup is not solid — walking within `constants.pickupRadius` of one removes it and applies its effect (`WolfensteinLogic.stepPickups`). Ammo is pooled **by ammo type** (`rules.ammoTypes`: `9mm`/`shells`/`plasma`), not by weapon id — pistol, machine gun and gatling gun all draw from the same `9mm` pool, which is why Pistol Clip and Ammo Box are both generic "bullet ammo" pickups rather than pistol-specific ones. A `kind: 'weapon'` pickup both grants the weapon (pushed onto `player.weapons` if not already owned), tops up its ammo type's pool by the item's `ammo` bonus, and auto-equips it. A `kind: 'key'` pickup (**wired up 2026-08-22**, see "Colored keys and locked doors" below) adds its `color` to `player.keys` if not already held — no weapon/ammo/health side effect. Level data: `level.items: [{ x, y, type }]` (`type` is the item's `id` in rules.json), a sparse per-cell list like `wallArt`/`objects`. At runtime, `state.pickups` is a top-level list (`{ id, itemId, x, y, taken }`) — `taken` flips true and rendering skips it once collected; no fade/animation, immediate disappearance (contrast a dead guard's multi-phase death sequence — overkill for a pickup). Rendering: pushed into `WolfensteinView._drawSprites`' shared sprite list exactly like an object, with `pkFrame` (looked up from `rules.itemById[pk.itemId].frame`) selecting the sheet frame the same way `objFrame` does for objects — same shared occlusion/depth-sort/wall-corner- crop path every sprite uses. Falls back to one flat placeholder block (`WolfensteinArt.paintPickup`, `wolf-pickup` texture, gold rounded square) for every frame if the sheet isn't loaded, same convention as objects. Editor (`WolfensteinEditor.js`): a "Pickup" tool category, dynamic dropdown like Object's, except populated from `data/wolfenstein-rules.json`'s `items[]` (`refreshPickupOptions`) rather than a dedicated frame-registry file — adding a new pickup still needs a rules.json entry (it has gameplay effects, unlike a cosmetic object/decal), but no other code changes. Clicking a cell bulldozes it to open floor and toggles that cell's entry in `level.items` (apply if absent, remove if present, regardless of which pickup is currently selected) — same bulldoze-onto-floor convention as Door/Enemy/Start/Exit, not Object/Wall Art's stricter floor-only/wall-only gating. Board icon: a shape keyed by the item's `kind` (ammo=gold square, health=red circle, weapon=cyan triangle, key=color-filled diamond — see below), not per-frame art. ## Colored keys and locked doors **Wired up 2026-08-22.** Three key items (`key-blue`/`key-red`/`key-yellow` in `data/wolfenstein-rules.json`, `kind: 'key'`, pickup frames 11-13 above) and a matching `color` field on a `doors[]` entry (`'blue'`/`'red'`/`'yellow'`, `WolfensteinLogic.DOOR_COLORS` is the one place that enumerates the valid set) — a door with no `color` (or `color: null`) is the original "Normal" door, openable by anyone in range, unchanged from before this feature. Gameplay contract (`WolfensteinLogic.js`): walking over a key item pushes its color onto `player.keys` (`stepPickups`' `kind === 'key'` branch, deduped — no effect if already held) — a plain array, not a Set, since it round-trips through `JSON.stringify` in `serialize()` for free. `player.keys` resets to `[]` every level (`createState` is called fresh per mission, see `WolfensteinGame._beginLevel` — nothing in this engine ever carries player state between missions), which is exactly "a key is lost on level completion" with no explicit clearing logic needed anywhere. `openNearestDoor` (the Space/E interact handler) now checks a door's `color` against `player.keys` before setting `target = 1`: missing the key pushes a `doorLocked` event (`{ color }`) instead of `doorOpen` and leaves the door shut. `nearestDoorInfo(state)` is a read-only variant of the same nearest-door lookup for the HUD prompt — null / `{ color, locked }` — so `WolfensteinGame` can show "Locked — need the Blue Key" instead of only finding out after a wasted keypress. `stepDoors`' enemy-push-through branch (`enemyNearDoor`) is gated `!d.color` — guards never carry keys, so a locked door is a hard barrier to them too, not just the player without the key. Level authoring / solvability: `buildLevelModel` already round-trips `color` for free (its `doors`/`items` mapping just spreads every field). `validateLevel` flags an unrecognized `door.color` as an authoring error (not one of `DOOR_COLORS`). More importantly, `bfsReachable` is **key-aware**: a colored door only counts as passable once its matching key has already been reached (without needing that same door) — implemented as a fixed-point loop, not a single flood-fill: flood-fill with the current keyset, harvest any newly-reachable key cells into it, repeat until a pass finds nothing new (keys are never spent, so the reachable region only ever grows). A key placed behind its own locked door, or a locked door with its key nowhere in the level, correctly reports the exit unreachable — same "exit is not reachable from playerStart" issue a level with no path at all gets. This only works because a door cell's `walls[y][x]` entry is 0 (plain floor) in every *authored* level JSON — `DOOR_WALL_TYPE` (9) only ever gets baked in by `createState` for the *runtime* map, which `validateLevel` never sees — so the color lookup is keyed off `level.doors` directly, not the walls grid. A key item's `type` is matched to a door's `color` by naming convention (`key-`) rather than by importing rules.json into WolfensteinLogic.js — kept in sync by hand, the same tradeoff `DOOR_WALL_TYPE` already makes with `WolfensteinRaycaster.js`. Editor (`WolfensteinEditor.js`): the Door category grew from one option ("Normal") to four (`door:normal`/`door:blue`/`door:red`/`door:yellow`, `DOOR_PREFIX`), same toggle-on-click convention as before (click an existing door of any color to remove it; switching an already-placed door to a different color is two clicks, not an implicit recolor). Board rendering makes a locked door visually unmistakable rather than just a differently- colored wall tile: the cell fills with the color (`LOCK_COLORS`, shared with the key pickup diamond below), plus a bold dark border and a small dark "keyhole" glyph (circle + triangle stem) drawn on top. A key pickup renders as a diamond filled with that same color — a fourth shape distinct from the existing ammo-square/weapon-triangle/health-circle icons, chosen so its color visually pairs it with the door(s) it opens. ## Door textures — `sheets.doors` `frameWidth: 64, frameHeight: 64`, same spritesheet convention as walls. **Wired up as of 2026-08-21, extended 2026-08-22 for locked doors.** Frame 0 is the plain/"Normal" door; frames 1-3 are the Blue/Red/Yellow locked-door variants (`WolfensteinView.DOOR_COLOR_FRAME` — `{ blue: 1, red: 2, yellow: 3 }` — matching the editor's Door category options and `WolfensteinLogic.DOOR_COLORS`). The current painted sheet is 256×256 (a 4×4 grid, 16 frames), of which frames 0-3 are read; the remaining 12 sit unused until a further door type exists. `WolfensteinView.js` grabs the texture once in the constructor (`this.doorTexture`) and `_drawDoorColumn` samples it exactly like the wall path samples `sheets.walls` (1px source-texel column, drawImage-stretched, same vertical source-crop for close-up magnification, same `multiply`-composited side/fog shading), now picking `DOOR_COLOR_FRAME[color]` instead of a hardcoded 0 — the door's color is looked up per-cell by `_drawWalls` (from `state.doors`, since a raycaster hit only carries the cell's mapX/mapY) and passed into `_drawDoorColumn`. Falls back to a flat color (`DOOR_COLOR_HEX`, the same blue/red/yellow the editor's board glyph uses) for a locked door, or the original flat tan for a normal one, if the sheet isn't loaded. See the sliding/recessed-door notes below for how that texture actually gets positioned in the scene (mid-cell plane, slide-driven visible fraction) — unaffected by color, which only changes which frame/flat color gets sampled. ### Sliding, recessed, see-through doors Doors animate open/closed (`WolfensteinLogic.js`'s `stepDoors`, `slide` field 0→1 over `DOOR_SLIDE_MS`) rather than popping instantly, and the cell stays solid for *gameplay* (collision/LOS/bullets) through the entire animation — it only becomes passable once `slide` reaches exactly 1 (see stepDoors' comment for why: no squeezing through a half-open door). That's unrelated to the visual, though, which is real mid-cell geometry, not a projection trick: `WolfensteinRaycaster.js`'s `intersectDoorMidplane`, opted into by passing `state.doors` to `castColumns`/`castRay` (every gameplay raycast in `WolfensteinLogic.js` omits it and still sees a door as an ordinary flush full-cell solid — this never touches what's actually walkable/shootable/ visible to AI, only what the player's screen shows). A door's blocking axis is inferred from geometry (which pair of neighbor cells is walled off) rather than the `doors[]` `orientation` field, which nothing in the codebase actually sets to anything but `'vertical'`. Given that axis, the door plane sits at the cell's exact midpoint (e.g. `x = doorCellX + 0.5` for a door blocking east-west travel) instead of at the near face like a normal wall — a straight-on ray is `perpDist`-recessed by 0.5 relative to where a flush wall would sit. Two things make a ray pass straight through a door cell instead of hitting it — both handled by `intersectDoorMidplane` returning `null`, which `castRay` treats as "keep stepping the DDA," not a miss: - the ray's own trajectory clears the cell sideways (crosses into a neighboring cell) before ever reaching the mid-plane — the DDA carries on to the real flanking wall cell, hit at *its own* true, unrecessed distance, which is what produces the "H" shape (door as the crossbar, visibly set back between two verticals that are ordinary wall hits, not anything faked in `_drawDoorColumn`) — confirmed with a sweep of rays across a doorway showing a symmetric run of door hits at the recessed distance flanked by wall hits at their own nearer distances, no discontinuity at the transition; - the crossing point has already slid open (`along < slide`, where `along` is the 0..1 position along the door's face and the panel is modeled as having physically translated `slide` cell-widths into its pocket, opening from `along = 0` outward) — the DDA carries on *past the door entirely*, so a column here renders whatever's genuinely beyond the doorway (through the ordinary wall path, at that room's own true distance), not a stand-in color — you can see into the next room as the door opens, exactly as far as the geometry actually allows. For a still-covered point, the returned `textureX` is `along - slide`, not raw `along` — sampling that directly (`_drawDoorColumn`, same source-texel technique the wall path uses) is what makes the door *texture* visibly translate sideways into the wall as it opens, rather than the closed portion just being a static crop that shrinks in place. **Now wired up** (as of 2026-08-21): `WolfensteinView._drawWalls` reads `hit.textureX` (the raycaster's fractional wall-face position) and `drawImage`s a 1px-wide source-texel column, stretched to the destination column's screen width, then darkens it with a `'multiply'`-composited grey rect using the same side/fog factor the flat-color fallback uses — matches the shading, just applied to a texture instead of a solid fill. Falls back to the old flat-shaded `fillRect` per wall type whose frame isn't loaded (`this.wallTexture` missing, or no `WALL_FRAME` entry for that type — true for type 9/doors today), so art can land wall-type-by-wall-type. The texture is grabbed once in the constructor (`scene.textures.get('wolfenstein-walls')`), which the manifest loader has already populated by the time a game scene is entered — no per-frame lookup cost. ## Secret doors **Wired up 2026-08-22.** No art of its own — a secret door reuses whatever wall texture (any of the `sheets.walls` types) already sits on its cell, which is the entire point: it must be visually indistinguishable from a plain wall until triggered. No HUD hint either (unlike a normal door's `[SPACE/E] Open` prompt) — the player has to guess and press Space/E next to a suspicious wall on a hunch. Level data: `level.secretDoors: [{ x, y, dir }]` — `dir` is degrees, the same 0=E/90=S/180=W/270=N convention `playerStart.angle` and an enemy's `facing` already use. Unlike a normal door, `(x,y)` must sit ON an existing wall cell (opposite of Door's "bulldozes to floor" requirement) and that cell's wall type is never touched — see `WolfensteinLogic.createState`'s `secretDoors` mapping, which captures it as `wallType` and leaves the live `map.walls` grid exactly as authored. Mechanically, once triggered (`WolfensteinLogic.triggerNearestSecretDoor`, called from the same Space/E handler as `openNearestDoor` — see `WolfensteinGame._pollKeys`), the wall cell slides — as real, continuous, multi-cell geometry, not a cosmetic stand-in — in its authored direction until it reaches the first solid cell, then permanently lodges there (no auto-close; a found secret stays found). How far it can travel (`restDist`) is scanned ONCE at level load against the level's own static walls (`scanSecretDoorTravel`) — nothing changes what's in that corridor before the door itself opens, so there's nothing to rescan later. Gameplay (collision/line-of-sight/bullets) stays exactly as authored — origin cell solid, corridor open, rest cell open — for the ENTIRE slide, flipping (origin → open, rest cell → solid, carrying the door's own `wallType`) in one atomic step only once `progress` reaches `restDist` (`WolfensteinLogic.stepSecretDoors`) — same "no squeezing through a half-open door" principle normal doors already follow, just over several seconds (`SECRET_DOOR_MS_PER_CELL`, 600ms/cell) instead of `DOOR_SLIDE_MS`. Enemies never trigger one — no equivalent of a normal door's `enemyNearDoor` push-through exists here; a secret is a player-only find, matching genre convention. The multi-cell *visual* slide is real geometry precisely because collision stays simple: only `WolfensteinView`'s render raycast ever sees a mid-slide door (`state.secretDoors` filtered to `state === 'sliding'`, passed into `_drawWalls`/`castColumns` as a NEW optional `secretDoors` param alongside the existing `doors` one). `WolfensteinRaycaster.secretDoorSlab(sd)` precomputes, once per frame per active door (not per ray), the block's current 1-cell-wide solid span along its slide axis (`blockLo`/`blockHi`, sliding continuously from `[origin, origin+1)` at progress 0 to `[rest, rest+1)` at progress `restDist`) and the full range of cells it could ever occupy (`corridorMin`/`corridorMax`). `castRay`'s main DDA loop checks every active slab for the cell it's just stepped into (`checkSecretDoorCell`, new): a cell outside the slab's row/column and range falls through to the ordinary static `map.walls` check untouched; a corridor cell the block isn't currently occupying reads as open regardless of what the (deliberately unchanged) static grid still says there; and the cell(s) the block currently spans get a genuine ray-vs-moving-plane intersection (same "solve for the ray's parametric distance to a face, then check the resulting in-plane coordinate," `side`/`textureX` shape `intersectDoorMidplane` already uses for a normal door's single FIXED mid-plane — just against a plane at a continuously moving position instead, and picking whichever face — near or far — the ray's own direction sign reaches first, so a ray approaching from *behind* an already-passed door correctly sees its trailing face too). The returned hit's `wallType` is the door's own captured texture, an ordinary type 1-10 value — NOT `DOOR_WALL_TYPE` — so it flows through `_drawWalls`' completely unmodified normal-wall-texture branch (`WALL_FRAME[hit.wallType]`, same shading, same wall-art lookup) with zero special-casing needed there; the "secret door" rendering is entirely contained in the raycaster's per-cell hit-testing, not a separate draw path. Editor (`WolfensteinEditor.js`): a "Secret Door" tool category (a mode, not a dropdown — same shape as Patrol/Erase). Clicking an existing wall cell toggles it (same remove-on-second-click convention as every other dynamic-entity tool); a freshly placed one immediately opens the same N/S/E/W direction picker Enemy placement uses (`pendingSecretDoor`, mirrors `pendingFacingEnemy`). Board marker is deliberately NOT a filled tile (that would hide the wall's own color, which the author still needs to see) — a magenta dashed outline plus a direction arrow (reusing `drawEnemyArrow`), shown at all times regardless of which tool is active, same "always visible" treatment doors/wall art get. `validateLevel` flags one that isn't on a wall cell, or has nowhere to go (`restDist` scanned the same way `createState` does) — but, unlike a normal or colored door, a secret door is deliberately NOT assumed passable for the exit-reachability check (`bfsReachable` has no special-casing for it at all — it's just an ordinary nonzero `walls` cell there): a secret is optional bonus content, so a level whose only path runs through an unopened one correctly reports the exit unreachable, the same as routing through a plain permanent wall would. ## Guard enemy — `sheets.guard` **Wired up as of 2026-08-21, extended 2026-08-21 and 2026-08-22.** Per the artwork JSON: `frameWidth: 128, frameHeight: 128`, painted sheet is `assets/images/wolfenstein/enemies.png` (1152×896, a 9×7 grid loaded as a plain row-major Phaser spritesheet — only the first row plus the first cell of the second row, frames 0-9, is used; the rest sit unused until a second enemy type lands there). Frame meaning (`GUARD_FRAME` in `WolfensteinView.js`): `0` shooting, `1` facing forward (idle), `2` facing forward walking, `3` facing right (idle, profile), `4` facing right walking, `5` facing away (idle), `6` facing away walking, `7` falling (death), `8` on the ground (death), `9` stunned (flinching from a non-lethal ranged hit — see `stunMs` in `WolfensteinLogic.js`'s `stepEnemyAI`/`stepProjectiles`). There is no "facing left" pose — `WolfensteinView` shows the "facing right" frames with `img.setFlipX(true)` for that case instead, per the sheet's own convention. Frames 7/8/9 are likewise angle-independent — no directional death or stun poses. `WolfensteinView._guardFacing(e, camera, now)` picks the frame every render: - `e.stunMs > 0` always shows the stunned pose (frame 9), unflipped, and takes priority over every other check below it — `stepEnemyAI` freezes `e.state` entirely while stunned, so it may still read `'attack'`/`'chase'` from right before the hit landed, and the stun pose must win regardless. - `e.state === 'attack'` always shows the shooting pose (frame 0), unflipped, regardless of viewing angle — there's only one shooting pose, and attacking guards stand still and roughly face the player anyway. - Otherwise it compares the guard's own heading (`e.angle`, set by `stepEnemyAI`/`stepPatrol` in `WolfensteinLogic.js`) against the bearing from the guard to the camera, bucketing the relative angle into front/back/side (±45°/±135° thresholds) the way classic sprite-based FPS billboarding does — front when the guard is looking at the camera, back when its back is turned, side otherwise (mirrored via `flipX` depending on which side). If left/right ever reads backwards in-game, it's a one-line sign flip in that method, not a rethink. - Walking-vs-idle pose alternates on a shared `WALK_CYCLE_MS` (300ms) timer whenever the guard is actually moving — `state === 'chase'`, `'alert'` (which also moves per `stepEnemyAI`), or `'idle'` while following a patrol route with waypoints. An idle guard with no patrol just stands (frame 1/3/5, whichever facing applies), never animating. `_guardFacing` only runs for live guards (`!e.dead`). Once `e.dead` flips true, `WolfensteinView._deadGuardSprite(e, now)` takes over instead and drives a **view-only** death sequence — `WolfensteinLogic.js`'s enemies just have a `dead` boolean, no animation-phase field, since that's presentation state, not simulation state (it doesn't round-trip through save/load and isn't checked by `tools/verifyWolfenstein.js`). Keyed by enemy id in `this.deathAnim` (a `Map`, separate from the position/depth sprite pool), the first render tick after death records a `startMs` and the elapsed time since then drives three phases: `DEATH_FALL_MS` (350ms) showing frame 7 (falling), then `DEATH_GROUND_MS` (2000ms) showing frame 8 fully opaque (on the ground), then `DEATH_FADE_MS` (800ms) still on frame 8 while alpha linearly ramps `1 -> 0`. Once the total elapsed time clears all three phases, the method destroys the pooled `Image` outright (not just hides it — a dead guard never comes back, unlike a live one flickering in/out of the depth buffer) so the body's sprite doesn't linger, and stops returning a sprite for it at all (excluded from that frame's list, so it's no longer drawn, occlusion-tested, or depth-sorted). The `deathAnim` entry itself is kept forever rather than deleted — `e.dead` stays true for the rest of the level, so deleting it would make the next render call read as "just died" again and restart the fall/ground/fade sequence from scratch on a loop. Falls back to the old flat placeholder (`WolfensteinArt.js`'s single-frame procedural `wolf-guard` texture, always frame 0, never flipped) if the sheet isn't loaded, same convention as the wall/door texture fallbacks — this applies to the death sequence too (dead guards would just render as the one flat placeholder frame, never flipped, for the same three phases/timing, alpha fade included, until removed) and to stun (a stunned guard just reads as the same flat placeholder frame — no visible flinch — until frame 9 is painted). ## Standalone images — `artwork[]` These are loaded as plain images (no frame slicing), so any resolution works, but matching the placeholder's proportions keeps in-world scale consistent with `WolfensteinView`'s `s.scale` multipliers (`setDisplaySize` is applied on top, so a differently-sized source image just gets stretched — same aspect ratio is what matters most): - `wolfenstein-bullet` — pistol round in flight. Placeholder is 12×12 (see `WolfensteinArt.paintBullet`: a brass/copper body with a bright tracer core and a small highlight, three concentric circles); rendered at scale 0.1 (`WolfensteinView._drawSprites`, shrunk from 0.18 on 2026-08-21 to read as a small round rather than a floating dot) — keep any replacement simple/legible at tiny sizes, same as before. - `wolfenstein-muzzle` — muzzle-flash sprite. Placeholder is 48×48. Not currently spawned by the scene at all (no code creates a `wolf-muzzle` image instance yet, painted or not) — wire it up on `weaponFired` events once a sfx/vfx pass lands. - `wolfenstein-title` — optional main-menu backdrop art. Not read anywhere in `WolfensteinScreens.js` today; the menu renders a flat panel + title text only (`backdrop()`'s `GAME_WIDTH`×`GAME_HEIGHT` rectangle, matching `TAScreens.js`'s style before `ta-background` was painted). If added, size it to the shared canvas, **1920×1080**, and code would need to draw it behind that panel. - `wolfenstein-weapon-pistol`, `wolfenstein-weapon-shotgun`, `wolfenstein-weapon-machinegun`, `wolfenstein-weapon-gatling`, `wolfenstein-weapon-plasmarifle` — POV weapon viewmodels, one per projectile weapon (fists has none — melee has no viewmodel art at all). **All five painted as of 2026-08-22** (`weapon_pistol.png`, `weapon_shotgun.png`, `weapon_machinegun.png`, `weapon_gatling.png`, `weapon_plasma.png` — note the plasma rifle's file is named `_plasma`, not `_plasmarifle`, but the manifest key stays `wolfenstein-weapon- plasmarifle` to match the weapon's own id in `rules.json`; a sheet's `path` is free to differ from its key). `WolfensteinView` builds one image per projectile weapon in `rules.weapons`, keyed by id (**generalized from a single hardcoded pistol image 2026-08-22**), and shows/hides them by `state.player.weapon`. Each real PNG is sized to the full shared canvas, **1920×1080 (`GAME_WIDTH`×`GAME_HEIGHT`), transparent background, gun art pre-positioned bottom-center** — `WolfensteinView` places that whole image at the origin `(0, 0)` rather than treating it as a small floating icon, so the gun's position in the frame comes from where it's drawn in the PNG, not from any offset in code. Depth 12: above the 3D view canvas and sprite billboards (10), below the crosshair (15) and bottom HUD bar (20) — the lower part of the gun art tucks behind the ammo bar exactly like a classic FPS viewmodel. `WolfensteinView._drawWeapon` adds a subtle bob (vertical bounce, `Math.abs(Math.sin(phase))`) and sway (horizontal drift, `Math.sin(phase * 0.5)`) while the player has forward/strafe input held, both driven off one shared phase accumulator (`WEAPON_BOB_SPEED`) that freezes when the player stops moving; a `WEAPON_BOB_SMOOTH_MS` lerp ramps the motion's strength in and out instead of snapping, so starting/stopping a step doesn't jerk the gun. An unpainted weapon (e.g. a future addition) falls back to its own `WolfensteinArt.paintWeapon` (a small 360×260 procedural placeholder, bottom-center anchored via `setOrigin(0.5, 1)` instead of the origin, one simple distinct silhouette per weapon) — the one case in this file where the real-art and placeholder branches use genuinely different Phaser image setup (origin/base position) per weapon, not just a different texture key, because the real PNG carries its own positioning and the placeholder can't. ## Sound effects None wired up yet — `WolfensteinGame._onSimEvent` has hooks for every event (`weaponFired`, `meleeHit`, `enemyDied`, `enemyMelee`, `doorOpen`/`doorClose`/ `doorLocked`, `secretFound`, `pickup`, `missionWon`/`missionLost`) but plays no audio. Once clips exist, add them to `assetManifest.js`'s `wolfenstein` entry (see the commented-out note there) and call `this.sound.play(...)` from the matching event branch.