~/adam.log

Custom Roguelike - 9/11/26

Published 2026-09-12

9/11/26

Real painted battle-arena backgrounds - Forest/Dungeon/Sewer

Picked up the “reconsider the arena’s static background/scenery” item from the battle-screen-redesign backlog. Talked through the design first, per the usual convention: the battle background already tracked the correct dungeon theme (Forest/Dungeon/Sewer share the exact same Box<dyn MapTheme> resource the map renderer reads), but it was still rendering that theme with the pre-tile-art technique - a flat tinted CP437 glyph fill plus a vignette and one of two generic scenery overlays (ScatteredTrees/RoomWalls), unchanged since before the map ever got real pixel-art tiles.


Considered reusing the map’s own tile art (map_tiles.png) to build the arena floor out of small repeating tiles, but the existing tile sets were never designed to read as a “stage” up close, and the user correctly called out that path would need a lot more themed tile variety to avoid looking stale fast. Landed instead on one full painted scene per theme - a proper JRPG-style battle backdrop (like classic Final Fantasy/Chrono Trigger, which paint the background and layer crisp sprite characters on top) rather than a tileable grid asset.


Getting the art right took real iteration, not one prompt

Sourced the art externally rather than generating it in-house (PixelLab is built for character sprites, not full painted scenes) - the user tried Lucid Origin. First Forest attempt: nice mood, but symmetric/mirrored (“radial clearing” is a classic diffusion-model default) with the two center tree trunks sitting right where enemies needed to stand. Second attempt broke the symmetry but added little creature figures the prompt explicitly excluded, and still had a trunk reaching into the enemy zone. Third attempt fixed both - clean, asymmetric, correct zones - but read as an open woodland clearing rather than an enclosed “stage” the way Dungeon/Sewer’s stone-walled rooms immediately did. A fourth, tightened prompt (thick fallen logs/root-wall along all four edges, mirroring Dungeon/Sewer’s solid perimeter) finally landed a version that reads as a proper boxed arena while staying distinctly forest.


That version still had two small hidden creatures the model snuck in (an obvious one in the open clearing, a second, subtler one - just a mouth/eyes tucked in a tree hollow - found on a closer double-check afterward). Both were isolated on fairly uniform texture, so patched both out locally with a feathered clone-stamp (copy a similar nearby patch, blend the seam with a soft elliptical mask) instead of spending another generation on it. Dungeon and Sewer both worked on the first real attempt - the “enclosed room” framing the user’s own prompt asked for came naturally to an indoor stone chamber in a way it didn’t for an open forest clearing.


Wiring it in: one new console, and the deepest z-order insertion this project has needed yet

Cropped/scaled all three (source images came back 1344x768, cropped to 1229px wide centered then scaled to exactly 1280x800 - the arena viewport’s real pixel size) and added MapTheme::battle_background_row (mirrors tile_row‘s existing Option<u16> shape/fallback exactly) so a theme without real art yet still falls back to the old procedural fill, unchanged.


Rendering the result needed a genuinely new console, not just new art - the backdrop has to sit below the battle screen’s own text (FINE_TEXT_CONSOLE) and creature portraits (BATTLE_PORTRAIT_CONSOLE), both of which had been bare literals (2 and 3) since the very beginning, never touched by any of the four previous “insert early, renumber everything after” moves this project’s console list has already been through - all of which only ever needed to go below the HUD, never this deep. Promoted both to real named constants as part of the insertion (the same reason HUD_CONSOLE/BIG_TEXT_CONSOLE were promoted once before) and bumped every console index in the file by one. A fully mechanical, grep-verified change - every literal 2/3 console call site across battle.rs/main.rs/end.rs/title.rs is enumerable by one search, so nothing could hide.


Hit the classic glyph-32 crash again, in a new shape: a single-column font sheet (one glyph = one full 1280x800 image, stacked 3 rows for the 3 themes) needs at least 33 rows just for cls()‘s default fill (glyph 32) to land on a valid cell, which would have meant an absurd 1280x26400 texture. Fixed by widening the atlas to a 6-column x 6-row grid (7680x4800, glyph 32 lands safely on row 5 - still blank) instead of a tall single column - same underlying gotcha CLAUDE.md already documents, just a new failure shape (a crash from too few TOTAL cells, not the sneaky “wrong row” tiling bug from previous sessions).


