~/adam.log

Custom Roguelike - 9/07/26

Published 2026-09-08

9/07/26



Dungeon Crawl Store and Chest Spawns

There were way too many Potions and Maps just scattered around the dungeon floors - basically free loot. I want monsters to drop gold instead, and that gold to actually go somewhere: a shop between dungeon floors (mirroring the Battle Arena’s own shop), plus a guaranteed chest per floor so there’s still something to physically find.


Implementation

Gold used to be a Battle Arena-only component - a Dungeon Crawl player never got one at all, and every gold codepath (traps, ranged strikes, the arena shop’s buy handler) used that component’s presence as the actual signal for “does this kill/purchase involve gold.” Dungeon Crawl players now start with Gold(0) too, so those same codepaths started paying out for free on the Dungeon Crawl side - no per-mode branching needed.


Healing Potion and Dungeon Map are pulled out of the ambient floor-loot pool entirely - a new shop_only template flag, the same idea as the existing prefab_only/boss_only flags. They’re still real, grantable items (a chest, and eventually the shop), just no longer something you stumble onto scattered across a floor.


In their place, every dungeon floor now always gets one guaranteed loot chest, guarded by 1-2 copies of that floor’s single toughest non-boss enemy (Goblin/Orc/Ogre/Ettin’s own natural per-level ordering, not a random pick). It’s a new prefab room, always attempted rather than the random one-of-three Fortress/Turret/Bunker roll the other prefabs use. Walking onto it grants 30-50 gold, a Dungeon Map, and 1-3 Healing Potions in one lump, then shows a new full-screen loot overlay (TurnState::ChestOpened) styled exactly like the Paused screen - it reuses Paused’s own scheduler outright (just redraws the map tiles, nothing else), so the frozen dungeon stays visible underneath while every enemy/item sprite drawn on it a frame ago simply isn’t redrawn again.


Got a real icon drawn for the chest too (c glyph, row 6 col 3) from a reference image - the background got flood-filled to true transparency from its outer edge inward, rather than a flat color-distance threshold, so the interior white highlights on the chest survived instead of getting punched out along with the background.


A real bug, found by just asking

Giving Dungeon Crawl players a Gold component broke something non-obvious: record_enemy_kill‘s “roll random ability loot vs. grant Arena-only gold” branch, and the Victory screen’s “show loot vs. show gold” branch, both used Gold’s presence as their arena-check. Once Dungeon Crawl also had Gold, both silently started treating every Dungeon Crawl fight as if it were an Arena one - ability loot stopped dropping from real battles entirely, with no error or warning anywhere. Fixed by switching both checks to Option<ArenaRun>, which is actually Arena-exclusive; Battle Arena’s own behavior didn’t change at all, since Gold and ArenaRun were already perfectly correlated there. Also had to fix a startup panic from the same root cause - movement_system (which now reads the new chest-loot resource) is shared by the title screen’s decorative background, so that resource needed inserting at State::new()/return_to_title() too, not just the two run-start functions - CLAUDE.md already had this exact gotcha documented from an earlier session, I just didn’t check it at first.


The shop between floors

Built as a follow-up in this same session. A dungeon floor’s own stairs tile now leads into a shop room first, not straight to the next floor - reached via a new TurnState::DungeonShopTransition. systems/end_turn.rs‘s Exit-tile check is a 3-way split now instead of two: Arena’s own ArenaTransition, this new state, or - once ShoppingActive is already Some, meaning you’re standing on the SHOP’s own stairs, not the floor’s - NextLevel again, which is what actually generates the next floor.


Reused MapBuilder::new_arena_shop/arena_rebuild_keep_player/spawn_arena_shop_items/buy_nearby_item completely unmodified - all four turned out to be exactly as mode-agnostic as they looked going in. The only genuinely new code is State::dungeon_shop_transition itself, which stocks a fixed Healing Potion (x5) + Dungeon Map (x2) pair instead of Arena’s class-rolled weapon/ability list, since the dungeon shop doesn’t vary by class or level.


One thing that would’ve been an easy miss: advance_level is now ALSO how leaving the shop actually happens, but it never touched ShoppingActive/ShopMessage at all before (never needed to, since dungeon crawl floors never used to reset those). Without clearing them there, a freshly generated floor would’ve silently inherited the shop’s auto-pickup suppression and frozen field of view forever. Caught before it shipped, not after.


Also noticed the HUD’s top-right corner only ever showed Gold during a whole Arena run (arena_run.is_some()), never for Dungeon Crawl - meaning there was no way to actually see your gold total while standing in the new shop deciding what to buy. Now shows Gold whenever shopping.is_some() too, on top of the existing Arena case.


Chest reachability bug (found in playtesting)

Walked into my own chest room in-game and couldn’t get in - the wall template fully enclosed the interior on every side, unlike the existing Fortress/Turret/Bunker prefabs, which all have a deliberate gap somewhere in their wall pattern. Mine didn’t. Opened a door on the chest’s own row first, then moved it onto the guards’ row instead, so the only way in is past the guards - not a straight shot to the chest that just happens to have a guard standing nearby. Verified both times with a real flood-fill from the player’s start across 200 generated floors, not just eyeballing the ASCII.



Class survivability simulation

Once potions stopped scattering across dungeon floors, finishing a run got noticeably harder - wanted real numbers before guessing at a fix. Built a headless simulation: a naive bot (always Attacks, never uses a Technique or Flees, only drinks a Healing Potion below half health) plays 10 runs per class, using the REAL game logic end to end - the actual schedulers, movement, item pickup, chest interaction, and shop transition, not a separate simplified model. Combat itself goes straight through the same resolve_player_action/trigger_enemy_action/dismiss_action_result functions battle_tick calls, just with turn order simplified to “player attacks, then every living enemy attacks back” instead of real-time ATB gauge filling (no real frame loop to drive that headlessly) - battle_tick/chest_loot_tick/battle_victory_tick themselves can’t be called directly, since they make real ctx.set_active_console/ctx.print_* calls that need a live window’s console registry, which doesn’t exist in a plain test binary.