Verification: source-confirmed, screenshot-partial

Traced bracket-terminal’s own calc_step/rebuild_vertices source directly (not just inferred from behavior) to confirm a console’s grid always stretches to fill the entire window based purely on its own cols/rows, completely independent of its font’s tile pixel size - confirming the “1x1 console = one glyph spanning the whole screen” design actually works the way BATTLE_PORTRAIT_COLS/ROWS’s existing coarse-grid trick already relies on. Got a real screenshot of the title screen post-renumbering (via the same ad hoc python-xlib driver from prior sessions) confirming no crash and no regression to the console range below the insertion point. Could not get a screenshot of a live battle or Class Select specifically - the same WSLg synthetic-input unreliability documented in a prior session’s own notes (screenshots work regardless of focus; synthetic keyboard/mouse input doesn’t reliably reach the game window) - left the game running and asked the user to check those two screens directly with real input instead of sinking more time re-attempting the same xlib approach.


The white-crack bug - a shader I mis-modeled, not a new mystery

The user’s own live screenshots (all three themes, mid-battle) showed jagged white cracks tracing the darkest lines in every scene - mortar lines, canopy gaps, shadow edges. First instinct was to suspect the huge 7680x4800 texture or WSLg’s virtualized GPU, but tracing bracket-terminal’s actual CONSOLE_WITH_BG_FS shader source settled it directly: even a WITH-background console falls back to the flat per-vertex background color for any texture pixel whose RGB is all <=0.1 (~25/255) or whose alpha isn’t fully opaque - the exact same rule CLAUDE.md already documents for a _no_bg console, which the “WITH bg avoids this” assumption in this session’s own earlier design writeup turned out to be wrong about. The battle art was never floored the way every other sprite sheet in this project already is (>=30/channel on near-black pixels) - every shadow was tripping the shader’s fallback and rendering as solid white (the fallback color chosen for the backdrop draw call).


Fixed by reprocessing all three images with every channel floored to >=30 (imperceptible - verified against the patched Forest image, both clone-stamp fixes held up) and switching the fallback color from white to black as defense-in-depth, so any pixel that somehow still slips through blends into a dark scene instead of standing out. Added this as a documented third variant of the glyph-32-adjacent near-black-pixel gotcha in both CLAUDE.md and DEVLOG.md, since it’s the same underlying rule biting a new asset type (a full-scene backdrop) that nobody had reason to think needed the same treatment as a character sprite sheet.


Title screen upgrades - a new branch, camera clamping, and the frozen background enemies

Merged the battle-background work into master directly (no branch this time), then started a genuinely new branch for the next ask: “the enemies on the title screen should walk in place too” plus a related camera bug the user had separately noticed - visible black space around the map’s edges, both on the title screen and, it turned out, during real dungeon-crawl play too.


Diagnosing both bugs before touching code

Investigated first rather than guessing. The “frozen” enemies turned out to already have idle animation wired up correctly - the actual bug was a throttling mismatch: tick_idle_animation_system only ran once every BACKGROUND_MOVE_INTERVAL_MS (400ms, the enemy-wandering pace), and each time it ran it only added that single triggering frame’s real elapsed time (a few ms), not the ~400ms that had actually passed - so an idle frame took roughly 9 real seconds to advance. The class-select portrait right next to these enemies looked alive because it ticks its own timer directly, every frame, with no such throttle.


The black-void bug traced back to Camera never having any bounds awareness at all - it’s just a fixed DISPLAY_WIDTH x DISPLAY_HEIGHT window centered exactly on a target point, with no clamp against the map’s own SCREEN_WIDTH x SCREEN_HEIGHT. The title screen’s own one-shot camera placement made this obvious fast, since it centers on whatever random point a map architect happened to pick as “player start” (only one of three architects even picks something centered) and never updates afterward - but the exact same fixed-window-no-clamp math runs during real gameplay too, meaning walking close to any map edge shows the identical black void mid-run. Confirmed with the user that this was worth fixing centrally rather than patching the title screen alone: “the same logic should keep it within the dungeon map” for both.


The fix, and the wrinkle it exposed

Added a shared Camera::clamped_top_left helper - the same clamp math used by both Camera::new (title screen’s one-shot placement) and on_player_move (every real step). Wrote an exhaustive test (every possible target point on the map, not just a few samples) confirming the window never extends past bounds anywhere, then removed it per the usual “verify, then delete” convention - the math itself doesn’t need a permanent test.


The clamp exposed a real wrinkle in camera_render_offset (the sub-pixel smoothing during a glide): its own doc comment already said it was written to “deliberately match Camera::new/on_player_move’s own math exactly,” which stopped being true the moment that math started clamping. Fixed by having it interpolate between the ALREADY-clamped camera position at both ends of a glide (the tile moved from, and the tile moved to) instead of interpolating the player’s raw position and subtracting a constant half-window offset - the two only ever disagreed near a map edge, exactly where it would have mattered.


For the enemy animation, moved tick_animations/tick_idle_animation out of the title screen’s throttled 400ms movement schedule and into the schedule that already runs every real rendered frame - decoupling “how often does this enemy decide to take a new step” (still 400ms) from “how smoothly does its animation progress” (every frame, matching real gameplay). Verified for real with a burst of screenshots 80ms apart: a stationary background goblin visibly cycled through several distinct walk-in-place poses within under half a second, then stepped to its next tile right on the 400ms schedule - both halves working independently, as intended.


Also ran the exact hud_system_execution_tests-style check (build a real Schedule, .execute() it against a real World, confirm no AccessDenied panic) against the newly-combined schedule, since this was the first time tick_animations/tick_idle_animation ever ran alongside map_render/entity_render in the same place - passed, removed afterward per the same non-permanent-test convention.


One more thing found along the way, deliberately not fixed here

A user screenshot of a real 2-enemy Forest fight showed the second enemy pushed against the frame’s right edge, and the first overlapping the background’s own fence rail - enemy_portrait_position‘s fixed coarse-grid coordinates were tuned back when the arena background was a flat procedural fill with nothing near the edges, and don’t account for the new painted backdrops’ own perimeter scenery. Correctly flagged by the user as out of scope for the title-screen branch - logged in docs/ideas.md as a follow-up instead of scope-creeping this branch.


A real regression, then the enemy-position follow-up gets its own branch

Merged the title-screen work into master directly, then a doc-restructuring pass on docs/ideas.md after realizing my own “what’s on the todo” summary had missed whole sections - several already-finished items were still sitting in the numbered Working list with “(fixed)” notes instead of moving to Done, and two entire sections (Refactoring opportunities, Content/world) held real open work with no number at all. Folded both into the numbered list, moved finished work to Done, and wrote the fix into the doc’s own header as a standing rule - every open item gets a number from now on, no exceptions.


A real bug found by tracing math, not by guessing

Before touching the enemy-position follow-up, dug into a separate report: “we only see the counter and the stairs when the character is moving.” Traced it to a genuine off-by-one in the camera-clamp work from the previous session - Camera::bottom_y was defined as top_y + DISPLAY_HEIGHT, matching right_x‘s own formula, but the map-rendering loop consumes top_y..=bottom_y INCLUSIVELY while it consumes left_x..right_x EXCLUSIVELY - so bottom_y needed to be one less than the X-axis pattern, not the same. The old pre-clamp formula happened to give the right row count purely because DISPLAY_HEIGHT (25) is odd, which is exactly why nobody had ever noticed the Y-loop was inclusive at all until the clamp rework touched that formula. Confirmed the mechanism precisely before fixing anything: bracket-terminal’s plain console silently drops an out-of-range set() call with no panic, so the whole bottom row of the viewport just never drew while standing still, and only reappeared for the ~220ms of a glide (a different, bounds-check-free render path). Fixed with a one-line - 1, verified with an exhaustive test, merged straight to master given how severe it was (every player was missing part of their own view during ordinary standing-still play) rather than waiting for a live check the way the more subjective art-positioning work gets.


The enemy-position fix, in four real rounds

New branch, enemy-portrait-positions, for the backlog item the previous session had deliberately left alone. Every round was verified the same way: crop the actual theme art out of resources/battle_backgrounds.png, overlay the candidate grid with Python/PIL, look for real overlap - not guessed from memory of what the art looked like.