Result: 3 out of 50 runs (6%) reached the first shop at all. Mage died 10/10. Only Barbarian, Rogue, and Hunter managed even 1 successful run each. A real player using Techniques and fleeing bad fights would probably do somewhat better than this bot, but not enough to explain away a number that stark - the guaranteed chest sits behind combat you can’t avoid (guarded by that floor’s toughest enemy), and every class’s starting kit is now the entire sustain budget until the chest or shop, since ambient floor potions are gone. That budget was much too thin.


Building the bot itself surfaced a genuinely reusable insight for future headless testing: State::new()‘s title-background schedulers need AbilityBarMousePos, MouseLeftJustPressed, FrameTime, and a raw Point (mouse_pos) resource that main.rs’s real tick() sets every frame from a live window - none of which start_game itself inserts, since they’re a real-input concern, not a new-run one. Missing any of them panics the instant the relevant system runs.



Balance fixes from the simulation

Every class’s starting kit now carries 3 Healing Potions instead of 1 - Barbarian gets a kit at all now (previously none, relying purely on stats + loot). Mage’s Speed also went from 6 to 7, closing part of the gap between its rough early damage output and everything else’s.


The survivability simulation is now a permanent tool

Made the diagnostic a real fixture instead of a throwaway - #[ignore]d so it doesn’t run as part of the normal cargo test (it takes real time even in release), rerun by hand after any balance change: cargo test --release class_survivability_report -- --ignored --nocapture. Pointer added to CLAUDE.md so a future session actually remembers it exists.


Also made the bot itself less naive per request - it now flees below a quarter health instead of always Attacking to the death, and spends an owned offensive Technique (one-time-use, so this tapers off to plain Attack once a run’s kit is spent) before falling back to a plain Attack. battle::available_actions - the exact same function the real battle menu itself builds from - is what it picks a Technique out of.



A real shop bug (found by the user, not the simulation)

Couldn’t buy a Healing Potion in the new dungeon shop despite having enough gold and the tooltip clearly showing it in reach - no error, just nothing happened. Root cause: buy_nearby_item‘s own player lookup (<(Entity, &Point)>::query().iter(ecs).find_map(...)) had no Player filter at all - it just grabbed whichever entity with a Point component legion’s iteration happened to return first. A shop scene also has a Shopkeeper NPC and a Point-tagged ShopStock counter entity per item on sale, so this could just as easily be one of THOSE. It never visibly broke the Battle Arena shop (the player’s archetype apparently iterates first there, by chance), but the new Dungeon Crawl shop’s different entity-creation order (arena_rebuild_keep_player first, Shopkeeper/ShopStock pushed after) exposed it.


Fixed with a plain .filter(component::<Player>()) - one line. Verified properly both directions: built a test reproducing the exact scenario (Shopkeeper + ShopStock + player, all with Point), confirmed it genuinely FAILS against the old code (gold unchanged - the bug reproduces on demand, not just in theory), then confirmed it passes with the fix, then removed the test.



Re-running the simulation after the potion/speed/bug fixes

Deaths basically vanished - 2/50 across all five classes, down from 36/50 before the starting-kit changes. The potions and Mage’s speed bump look like they genuinely fixed the survivability problem.


But almost everyone times out instead of finishing now (43/50) - and I think that’s a bot-AI artifact, not a new real balance problem. The bot’s new “flee below 25% HP” rule ends a losing fight, but its pathing always recomputes the literal shortest route to the same target - if the enemy it just fled from is still sitting on that route (usually true, since Flee doesn’t reposition anyone), the very next action walks right back into it, fights again, flees again, and loops without ever making progress. A real player would obviously route around or retreat further first; this bot doesn’t know how to yet. Left open rather than rushed - the flee-then-repath loop needs a real fix (e.g. avoid re-pathing onto the same enemy for a turn or two after fleeing it) before the “reached the shop” numbers can be trusted again.


Two Small Backlog Cleanups

Cleaned up docs/ideas.md properly this time - moved everything actually finished (this session’s work, plus a couple of already-done items that were sitting unmarked) out of the numbered Working list and into real categorized Done sections, instead of leaving ~~strikethrough~~ items mixed into the numbered list. Committed the whole night’s work as one commit, then started a real feature branch (cleanup-arena-shop-dedup-and-tooltip-offset) for what came next, rather than continuing to commit straight to master.


Deduplicating the shop-building code

Three functions - start_arena (the very first Arena shop), arena_advance_to_next_shop (later Arena shops), and this session’s own dungeon_shop_transition (the new Dungeon Crawl shop) - had all accumulated their own copy of the exact same “build the room, reveal it with no fog of war, freeze the FOV, spawn the Shopkeeper, stock it, set the Exit tile” block. arena_advance_to_next_shop‘s own doc comment already flagged this as a known cleanup, unfinished from an earlier session; adding my own third copy this session made it worse, not better.


Extracted a single build_shop_room helper. It deliberately does NOT touch TurnState/Battle/BattleVictory/ArenaRun/Gold/Stats - those differ too much between a fresh-world bootstrap (start_arena) and an in-run transition (the other two) for a shared helper to guess correctly, so every caller still sets those itself right after calling it. Verified all three callers still produce a correct shop world (right player entity kept, right resources set, real stock spawned) with a real test before removing it - net result was about 70 fewer lines in main.rs.



The tooltips.rs camera offset

This one turned out more interesting than “swap one line for another.” tooltips.rs computed which map tile the mouse was hovering using Camera‘s own plain integer left_x/top_y - correct at rest, but during the ~150ms the camera is smoothly panning after a step, the actual on-screen position is a few pixels off from where that integer offset says it is (see components::camera_render_offset, which map_render/entity_render already use for the real drawing during a glide, for exactly this reason). Switched tooltips.rs to the same function, rounding the final fractional map position to the nearest tile.


camera_render_offset reads a MovingAnimation component internally, which tooltips_system hadn’t declared access to - added #[read_component(MovingAnimation)] to be safe, matching entity_render.rs/map_render.rs‘s own convention for this exact same call. Went to verify it the usual way (build a Schedule, force a real glide, confirm no AccessDenied panic without the declaration) - and it turns out this one genuinely doesn’t panic without the declaration in this particular schedule. Single-entity entry_ref().get_component() lookups apparently aren’t checked against the declared access list as strictly as bulk ::query() calls are, and the one system that actually WRITES MovingAnimation (tick_animations) is already .flush()-separated from this whole read-only batch, so there’s no live conflict to race against either. Kept the declaration anyway - it’s still the correct, honest description of what this system reads, and matches its siblings - but worth remembering this isn’t a universal safety net the way the hud.rs regression test’s own bug was: query access is real access control, a lone entry_ref read apparently isn’t enforced the same way.