Round one replaced the old fixed 2-enemy/3-enemy/4-enemy coordinate table with a single evenly-spread row, verified clear of the fence/edges on all three themes. Shipped, then the user sent two live screenshots that didn’t match my own math at all - turned out to be a stale game process still running the OLD code from before the fix (Rust doesn’t hot-reload; a window launched before an edit keeps running the old binary no matter how much source changes afterward). Confirmed precisely by converting the screenshot’s own text-label positions back into grid coordinates using the same formula the code uses, landing almost exactly on the old formula’s numbers - not a guess, a re-derivation. Killed the stale process, rebuilt clean, got a real screenshot back.


Round two: “I dont like the line of enemies” - geometrically correct (nothing overlapping) but visually flat. Replaced the flat row with a shallow zigzag, alternating a back row and a front row by index parity, so a 3-enemy fight reads as a wedge and 2/4-enemy as a diagonal. Needed the Actions box’s own vertical position adjusted too, since the front row pushes an enemy’s name/HP text lower than the flat row did - simplified from a per-count special case (tuned for the old pyramid’s specific shape) to just “single enemy vs. any zigzag,” since the zigzag’s lowest row is now the same regardless of count.


Round three: “move it up and to the right.” Shifted the whole horizontal window right (closer to its safe ceiling before re-clipping the frame edge - confirmed there wasn’t much room left there), but pushing the back row up further immediately re-clipped Forest’s fence in a fresh screenshot check - a hard ceiling for that theme, not an arbitrary number, so flagged the trade-off back to the user rather than quietly refusing or quietly regressing Forest.


Round four: “we need to move them up” - the user wanted the up motion even knowing Forest was capped. Right call: gave each theme its own row values instead of one shared pair. Tested how far Dungeon/Sewer’s much thinner top wall/pipe band could actually go (as high as row 0.9 clipped Dungeon’s window sill/torch/crate; 1.5/1.9 landed clean on both, with Sewer having room to spare). New MapTheme::enemy_formation_rows(), same shape as tile_row/battle_background_row - defaults to Forest’s own conservative ceiling so a future theme without this checked stays safe, Dungeon/Sewer override higher. Confirmed “that might be perfect” on the next real screenshot, across all three themes at once.


A debug shortcut so this never needs a real 4-enemy encounter again

Along the way: “trying to run an instance and finding 4 enemies and then trying to round them up makes this very tough.” Added a new Debug-class cheat, “Battle 4” (glyph 9 - every other digit is already claimed by a weapon tier), that spawns 4 fresh Goblins next to the user and starts a real Battle instantly. New Templates::spawn_named_enemy_via_commands mirrors the existing spawn_named_item_via_commands pattern exactly - one exact named enemy via CommandBuffer instead of a random weighted pick. Verified for real, not just compiled: executed use_items‘s newly-added #[resource] battle parameter through a real Schedule (no AccessDenied panic - the exact class of bug this project has been bitten by before), confirmed the RON data loads with the right fields, and confirmed the effect actually produces a real 4-enemy Battle, not just that the code runs. This one tool made every subsequent round of the enemy-position work above dramatically faster to check - exactly what it was built for.


Full animation batch: Attack/Defend, every technique, enemy attacks

New branch, new-animation-batch, for the big one: the user delivered 14 zip files (all 5 playable classes + Debug + all 8 enemies) with a much fuller PixelLab export per character - Attack, Defend, Death, Victory, Idle_Battle_Stance, 4-directional Walk, 8-way rotations, and a named animation for nearly every real battle Technique. Per standing instruction, looked everything over and reported back BEFORE writing any code: structure, the recurring “canvas size can’t be trusted” gotcha (confirmed this batch that it can vary per-direction within one animation, not just per-character), 5 naming mismatches between art folders and real template.ron names, and 4 gaps (Barbarian missing Counter Attack, Hunter missing Shoot, Amazon missing Idle_Battle_Stance entirely, no enemy Death animations anywhere). Proposed splitting “ready now” (art swaps into systems that already exist) from “needs a design conversation” (out-of-combat Effect animations, directional facing) - confirmed understanding before starting, per the user’s own request for a full list of gaps/naming issues first.


What shipped

Two brand-new sheets, character_attack.png/character_defend.png (9 cols x 8 rows, same row layout as character_battle.png), give the plain Attack/Defend actions their own played-once animation instead of just holding the Idle_Battle_Stance loop the whole time. character_technique.png widened from 8 to 20 rows to fit a real animation for every remaining technique at once - 18 rows populated (up from 5), including every Barbarian/Rogue/Hunter/Mage/Amazon technique that had art. A new enemy-side sheet, enemy_attack.png (8 cols x 9 rows, mirrors enemy_battle.png‘s layout), gives enemies a one-shot attack animation for the first time - EnemyCombatant previously had zero concept of anything beyond its looping battle-idle frame. character_battle.png also got a full art refresh for every class’s Idle_Battle_Stance, including Amazon, whose export used a differently-named folder (Idle_Battle_Animation instead of Idle_Battle_Stance like everyone else) - caught and handled by folder name, not by adding a second row function.


Since Attack/Defend/Technique now live on three separate sheets/consoles instead of one, Battle::player_technique_animation got renamed to player_action_animation and is shared by all three (only one plays per turn, so no reason for parallel fields) - but draw_battle_arena still needs to know which of the three sheets a given glyph index resolves against, so a new sibling field, player_action_kind (PlayerActionKind::{Attack,Defend,Technique}), gets set and cleared in lockstep with the animation itself. The enemy side’s new ENEMY_ATTACK_CONSOLE got registered as a fancy console (unlike the two new character consoles, which are plain, mirroring CHARACTER_TECHNIQUE_CONSOLE) specifically so a 2+ enemy fight’s fractional zigzag position still lines up correctly during an enemy’s own attack - drawn via draw_portrait_fancy, same trick ENEMY_BATTLE_WIGGLE_CONSOLE already relies on for its own multi-enemy case, even though nothing on the attack console ever actually applies a wiggle offset.


A v2 zip arrived mid-build, folded in rather than bolted on after

Partway through the image-processing pass, the user sent a follow-up zip for exactly the 3 classes with gaps (Barbarian’s Counter Attack, Hunter’s Shoot, Amazon’s missing battle stance), plus renamed Amazon’s Spear_Volley/War_Cry/Spear_Throw folders to Javelin_Volley/Battle_Cry/Throw_Spear - matching the real names directly instead of needing the row-function’s mismatch workaround. Rather than finish the build against the now-stale v1 data and redo it, extracted the new zips alongside the originals and added a small character_dir()/V2_CHARACTERS redirect to the build script so Barbarian/Hunter/Amazon sourced from the newer export while everyone else kept using the original. Every rebuilt sheet got the same visual-verification pass as before committing anything - a labeled, cropped, 3x-upscaled strip confirming each changed row showed the right class/technique before it ever touched resources/.


Verified with a temporary test module (four tests covering every new row function plus the Counter Attack follow-up gap and the forbidden-row invariant), removed once green per the project’s usual “write it, verify, then remove” convention for logic a type-check alone can’t confirm. cargo build/cargo test clean, and a real launch confirmed no glyph-32 panic across all three new consoles’ first cls() sweep. Live in-fight verification still pending - no screenshot tooling available this session (scrot/xdotool/etc. all missing), so that’s on the user to confirm visually.


Still open, per the original phasing: enemy Death animations (the user is assembling these separately, boss-only - the four basic enemies won’t get one) and the out-of-combat Effect animations (Ice Armor, Invisible Cloak, Stealth, Throw Spear, Trap, Freeze Trap, Shoot) plus the directional-walking/8-way-facing architecture, both deferred to a design conversation after the ready-now work and the new zips are both in.


The enemy-tiling bug, then out-of-combat and movement animations both ship

The user tested and confirmed the full animation batch above looked good, then flagged something odd: a screenshot of the Adventure Select screen tiled with the exact same enemy sprite across the entire display. First guess was wrong - reasoned it was the intentional “fully-revealed decorative dungeon” title background (which genuinely can spawn up to 50 monsters via the automata/drunkard map builders) just looking unusually dense. The user pushed back with a second screenshot (“You are tiling an enemy across the entire screen this is NOT intentional”), and a third showing the exact same tiling inside a REAL dungeon-crawl level too - which ruled out the title-background theory outright and pointed straight at the classic glyph-32 gotcha instead.