Sprite sheet architecture question - and a real finding

Talked through whether per-class/per-enemy sprite sheets are possible, since I’m worried about running out of room in one shared atlas once battle animations, idle animations, more ability icons, and more dungeon tile variety all want space. Turns out this project is ALREADY running two sheets side by side (dungeonfont.png at 32x32 and terminal8x8.png at 8x8, both loaded via separate .with_font calls) - so multiple sheets are clearly possible, just not free: the current rendering pipeline draws every visible entity in ONE pass through ONE shared console, looking up glyphs as indices into ONE atlas. Per-class sheets would mean sorting entities by sheet every frame and drawing multiple batches into multiple consoles, plus a lot more console registrations to keep z-ordered correctly (already a documented gotcha here).


The actual useful discovery: checked bracket-lib’s own source and the 256-cell ceiling isn’t a real limit at all - FontCharType is a u16 (up to 65,536), and Font::load computes the glyph grid straight from the image’s own pixel dimensions divided by cell size, not a hardcoded 16x16. The 256 cap is purely this project’s OWN convention (to_cp437(char), which maps through CP437’s 256-value codepage). So the actual fix for “not enough room” is just a bigger single PNG - 1024x1024 or 2048x2048 gets 1,024 or 4,096 cells respectively, comfortably covering every animation/icon/tile-variant need with zero rendering-architecture changes. The one real cost is authoring: a much bigger canvas is harder to navigate by hand, which is where “author each class/theme in its own file, composite into the one shipped sheet” earns its keep - not as a workaround for an engine limit, since there isn’t one, but as a workflow choice once the canvas gets big.



AOE technique icons + Debug class glyphs

Went looking for exactly what still needed icons before doing any actual art. Two things fell out:


A real glyph collision, not just missing art. Debug’s own player-portrait glyph (class_base_stats‘s glyph: 'D') was the same codepoint as Deathblow’s already-finalized icon (Barbarian) - playing Debug, the player’s own map/portrait sprite rendered as Deathblow’s icon instead of anything distinct. Fixed by moving Debug’s portrait to N, and giving Victory/Defeat/Next Level (previously all three sharing the generic ? placeholder) their own distinct L/M/e. Verified with a real test - loaded the RON, confirmed all four new glyphs are genuinely distinct from each other and from every other template - before removing it. No pixel art yet for any of these four; Debug is hidden/test-only so real art here is a nice-to-have, not a priority - the collision was the part worth fixing on its own.


The five AOE techniques (one per class - Whirlwind, Blizzard, Flurry, Javelin Volley, Arrow Volley) already had their own reserved codepoints from an earlier session, just never got real art. Got reference images for all five and finalized them the same session. These turned out much easier than the chest icon from earlier - all five references were soft glow/motion-blur art (swirls, ice shards, streaking blades), not crisp linework, so a direct high-quality resize straight to 32x32 held up well without needing a hand-redraw. Filled each cell edge-to-edge with the reference’s own dark background (opaque, matching every other finalized ability icon), floored near-black pixels per the standing bracket-lib culling gotcha, and verified with a pixel diff that only those 5 cells changed on the whole sheet.


Debug class icons - three references, three different outcomes

Got three images for the Debug class’s remaining glyphs, one per open slot, plus “use the staircase” for Next Level with no reference at all. Each one ended up needing something different.


The trophy (Victory) had a watermark. A tiled, repeated diagonal text pattern across the whole image - stock-marketplace style, the same class of problem this project’s own docs already warned about from an earlier session (Invisible Cloak’s reference got blocked the same way once). Declined it and told the user directly rather than trying to work around it or crop it out - L stays a reservation only.


The robot (Debug portrait) was straightforward - treated it exactly like the other class portraits: flood-filled the flat pale background to true transparency, cropped to the character’s own bounding box, top-anchored onto a square canvas (the antenna touches the very top edge, same reasoning as the Shopkeeper’s own top-anchor from an earlier session), floored near-black pixels. Reads clearly as a distinct robot at 32x32.


The skull (Defeat) reference wasn’t actually art - it was a black-and-white graph-paper pixel-pattern chart, the kind of thing you’d use to plan a cross-stitch or bead pattern, not a rendered icon. Detected the grid spacing programmatically (found the periodic dark grid lines, computed cell size), sampled each cell to build a boolean mask, and rendered a REAL icon from that mask myself - dark red background, bone-white fill for the interior, black outline computed via simple erosion (filled cells with all 4 neighbors also filled = interior; everything else on the boundary = outline). The shape is faithful to the reference; the actual coloring was my own choice, since the source had none to copy.


Next Level didn’t need new art at all. Drew a hand-made staircase first (dark blue background, 4 ascending stone steps with lighter tread highlights) since no reference was given - looked clean on its own, but the user caught something better: the dungeon already has an established stairs glyph. TileType::Exit itself renders as plain > (map_builder/themes.rs). Reverted my custom icon back out of the sheet entirely and just re-pointed template.ron‘s Next Level entry at > directly - more consistent (the debug item now looks exactly like the real tile it simulates reaching) and one less custom icon to ever maintain. Worth remembering for next time: check for an already-established in-game symbol before inventing new art for something that’s essentially a shortcut TO that exact thing.


Victory, take two

Got a clean second version of the same trophy art - no watermark this time. Square-cropped, resized straight to 32x32, floored the near-black outline pixels. Kept its own plain white background rather than inventing a themed fill (unlike Defeat, which had no color to copy from its source). All four Debug glyphs are finalized now - nothing left open on the icon backlog.



Idle animations - a second sprite sheet, and a real lesson in background removal

Started the walk-in-place idle animation art (IdleAnimation‘s cycling infrastructure has existed for a while with every frame pointing at the same placeholder glyph - see earlier sessions). This was always going to need a real design conversation first per this project’s own convention for architecture-sized changes, and it turned into one: dungeonfont.png only had about 32 free cells left out of 256, and idle frames for 5 classes (plus enemies eventually) would burn through most of that. Decided on a second, dedicated sprite sheet (resources/character_idle.png) instead of cramming more into the shared one - a real architectural decision, not just an art one, since it means a new font, new consoles, and a render-path split still to come (see below).