Traced it to a real mistake in this session’s own build script: enemy_idle.png (6 columns, forbidden row 32/6==5) had reused the SAME row dict built for the 8-column enemy_battle.png/enemy_attack.png sheets, which correctly puts Goblin Chieftain on row 5 for those - but row 5 is exactly enemy_idle.png‘s own forbidden row. Real Goblin Chieftain walk-frame content sitting there meant cls()‘s default glyph-32 fill tiled it across every cell of ENEMY_IDLE_CONSOLE (which spans the full display) not otherwise redrawn that frame - both the title background AND every real level. Fixed with a dedicated row map for enemy_idle.png matching components.rs‘s own (already-correct) enemy_idle_row, asset-only, no code change.


Out-of-combat effect animations

With that fixed, moved on to the two remaining pieces from the earlier animation-batch conversation, out-of-combat first per the user’s own ordering. Seven class Abilities (Ice Armor, Invisible Cloak, Stealth, Throw Spear, Trap, Freeze Trap, Shoot) had real art sitting unused since technique animations only ever covered in-battle Techniques. New sheet character_effect.png (one row per (class, ability) pair, keyed by the real item name since RangedStrike alone covers both Throw Spear and Shoot with different art), a new EffectAnimation component set the instant one of these effects applies (use_items.rs) and ticked/cleared by a new system, and a new CHARACTER_EFFECT_CONSOLE trio that needed a genuine mid-chain console renumbering (23 constants shifted by 3, done via a name-anchored Python script rather than by hand) to sit below the HUD/Ability Bar at the same z-order tier as the player’s ordinary idle sprite. entity_render.rs‘s idle_glyph/idle_sheet - the two functions every dungeon-view render path already funnels through - just needed one new check each, no per-render-path changes. Verified with a new permanent legion-access regression test, matching hud_system_execution_tests‘s own precedent.


A real pixel-health bug, found by actually measuring instead of assuming

The user flagged transparent pixels in Hunter’s animations, then noted it looked like most classes had the same issue - worth a scan before touching more art. A full scan of all 1549 source frames (every class/enemy, every animation/direction, both original and v2 zips) found the alpha channel was genuinely clean everywhere (strictly binary, no anti-aliasing) - not the real problem. The real problem: 6-12% of every character’s OPAQUE pixels were near-black, and this session’s own build script had never actually applied the near-black floor CLAUDE.md’s own PixelLab gotcha calls for, despite using it to build every sheet so far. Fixed by wiring floor_pixels into the one function every frame already passes through, then rebuilding and re-verifying all 10 affected sheets (zero near-black-opaque, zero non-black-transparent confirmed by re-scan). Also caught, by the same defensive floor: Ettin Overlord specifically had ~4700 transparent pixels with leftover non-black RGB - the OTHER real risk this gotcha describes, since a plain console’s shader is a pure RGB colorkey that ignores alpha entirely. A separate check (enclosed transparent “holes” not connected to a frame’s border) found real holes in ~39% of frames, but a look at the worst offenders showed most are legitimate negative space (the inside curve of a drawn bow, gaps between limbs) rather than defects - logged for a human visual pass instead of risking an automated fix that could destroy real linework.


Real movement, and the bug the user predicted it would fix

Last piece: real walk-cycle animation synced to actual movement, using the 4-directional Walk art every class and enemy has had sitting unused since the first batch. The user correctly predicted this would also fix a related complaint - “the player and enemies become static again” while moving - and it did: tick_idle_animation had always deliberately paused during a glide, reasoning that movement already had its own animation, but MovingAnimation only ever tweened position, never a real pose. Removed the pause entirely (walk animation now just keeps advancing through a glide the same as while standing), and added a Direction enum (4-way only, no diagonal movement exists in this game) computed once per committed move in movement.rs, which rebuilds the mover’s IdleAnimation.frames in place for its new facing - preserving frame_index so a walk cycle doesn’t restart mid-stride on a turn. character_idle.png/enemy_idle.png both widened from 1 row per class/enemy to 4 (25/33 rows), each needing its own one-off forbidden-row exception (Rogue’s North, Orc’s North) - same shape as every other forbidden-row collision this project has hit. No changes needed in entity_render.rs at all - it already just reads whatever’s in IdleAnimation.frames. Verified with a third permanent legion-access regression test.


Session ended with the user heading to bed and asking for the branch finished by morning - out-of-combat animations, the pixel-health floor fix, and real directional movement all shipped and verified (build, tests, and a live launch check each) without a further check-in.