Sheet shape. 128x128px per cell (4x dungeonfont’s native 32px) so the renderer’s own GPU-side downscale to the real 32px on-screen footprint does the final resize, not a lossy pre-shrink on my end. 5 columns (one per unique animation frame) rather than 6 - the reference art’s own “Idle (Loop)” 6th frame is always identical to frame 1, so it’s just there to show the loop closes, not a real 6th pose; dropping it matches MAX_IDLE_FRAMES: usize = 5, already reserved for exactly this, exactly. Rows are one per class in CLASS_ROSTER‘s own order (Barbarian, Rogue, Amazon, Hunter, Mage), sized to grow downward later for enemies with no code changes needed - just a taller PNG.


Classes without real art yet get their existing dungeon glyph, not a new placeholder scheme. Barbarian and Mage (art still pending) each just have their current 32x32 portrait upscaled with nearest-neighbor (keeps the blocky pixel-art edges crisp instead of blurring) and repeated across all 5 slots. This was a deliberate simplification the user suggested - it means every class routes through the same one sheet/lookup, no separate “does this class have real art” branch needed in the eventual render code.


Background removal turned into the real fight this session, across three separate reference batches for Rogue/Amazon/Hunter:

  • First pass (dark, near-black JPEG backgrounds, Hunter’s with an added glow/vignette): tried flood-fill-from-border color thresholding, then a bilinear four-corner background field, then OpenCV’s GrabCut. GrabCut produced genuinely clean silhouettes on Rogue/Amazon’s flat dark background but couldn’t cleanly separate Hunter’s glow (no hard edge exists in the source to find). Worse, tuning aggressively for a crisp edge caused GrabCut to occasionally drop real content - heads and feet vanished on some frames, confirmed by the user from a real screenshot. Wrong trade-off: chasing a clean edge cost completeness, and completeness matters more.
  • Root cause of the “why does frame 4 look different from frame 1” complaint: partly genuine - the user confirmed each frame has small real differences beyond a shared few-pixel vertical bob - and partly my own bug, since early attempts cropped each frame to its own independently-computed bounding box, adding scale/position jitter on top of whatever the source art actually did. Fixed by computing one shared crop/scale from the union of all 5 frames and anchoring every frame to the same foot baseline, so only genuine pose differences show.
  • The actual fix for Hunter: asked the user to re-export on a flat background instead of fighting the glow. What came back had a real alpha channel already (their own tool had cut it out), which sidestepped the whole segmentation problem - just needed cropping, a caption-text cutoff (found via a precise pixel-row ruler rather than guessed fractions), and the same shared-scale/baseline treatment.
  • Rogue and Amazon’s white-background versions hit two more distinct failure modes, both from the same root cause (a single global “is this white” threshold can’t tell “background” apart from “a light-colored enclosed gap in the pose” or “a soft shadow blob touching the feet with no clean seam”): a solid white block trapped between Rogue’s legs (background not connected to the image border through the flood fill, so it read as opaque “content”) - fixed by also clearing near-white connected regions above a minimum size regardless of border-touching, while leaving small isolated highlights (Amazon’s actual white armor/leg-wrap pixels) alone. Then a residual gray shadow smear under Amazon’s feet, too close in brightness to her boots to threshold out safely - fixed by keying on saturation instead (a flat gray shadow reads very differently from her saturated brown/orange boots even at the same brightness).
    <br />

The throughline: color-only background removal on real reference art is genuinely hard right up until the source has an actual alpha channel or a truly flat, uniform background - approximations (thresholds, gradients, graph-cut) each fail in their own particular way, and “keep every real pixel of the character” has to be the non-negotiable priority, with “how clean is the edge” a distant second. Going forward, new class/enemy art gets requested pre-cut (transparent PNG) or on a flat solid non-black background specifically because of this.


Master sheet locked in with Rogue, Amazon, Hunter (real art) and Barbarian, Mage (placeholder rows) - moved on to the actual code side same session: a new CHARACTER_IDLE_CONSOLE/CHARACTER_IDLE_SCROLL_CONSOLE/CHARACTER_IDLE_GLIDE_CONSOLE trio mirroring the dungeon view’s existing static/scroll/glide consoles (inserted right after ENTITY_SCROLL_CONSOLE, renumbering every console from HUD_CONSOLE on by +3 - same “insert early, renumber everything after” move this project’s own console history already documents twice), entity_render.rs‘s three draw loops each split on a new IdleAnimation::sheet field (IdleSpriteSheet::Dungeon vs CharacterIdle - a field on the existing component, not a new one, specifically to avoid a new #[read_component] declaration and the legion access-panic class of bug that would risk), and a new idle_frames_for_class(class, base_glyph) that looks up a class’s row on the new sheet, falling back to the old dungeonfont placeholder only for the hidden Debug class.


A real bracket-lib panic caught by actually running the game, not by any type check: attempt to subtract with overflow in bracket-terminal‘s own FontScaler::glyph_position, on startup, before any real content ever drew. Traced it to the library source - every console’s cls() fills all cells with glyph 32 by default, and the scaler computes that glyph’s row with no bounds check against the font’s actual grid size. character_idle.png‘s grid was only 5 columns x 5 rows (25 valid glyphs) - nowhere near 32. Fixed by padding the sheet to 5x8 (40 valid glyphs, comfortably past index 32, with the 3 new rows also doubling as the reserved space for enemies later) rather than special-casing glyph 32 anywhere in this project’s own code. Added to CLAUDE.md’s standing gotchas since any future custom with_font sheet in this project would hit the exact same wall.


A second real bracket-lib finding, this time caught by the user’s own screenshot (still-standing entities showed a solid dark box instead of a transparent background - walking ones looked fine): traced both shaders in bracket-terminal‘s own GLSL source to the actual root cause. A plain (with_simple_console_no_bg) console’s fragment shader discards a pixel only when ALL of its RGB channels read below 0.1 (25.5/255) - it never touches the alpha channel at all, so a real alpha channel does nothing there on its own. A fancy console’s shader is different: it shows texture content only when at least one RGB channel clears that same 0.1 cutoff AND alpha clears it too, else falls back to the per-vertex background color - which is also the actual mechanism behind the already-documented “bracket-lib culls near-black opaque pixels” gotcha, now precisely explained rather than just observed. character_idle.png‘s background pixels had a real alpha of 0 but non-black leftover RGB (whatever color was there before cutout), which a plain console’s shader doesn’t discard - exactly why the still-standing case (drawn on the new PLAIN CHARACTER_IDLE_CONSOLE) showed the box while gliding (a FANCY console) didn’t. Fixed at the asset level: forced every alpha-0 pixel’s RGB to true (0,0,0), and raised the “floor near-black content” convention from the previously-documented 10 to 30 (comfortably past the real 25.5 cutoff, with margin) so no part of a dark cloak/leather section risks the same discard. Corrected CLAUDE.md’s existing gotcha with the exact numbers instead of leaving the earlier approximate one in place.


That first fix wasn’t actually enough for Hunter specifically - the user caught a real gray box still showing on a second screenshot. Root cause: Hunter’s reference PNG (the one with a genuine alpha channel from the user’s own cutout tool) has a wide, continuous range of alpha values (confirmed earlier at 0-253, not a clean binary split) from a soft/feathered edge, not a hard cutout - my first pass only zeroed RGB where alpha was EXACTLY 0, leaving the whole semi-transparent fringe (non-black leftover RGB, alpha in the 1-127 range) fully opaque on the plain console, which - as the shader source confirms - ignores alpha entirely regardless of how close to 0 it is. Fixed by treating any pixel below a real alpha threshold (128, not just 0) as background for RGB-zeroing purposes - since a plain console can only ever show a pixel as fully-there or fully-gone anyway (no real alpha blending happens there), there was never a soft edge to preserve in the first place.


Also fixed the same session: Amazon rendered visibly shorter than the other classes. Traced to how each class’s frames were scaled to fit their target cell - by whichever bounding-box dimension (height or width) was larger, to avoid ever overflowing the cell. Amazon’s sword arm reaches out sideways far enough that her bounding box is WIDER than it is tall, which meant that safety scale was computed off her width, not her height - shrinking her actual standing height well below Rogue/Barbarian’s to keep the sword-reach inside the box. Switched to scaling strictly by height (letting a wide prop extend toward the cell edge instead of shrinking the whole character to accommodate it) - the right general rule for any future class/enemy art too, not just an Amazon-specific patch. Re-verified numerically after the fix: Barbarian/Rogue/Amazon all land within a pixel of the same content height; Hunter’s naturally a bit shorter from being crouched, not standing straight, which is correct, not a bug.


One more Amazon-specific tweak after seeing it in-game: even at the same measured height as the other classes, she still read as visually smaller - a plausible effect of her slimmer, more spread-out silhouette carrying less visual weight than Barbarian’s bulky frame or Rogue’s wide cloak at an identical bounding-box height. Bumped her scale up ~15% on top of the already-fixed cutout (no re-segmentation needed, just how much of the cell she fills) - new content height ~119px, close to the Mage placeholder’s 120px. Confirmed no clipping at any of the 5 frames before locking it in. User’s aware better Amazon reference art may come later and this is a good-enough stopgap either way.


Current state: resources/character_idle.png is done for this pass - Rogue, Amazon, Hunter with real art (transparent backgrounds confirmed correct on both plain and fancy consoles), Barbarian/Mage on placeholder rows, sized 5 cols x 8 rows (3 rows still reserved for enemies later). Code side (new console trio, entity_render.rs split, idle_frames_for_class) all in from earlier this session and confirmed working via the user’s own in-game screenshots, not just a clean build.


First real use of the new sheet outside the dungeon itself: Class Select’s highlighted class now plays its real idle-loop breathing animation instead of a static portrait, while every other row stays a plain still icon. New CLASS_SELECT_IDLE_CONSOLE (console 18, registered last so it always paints over console 3’s static icons) shares console 3’s coarse grid but sources from character_idle.png. No real ECS entity backs a class-select roster row, so this couldn’t reuse the dungeon’s own IdleAnimation/tick_idle_animation machinery - instead two plain State fields (class_select_anim_frame/_elapsed_ms) track it directly, the same “menu timing lives on State, not the ECS” convention background_move_timer_ms already established. Pulled the class-to-sheet-row mapping out of idle_frames_for_class into a shared character_idle_row/character_idle_glyph pair in components.rs specifically so this and the in-dungeon spawn path can never drift out of sync with each other.



PixelLab.ai - the first real batch, and rebuilding the idle sheet around it

User settled on PixelLab.ai for future art generation (see the earlier “put a pin in it” conversation) and sent the first real batch: a Hunter export, delivered as a zip with a genuinely different, much richer shape than anything hand-supplied before - 8-directional static rotations, plus THREE separate named animations (Breathing_Idle 4 frames south-only, Fight_Stance_Idle 8 frames in south+east, Walk 6 frames south-only), all at native 32x32 with clean binary alpha and - unlike every earlier reference image this project processed - background pixels already baked to true (0,0,0) wherever transparent. No segmentation work needed at all this time; PixelLab’s own export already matches this project’s exact rendering requirements out of the box.


Scoped the integration before touching anything: Breathing_Idle set aside for now: Walk/south becomes the dungeon/Arena/Class-Select walk-in-place animation (replacing the older manual-pipeline breathing content for Hunter specifically), and Fight_Stance_Idle/east becomes a brand new battle-screen portrait animation - battle previously had NO portrait animation at all, always showing the player’s static base glyph regardless of attacking/idle.


A real “how do we scale this” architecture question came up along the way: since every future class AND enemy AND NPC (even the Shopkeeper) will eventually get real PixelLab animations, would one full sprite sheet per character scale better than the current “one sheet per animation type, one row per character” approach? Reasoned through it from bracket-lib’s actual constraints: a distinct sheet needs a distinct registered font AND its own set of consoles (plain + glide + scroll for the dungeon, plain + wiggle for battle) - the expensive, code-complexity-heavy resource is CONSOLES, not image rows. One sheet per character would multiply consoles per character; one sheet per render-context (growing rows for free) keeps console count bounded to the number of distinct contexts regardless of roster size. Kept the existing per-context-sheet approach on this reasoning.


Bumped character_idle.png from 5 to 6 frame columns (MAX_IDLE_FRAMES/CHARACTER_IDLE_COLS) to fit Hunter’s real 6-frame Walk cycle - Rogue/Amazon’s own older 5-frame content got a 6th column too (a duplicate of their own frame 1, the same “loop closes” convention the very first reference sheets used before being dropped for being redundant - genuinely useful again now that the column count needs to match across every row). Barbarian/Mage’s placeholder rows just repeat across 6 columns same as before.


New: a real battle-portrait animation, previously not a feature at all. New resources/character_battle.png (one row per class, 8 columns matching Fight_Stance_Idle’s real frame count, native 32x32 cells - no oversampling concern, this is the same resolution the EXISTING dungeonfont-sourced battle portraits already blow up to a big cell from) plus a new plain+fancy console pair (CHARACTER_BATTLE_CONSOLE/CHARACTER_BATTLE_WIGGLE_CONSOLE) mirroring console 3/BATTLE_PORTRAIT_WIGGLE_CONSOLE‘s existing split exactly - draw_wiggling_portrait/draw_portrait/draw_portrait_fancy didn’t need touching at all, they just draw whatever Render/console a caller already targeted, so the same helpers work unchanged against the new console. The animation itself lives on two new fields directly on Battle (player_idle_frame/_elapsed_ms, ticked in battle_tick right alongside the existing flash/damage-popup timers) rather than the ECS IdleAnimation component - a battle portrait isn’t a dungeon-view entity, and Battle already persists for exactly the lifetime (fresh at 0 every new fight) this needed. Scope is player-only for now, matching what was actually asked - enemies keep their existing static dungeonfont portraits. Renamed character_idle_row to the more general class_sheet_row once a SECOND per-class sheet existed, so both character_idle_glyph and the new character_battle_glyph share one row-assignment source of truth instead of two copies that could drift.


A real, reproducible bug shipped with that: the ENTIRE title screen filled with tiled Mage portraits, on every screen in the game, not just battle. Root cause: character_battle.png has 8 columns, and bracket-terminal’s cls() fills every never-drawn-this-frame cell with glyph 32 by default - 32 / 8 == 4 exactly, and row 4 was Mage’s row under the shared class_sheet_row mapping. character_idle.png (6 columns, 32 / 6 == 5) happened to dodge this purely by landing in an already-reserved blank row - luck, not a guarantee, and exactly why the same shared mapping broke the moment a second sheet with a DIFFERENT column count reused it. Fixed with a dedicated character_battle_row (Mage moved to row 5, row 4 deliberately left blank) instead of reusing the shared one, and wrote up the general form of this gotcha in CLAUDE.md: fixing the crash (enough total rows) doesn’t fix this quieter failure mode (the specific row 32 / cols lands on must actually BE blank) - two different bars to clear, not one.


Then: replacing the old dungeonfont class icon everywhere it was still used outside the animation systems. IdleAnimation/battle-idle already covered the dungeon-map tile and the battle portrait, but two more sites drew the player’s raw Render.glyph (dungeonfont) directly, bypassing both: Class Select’s non-highlighted row icon, and the dungeon HUD’s player-status portrait (top-left corner). New resources/character_portrait.png (one still pose per class - PixelLab’s own rotations/south.png for Hunter - column 0 only, reusing character_idle.png’s already-proven-safe 6-column layout rather than inventing a new one) plus two more consoles (CHARACTER_PORTRAIT_CLASS_SELECT_CONSOLE, CHARACTER_PORTRAIT_HUD_CONSOLE) cover both remaining sites, each falling back to the old dungeonfont behavior for any class without a row there yet. That’s every current use of a class’s static identity now routed through per-class sheets except the base Render.glyph/ClassBaseStats.glyph fields themselves, which still need to exist as the fallback for classes without real art.



A Battle Arena balance pass, and a real Dijkstra library bug along the way

Dungeon Crawl got a real data-driven balance pass earlier (the class-survivability simulation); Battle Arena never had one. Extended the same approach: a new arena_class_survivability_report test, sharing every helper the Dungeon Crawl one already had (step_toward, use_potion, choose_battle_action, resolve_battle) but with its own navigation policy, since Arena’s structure is genuinely different - no ambient floor loot at all, so a bot needs to actually SHOP (new shop_step: walk to the Healing Potion stack, buy while affordable and in stock, then head for the exit - deliberately simple, ignoring weapons/abilities for now), and wave combat means walking toward whichever enemy is closest (new wave_step) rather than toward a fixed exit tile. Tracks a real ArenaOutcome (Won the full 3-level run / Died at a specific level+wave-or-boss / TimedOut) instead of the Dungeon Crawl one’s simpler “reached the first shop” binary.


First run: 0/10 won for every class, all 50 runs timed out, zero deaths at all. That’s not a balance signal, it’s a stuck bot - nothing dies in a game with real combat unless something is fundamentally not happening. Added a temporary debug probe (removed once diagnosed, per this project’s own “write a real test, then remove it” convention) printing player position/gold/TurnState every step of one real run, and found the bot endlessly oscillating one tile away from the shop’s own exit tile, forever, having already correctly bought potions.


Root cause traced all the way into bracket-pathfinding‘s own source, not this project’s code: DijkstraMap::build() seeds its internal queue with the target at depth 0.0, but never actually writes that 0.0 into the target’s own dm.map[] slot - only neighbors’ own later relaxation passes overwrite it, landing at roughly the edge cost back to whichever neighbor got there first (~2.0 for one cardinal hop) instead of the true 0. A step_toward bot picking “whichever neighboring tile has the lowest reported distance” can therefore see the ACTUAL target report a WORSE number than a tile genuinely farther away, and get stuck in a stable back-and-forth right next to it - worst-cased exactly here, since the Arena shop’s exit sits against a wall on one side with only 3 real approach directions instead of 4, making the specific tie/cycle far more likely to actually manifest than in a more open dungeon-floor exit’s usual surroundings (which is very likely why this exact defect never surfaced during the Dungeon Crawl simulation’s own development, despite step_toward being shared code that was always theoretically exposed to it). First fix was a special case (“this candidate IS the literal target, take it unconditionally”) - held up for THAT specific cell, but a second real reproduction turned up a DIFFERENT stable 2-cycle a couple of tiles away (the library’s open list is a plain FIFO queue, not a priority queue, so a wrong seed value can itself get used as a base by further relaxations and corrupt more than just its own cell). Patching individual symptomatic cells wasn’t going to hold indefinitely, so step_toward now uses a small from-scratch BFS (bfs_distance_field) instead of the library function entirely - every step here costs exactly 1, so plain BFS is the textbook-correct algorithm anyway, not a workaround. Documented precisely in CLAUDE.md’s standing gotchas, since any future Dijkstra-seeded navigation in this project would hit the identical wall.


A third, unrelated bug surfaced once navigation itself was solid: the bot kept undoing its own progress toward the exit. try_buy_item checked “am I standing next to this item” BEFORE checking “can I actually afford it” - so once gold ran out, an entity not currently adjacent to the (now unaffordable) Healing Potion stack would still blindly walk toward it every single tick, only bailing out once it happened to arrive next to it, fighting whatever the NEXT tick’s real priority (heading for the exit) had just accomplished. A real ordering bug, not a pathfinding one - swapped the two checks so affordability is settled first, before ever deciding to navigate anywhere.


With all three fixed, the simulation finally produced complete, trustworthy data - zero timeouts across all 50 runs (10 per class), each one now resolving to a real win or a real death:

Barbarian: 0/10 won - deaths: L3 boss, L3W2, L3W3, L3W2, L3W3, L3W3, L3 boss, L3W2, L3W3, L3W2
Mage:      0/10 won - deaths: L2W3, L1 boss, L2 boss, L2 boss, L2 boss, L2 boss, L2 boss, L2 boss, L2 boss, L2W2
Rogue:     0/10 won - deaths: L3 boss, L2 boss, L3 boss, L2 boss, L3W3, L3W2, L3 boss, L3 boss, L3W2, L2W2
Amazon:    0/10 won - deaths: L2 boss, L3W2, L2 boss, L3 boss, L3W2, L2 boss, L3W1, L3W2, L3W1, L3 boss
Hunter:    0/10 won - deaths: L3W2, L2W2, L3W3, L3W2, L3W2, L3W2, L3W2, L3W3, L3W2, L3W3

Zero clears for every class with this bot’s policy (one weapon tier per level, potions with whatever’s left, no ability purchases) - a real lower-middle-bound signal, same spirit as the Dungeon Crawl simulation’s own documented caveat, not a verdict on real play. Mage is the clear outlier dying earliest and most often at Level 2’s boss specifically, matching its already-known fragility (Defense -1, lowest HP). Barbarian and Hunter both consistently reach Level 3 before dying, Barbarian slightly deeper on average. Level 3 (especially its boss and late waves) is a real wall for every class as currently tuned - worth a closer look before any specific number changes, not acted on blind from this one data pass alone.



Closing out the still-icon migration, then the full 5-class PixelLab roster

Two more sites were still drawing the player’s raw dungeonfont Render.glyph directly, missed by the earlier “replace every old icon” pass: screens/end.rs‘s draw_end_screen_portrait (the run-ending Victory screen’s hero icon - distinct from battle.rs‘s own per-fight Victory screen, already fixed) and draw_end_screen_fallen_portrait (the Game Over screen’s rotated fallen-hero pose). Both now try character_portrait_glyph first, same as every other site. The rotated Game Over pose needed a genuinely new console - END_SCREEN_FALLEN_PORTRAIT_CONSOLE (23), a fancy console sourced from character_portrait.png at the same DISPLAY_WIDTH x DISPLAY_HEIGHT/32px-cell grid as END_SCREEN_FALLEN_CONSOLE - since set_fancy‘s rotation only works against whatever font a given console is bound to, and the old console is permanently bound to dungeonfont.png. Also gave draw_battle_arena (the in-battle Victory screen) its intended middle tier: still-portrait fallback via character_portrait_glyph, slotted between the animated battle-idle tier and the raw-glyph last resort, on the console renamed to CHARACTER_PORTRAIT_BIG_CONSOLE since it’s now shared by three call sites instead of one.


Then the user sent real PixelLab exports for the remaining four classes - Mage, Rogue, Amazon, Barbarian - one zip per message, each in the exact same shape as Hunter’s original. Same integration recipe applied five-for-five, no new code needed for any of them (every class already had a reserved row from the earlier placeholder pass): Walk/south‘s 6 frames into character_idle.png (upscaled 4x nearest-neighbor into that sheet’s 128px cells), Fight_Stance_Idle/east‘s 8 frames into character_battle.png at native 32x32, rotations/south.png into character_portrait.png column 0, Breathing_Idle set aside every time. Each class’s opaque pixels got the same near-black floor (≥30 per channel) applied fresh, since the exact fraction needing it varied a lot by art (roughly half of Mage’s dark-robe pixels qualified, a third of Rogue’s) - confirmed via numpy rather than assumed, per class.


Rogue and Amazon weren’t blank placeholders like Mage/Barbarian were - they already held real, varying frames from an older hand-processed pipeline (predating PixelLab, visible in scratch files like rogue_final_*/amazon_v6_* from earlier in the project). Confirmed which was which with a quick numpy pixel-diff between two frames of the same row (identical frames = untouched placeholder, real diff = actual content) before deciding to overwrite either one - “everything should follow the same paths for the classes” meant standardizing all five on the new pipeline, superseding the older hand-made Rogue/Amazon art rather than leaving it as a special case.


Every row’s own neighbors got a visual before/after check after each swap (crop the row above and below, confirm no bleed) rather than trusting the paste-region math alone - cheap insurance against a copy-paste sizing mistake silently clobbering an adjacent class. All five classes are now on real PixelLab art across all three sheets (idle/battle/portrait); the old dungeonfont-glyph fallback in every lookup function still exists but has no current class left that actually falls through to it.


A sixth zip arrived for “Debug” - the hidden dev/test class (real base stats in spawner::class_base_stats, a real starting kit and template techniques, reachable only via a ‘D’ hotkey in class_select that isn’t part of the visible CLASS_ROSTER list) - a Robot-themed character. Unlike the five visible classes, Debug had genuinely never been given a row on any sheet before, so this needed one real (tiny) code change alongside the asset work: "Debug" => Some(5) added to class_sheet_row (idle/portrait sheets) and "Debug" => Some(6) added to character_battle_row (row 6, since row 4 stays permanently blank and row 5 is Mage’s on that sheet specifically). Both landed in rows that were already blank on the existing PNGs - no resize needed, confirming the “rows 5-7 reserved” headroom mentioned in earlier entries was exactly for cases like this. No other code changes were needed; idle_frames_for_class and every glyph-lookup function already key off these two row functions, so Debug’s dungeon/battle/portrait rendering came online automatically once the row existed.


That “Some(5)” on class_sheet_row was the bug, confirmed by a user screenshot minutes later: the Adventure Select screen tiled wall-to-wall with Robot portraits, same shape as the earlier Mage incident. class_sheet_row was shared by BOTH character_portrait_glyph (portrait sheet) AND, at the time, character_idle_glyph/idle_frames_for_class (idle sheet) - safe for the portrait sheet (it only ever populates column 0 of a row, and glyph 32’s column on a 6-col sheet is column 2, permanently blank there regardless of row), but NOT safe for the idle sheet, which fills every column of a class’s row with a real walk-cycle frame. Row 5 is exactly where glyph 32 lands on a 6-column sheet (32 / 6 == 5) - the same “intentionally reserved, blank-so-far” row every earlier entry noted as safe purely because nothing had been assigned there yet. Debug’s assignment was the first real content ever placed on that row, and the bug was immediate and total (CHARACTER_IDLE_CONSOLE spans the full display). Fix: split a dedicated character_idle_row off from class_sheet_row (mirroring character_battle_row‘s existing pattern exactly) that permanently skips row 5 and puts Debug on row 6 instead; class_sheet_row now serves the portrait sheet only, where reuse is actually proven safe rather than merely lucky. Moved Debug’s already-composited idle frames from row 5 to row 6 in the PNG itself and re-blanked row 5. Lesson written up directly in the new function’s own doc comment rather than just CLAUDE.md, since this is the second confirmed real occurrence of the exact same failure class (first on character_battle.png/Mage, now on character_idle.png/Debug) - a strong signal that “reuse the shared per-class row mapping” is the actual anti-pattern here, not a one-off mistake, and any THIRD per-class sheet added later should get its own dedicated row function from the start rather than starting from the shared one and hoping.



Two real asset-pipeline bugs the user caught by eye, neither one code

The user reported Mage’s Class Select row “still has the original icon below the walking animation” - and immediately corrected an early guess that this was a title.rs logic bug: “It’s the artwork, not the code.” Right call - a numpy pixel-diff (compare the current sheet’s Mage row against a fresh render of the same source frames) found ~3100 leftover opaque pixels per idle-sheet frame and ~230 per battle-sheet frame, all exactly matching the OLD placeholder’s own RGB values. Root cause: Mage was the very first class composited this session, and that very first script pasted new frames straight onto the sheet with no “clear the cell first” step - later classes (Rogue onward) got that step added after Rogue/Amazon turned out to already hold real (not placeholder) older art that needed clearing, but Mage’s own two sheets (idle, battle - portrait WAS blanked first, and came out clean) never got backfilled with the same fix once the pattern was established. Wherever the new frame’s silhouette didn’t fully cover the old placeholder’s silhouette (different pose, different edges), the old pixels peeked through underneath - “below the walking animation” was literally accurate, not just a loose description. Fixed by re-running Mage’s idle/battle composite with the now-standard blank-first step.


While verifying no other class had the same leftover-pixel bug, a second, unrelated, and much bigger issue turned up: the user separately asked “are you changing the size of any of the images” after noticing animations looking smaller than the still portraits. Checking every class’s actual PNG dimensions (not just trusting metadata.json‘s declared 32x32) found PixelLab does NOT export every animation state at a fixed 32x32 canvas - rotations/south.png and (for 5 of 6 classes) Fight_Stance_Idle are 32x32, but Walk varies per class: Hunter 48x48, Rogue/Barbarian/Debug 44x44, Amazon 40x40, only Mage’s actually 32x32 (coincidentally, which is exactly why Mage never showed the scale bug). Comparing bounding boxes across differently-sized canvases for the same class showed the character’s own absolute pixel size stays constant and roughly centered regardless of canvas size - PixelLab just gives some animations more padding to avoid clipping during a bigger range of motion (arm/leg swing), it doesn’t actually render the character bigger or smaller. Since the idle-sheet compositing script resized “whatever canvas size” straight to the sheet’s 128px cell, a padded 48x48 canvas got a smaller effective scale-up (128/48 ≈ 2.7x) than a tight 32x32 canvas (128/32 = 4x) - the exact “animations become smaller” the user noticed, present for every class except Mage. Separately, Debug’s 44x44 Fight_Stance_Idle frames had been direct-pasted (no resize at all) into the battle sheet’s 32x32-native cell slots, which doesn’t crop or scale - it just draws the full 44x44 image starting at that position, bleeding 12px into each neighboring cell.


Fix: normalize every frame to a true 32x32 canvas (center-crop when larger) before any further processing, not just resize-to-fit-cell. Confirmed via the same bbox check that every affected class’s character content fits comfortably inside a centered 32x32 crop of its larger canvas (closest call was Rogue/Barbarian/Debug’s Fight/Walk bbox extending to y=36 against a crop boundary at 37 - real margin, not a coincidence). Re-composited: character_idle.png rows 0/1/2/3/6 (Barbarian/Rogue/Amazon/Hunter/Debug - Mage’s row 4 was already correct scale, separately fixed for the leftover-pixel bug above) and character_battle.png row 6 (Debug only). Verified with the same leftover-pixel diff AND a fresh bbox-in-final-cell check (Hunter’s walk frame went from an under-scaled ~40x80px footprint in its 128px cell to the correct ~60x120px matching a true 4x scale) that every class, every sheet is now clean - zero leftover pixels, zero cross-cell bleed. Lesson for every future PixelLab batch: never assume metadata.json‘s declared size describes every exported frame’s actual canvas - check each animation state’s real PNG dimensions before compositing, and center-crop to 32x32 whenever it’s larger.