Custom Roguelike - Themed Dungeons: Their Own Enemies, a Walking Cat, and an Enemy HP Budget
Published 2026-09-28
Themed Dungeons: Their Own Enemies, a Walking Cat, and an Enemy HP Budget
Reorganizing the to-do list, item by item
docs/ideas.md had turned into a pile of things I kept adding, so before starting anything new I went through every item with Claude, one at a time, until each one said what I actually want. The result:
- Order: the first four items are the active batch, in build order - the themed enemies per dungeon, the AOE rework, the town cat rework, and the code refactor (an always-open section). After that I push a full build, and only then run the full balance re-pass, because the new enemies and the AOE rework move every win rate. The goal of that re-pass is that every class can finish every adventure; the Battle Arena (the Mage is close to unplayable there) becomes its own separate pass, with starting kits and gold on the table. The rest of the list is not in priority order - I ask what is on the list and pick from it.
- AOE: they wipe every enemy however strong, and you can save one for a boss. Two ideas so far: fewer AOE attacks or one true AOE, or the same number of attacks at 0.5x-0.75x damage.
- Town cat: I want to actually see the sprite. Each time I enter town there is a 65% chance a cat is there, and a 10% chance of that being the quest cat (about 6.5% of visits). It wanders on walkable tiles on a real-time timer that does not depend on my turns, avoids the quest spots, and stops and looks at me within a tile or two. The quests stay the same.
- Endless: it ships with the full game (Steam starts as Early Access with a demo). After each dungeon I pick an upgrade I keep for the run - a stat boost, an ability, a trinket - and the enemies get buffed when I go back in. The story: I finished the game, went to sleep, and woke up pulled into the nether for holding the Blades too long. Trinkets are the only equipment I want to add, and only for Endless. Saving Endless happens right after the upgrade pick, with a fresh dungeon seed that is not stored in the save so a reload cannot replay a floor.
- The opening: the first time the game starts, a one-off dungeon with a very powerful hero who has gathered all the Blades, kills the demon and falls. The Elder then tells the acolytes to spread the Blades as far away as possible - which is what the themed dungeons are. That hero is also going to be one of Endless’s final bosses. There is also one more theme coming: Mountains.
- Parked or dropped: the Fortress Ingot flux guard, unlock rewards (every armor set would have to still look like the same character), the bots skipping the cat, the town (it is finished), modding (wanted, but after the full release), and audio (waiting on me finding music and sound effects).
-
A real size problem:
enemy_idle.pngis 4224px tall for 8 enemies, four rows each. About 25 more enemies would make it roughly 17,000px, past the 16,384 texture limit. The fix is to repack it to one row per enemy with the four directions side by side (24 columns), and it has to happen before the new art goes in. Frame counts stay exactly what they are for every animation (walk 6 per direction, battle idle 8, attack 8) - that is now a rule inCLAUDE.md.
Starting the themed enemies: Forest is what I have now, Sewer goes first
New branch, themed-enemies-sewer. The eight enemies I have today (Goblin, Orc, Ogre, Ettin and the four bosses) become the Forest set, and the Sewer set is the first new one. Six PixelLab zips for it are already in my Downloads folder: Rat, Green Slime, Red Slime, Kobold, Skeleton and Skeleton King. Each has a Walk in four directions, an Attack and a battle idle (the Kobold’s walk folder has the usual prompt-derived name). What the code does today: an enemy’s levels field is the floor number within a run (0-2), a floor’s theme is rolled per floor from dungeon_theme_pool(), and the spawner only knows the floor number, not the theme, so nothing decides which enemies belong to which theme yet.
The Sewer plan I settled on: floor 0 has the Rat and the Green Slime, floor 1 the Red Slime and the Kobold, floor 2 the Skeleton. The bosses are new kinds of fight, not just bigger enemies: a Rat Pack on floor 0 (four rats at once), a Large Red Slime on floor 1 that splits into two normal slimes and then each of those into two small ones (same animations, just drawn bigger and smaller), and the Skeleton King on floor 2. Those fights need a way to choose which enemy I hit, so I want a target step after picking an attack - it needs a proper design pass before I build it. I also asked for the Mountain art prompts so I can start generating that theme, and they are in docs/Mountain_Art_Prompts.md (tileset, battle background, two Victory scenes and a Defeat scene, written from what went wrong for Forest and Swamp).
Step 1 done: enemy_idle.png is repacked
Before any Sewer art goes in, I repacked enemy_idle.png from four rows per enemy (768 x 4224) to one row per enemy with the four facings side by side (3072 x 1152, 24 columns). enemy_idle_row now returns just the enemy’s row, and enemy_idle_frames picks the facing’s six columns; the old Orc-North exception is gone because the forbidden glyph-32 row is now row 1, which no enemy uses. tools/repack_enemy_idle.py does the move and then rebuilds the old sheet from the new one and compares every enemy’s cells byte for byte, so nothing but position changed. cargo build, cargo test (47 passed) and a real launch are clean, but I still need to look at the walk animations in the game to be sure the facings all point the right way.
Step 2 done: every enemy can belong to a theme, and the spawner uses it
Added a theme field to Template (an Option<EndSceneTheme> - the same enum the map/background art already keys off). Left unset, it means theme-agnostic, which is every non-Enemy template (potions, gold, weapons); every one of the 8 existing enemies got tagged Some(Forest) in template.ron. The spawner (spawn_entities, spawn_prefab_enemies, spawn_prefab_chest_guards, spawn_boss) now filters enemies by theme as well as by floor level. Since Dungeon/Swamp/Sewer have no roster of their own yet, I added Templates::resolve_enemy_theme/resolve_boss_theme: they check whether the floor’s real theme actually has any matching template, and fall back to Forest’s roster if not, resolved once per floor in run_flow.rs right after MapBuilder::new. Since that resolution reads the map builder’s own theme (random, or the Debug Theme Select’s forced one), a forced theme test now forces the matching enemies too, with no separate wiring needed. The Battle Arena keeps its own hardcoded Forest theme (it’s supposed to stay exactly as it is), and the title screen’s decorative background resolves the same way a real floor does. Three new permanent tests cover the fallback and the actual filtering. cargo test (50 passed), cargo build, and a real launch are all clean.
Step 4 done: the Sewer set’s regular enemies and Skeleton King are in
Placed all six Sewer zips (Rat, Green Slime, Red Slime, Kobold, Skeleton, Skeleton King) onto the three enemy sheets - tools/place_sewer_enemies.py, rows 9-14, growing each sheet from 9 to 15 rows first. A real pixel-health pass over every source frame ran before compositing anything: some real near-black content (bone, fur, armor shading - floored as usual) and a lot of flagged interior holes, every one of which turned out to be real negative space when I actually looked, the same conclusion every batch on this art style has landed on. Kobold’s Walk folder had the usual “named after its own prompt” quirk. template.ron got six new entries, all tagged theme: Some(Sewer) with placeholder stats pitched at the same rough tier as the matching Forest floor. Skeleton King is a real, playable boss on floor 2 right now
- a plain single-enemy fight, so it didn’t need to wait on the target-choice/splitting work the Rat Pack and Large Red Slime bosses still do. Three tests updated/added (one existing fallback test needed fixing
-
Sewer now genuinely resolves to itself instead of falling back, which is the whole point).
cargo test(51 passed),cargo build, and a launch are all clean.
A real detour: the Goblin’s Attack sprite, three attempts to actually get right
Testing the enemy_idle.png repack turned up a real, pre-existing bug from a recording: Goblin’s enemy_attack.png row was composited noticeably bigger than its own Battle-idle row (Goblin Chieftain had a smaller version of the same thing) - confirmed by overlaying the two silhouettes directly, not just eyeballing it. Took three real rounds to actually fix:
-
First attempt: rescaled the existing frames down with
Image.LANCZOS, blanking each cell first… except I didn’t blank the cell BEFORE checking my own logic - I pasted using the new frame’s own alpha as the mask, so wherever the smaller sprite was transparent the old, bigger sprite stayed visible underneath it. A literal double image, caught immediately from a screenshot. Restored the true original from git (not the now-buggy working file) and redid the paste with a real blank-first step. -
Second attempt: the rescale itself was the wrong idea -
LANCZOSis a smoothing filter, and it blurred the crisp pixel art (“you lost the definition when you downscaled,” fair). Got a freshGoblin.zipand placed its Attack frames at native size instead, no rescale at all. -
Still wrong - reported back as “too big” again. This time I actually overlaid the fresh zip’s frames against idle BEFORE trusting them, and the overlay showed the new art genuinely was still oversized (a real halo around the whole body, not just the weapon swinging wide) - I’d generated that same overlay evidence a step earlier and talked myself out of acting on it, which was the real mistake, not the rescale idea itself. Got a fresh
Golbin_Chieftain.ziptoo and checked THAT one the same way first - it already matched, so it went in natively. Goblin’s did not, so it got rescaled again, but withImage.NEARESTthis time (crisp/blocky, not a smoothing blur) - confirmed by a direct crispness comparison before using it. Both cells blanked first, every other row asserted unchanged.
Lesson worth keeping: when there’s a way to actually check something (an overlay, a measurement), check it before reporting a fix is done, rather than trusting either my own first guess or the freshness of new art. cargo test/cargo build/a launch all clean after every round; not yet confirmed on a real screen for the final version.
Step 5: choosing which enemy to hit
Talked through the design first, since this touches every class and battle screen: how the choice should feel (a cursor moves onto the enemy formation itself, Left/Right cycles, Enter confirms, Escape cancels), what the bots should target (lowest current HP - focus a fight down instead of spreading damage), where the cursor starts (always the first alive enemy), and whether an enemy can still interrupt me while I’m choosing a target the same way it already could while I was choosing the action (yes - no new safe pause).
The mechanism turned out smaller than it looked once I actually read the code: apply_player_technique already took an explicit target entity, Battle::primary_target (fastest-alive) just always fed it one. So the real change was a new BattleTurn::ChoosingTarget(action, cursor) state between picking an action and resolving it - only entered when 2+ enemies are alive and the action actually needs one (Attack, Stab, a single-target Technique; Defend/Flee, a self-buff, and an AOE technique skip it, since they don’t aim at one specific enemy). resolve_player_action now takes the target explicitly, falling back to primary_target for every caller that doesn’t go through the new step - Stab, an AOE technique, and the one deliberate exception, the True ATB “queue an action while an enemy’s result is still playing” path, which I left on the old behavior rather than threading target selection through that whole separate mechanism too.
The existing “> Name” highlight under an enemy’s HP (already there for primary_target) just needed to follow the ChoosingTarget cursor instead - one line changed, not a new render path. A small box prompt replaces the Actions box while choosing. The bots (dungeon and Arena both, since the Arena bot reuses the same fight-resolving function) now compute a target too - lowest current HP among the living enemies.
Wrote real tests rather than trusting the wiring by eye: that resolve_player_action‘s target actually decides who gets hit (not silently falling back to the fastest enemy), that None still falls back correctly, that the cursor can’t go out of range, and that every technique effect opens (or doesn’t open) the target step the way it’s supposed to. cargo test (55 passed), cargo build, and a launch are all clean. Not yet seen rendered - the exact box layout and highlight color are a first guess pending a screenshot, same as any pixel-level UI change on this project.
Two quick corrections: the Actions box, and Debug’s “Battle 4” cheat
Real feedback on the target-choice step: showing a separate “choose a target” box was pointless - that’s just what an RPG battle system does, no need to announce it. Dropped draw_target_prompt entirely and left the ordinary Actions box up the whole time; the arrow-key exclusion I’d already added for ChoosingTarget meant nothing else needed to change - Left/Right still drives only the target cursor, the box just keeps showing what it always shows.
Also asked for the Debug class’s “Battle 4” cheat (always 4 Goblins, added ages ago to test the multi-enemy screen before there was anything else to test with) to spawn one of each enemy of the current theme instead - genuinely useful now that there’s a second roster to look at. New spawner::enemy_names_for_theme, with the same Forest fallback a real floor’s spawner already uses for an unfinished theme. Caught by the exact regression class CLAUDE.md warns about: adding a new #[resource] read to use_items compiled fine but panicked at runtime the moment its own permanent access test actually ran the schedule, missing that resource - fixed by inserting a real theme into that test’s Resources, exactly the kind of thing a clean build alone would never have caught. Two new tests for the theme-aware roster itself. cargo test (57 passed), cargo build, and a launch all clean.
The Debug class’s “Current Boss” cheat
The screenshot of the Sewer fight confirmed the target step (the “> Rat” highlight) and the Battle 4 theme fix both actually work. Then I was asked about a piece I’d flagged as still open but hadn’t built yet: a Debug item to fight the CURRENT floor’s own boss - distinct from the existing “Final Boss” item, which only ever fights the demon.
Split spawn_boss‘s own weighted-pick logic (which boss template a real floor’s level+theme actually rolls) out into a small shared boss_pool helper, then added spawn_boss_via_commands alongside it - the same “only a SubWorld + CommandBuffer available” shape spawn_named_enemy_via_commands already uses for Battle 4. The new “Current Boss” item reads the player’s own map_level and the live theme resource, resolves the real boss for that pair (falling back to a real Forest boss if the current theme has none yet for that level, same rule a real floor’s spawner follows), and starts a real Battle against it - Skeleton King on Sewer’s floor 2, right now. Granted in the Debug starting kit next to “Final Boss”. Two new tests: one confirms the right boss for a real (level, theme) pair, one confirms the Forest fallback. cargo test (59 passed), cargo build, and a launch all clean.
Step 6, first half: the Rat Pack
The mechanism turned out to already exist. Player_input.rs already gathers every enemy stacked on a tile into one Battle the moment you walk onto it - that’s how Debug’s own “Battle 4” cheat works, and how any two enemies that happen to converge onto the same square already fight you together. So a “pack boss” only needed a way to say “spawn several of this ordinary enemy stacked on the boss tile” instead of one boss entity - new Template fields, pack_of/pack_size. “Rat Pack” itself is never actually spawned; picking it as floor 0’s boss spawns 4 ordinary Rats, all tagged Boss, all standing on the same point. No new battle-trigger code needed at all.
Reading the reward code closely before touching it turned up a real bug that would have shipped quietly: guaranteed boss loot was granted per Boss-tagged KILL, not once per FIGHT. With 4 Boss-tagged rats, that’s 4 guaranteed drops from one “boss” instead of one - directly against “the Rat Pack’s four rats count as one boss fight,” which was already the plan before I even started building this. Fixed by tying the guarantee to whether a kill actually ends the fight (battle.enemies empties out), not to the Boss tag alone - a single ordinary boss is unaffected (its one kill was always also the fight-ending one), a pack now pays out exactly once, on whichever rat happens to die last.
Wrote real tests rather than trusting either half of this by eye: the pack spawns 4 stacked Rats (not one “Rat Pack” entity), the Debug “Current Boss” cheat’s roster reflects the same thing, a non-final Boss kill rolls for loot like an ordinary kill (40 trials, at least one comes back empty), and the fight-ending kill guarantees it every time (10 trials, no misses). cargo test (63 passed), cargo build, and a launch all clean. Large Red Slime is next - a genuinely different mechanic (it splits on death instead of starting as several enemies), not just a reuse of this one.
Step 6, second half: the Large Red Slime
A genuinely different mechanic from the Rat Pack, not a reuse of it - this one is a single body that splits into more enemies on death, rather than starting as several. Two new Template fields: splits_into/split_count (a dying enemy spawns that many copies of another named template into the same fight, instead of just vanishing), and draw_scale with a new DrawScale component to actually draw it bigger or smaller. The final boss already had its own hardcoded “draw bigger” scale constant - I just made that data-driven and reused it in arena.rs‘s ordinary enemy-render loop, so any enemy can carry its own size now, not just him. Large Red Slime (1.5x, boosted placeholder stats) splits into 2 Red Slime, each of which splits again into 2 Small Red Slime (0.6x, no further split) - all three reuse Red Slime’s own art rows, no new art needed at all.
The reward fix from the Rat Pack generalized cleanly once I actually looked at it: a splitting kill grants nothing and never ends the fight, no matter how many bodies it briefly leaves standing (a naive “battle.enemies is empty” check would have ended the fight the instant the first body split into two, which is exactly backwards) - only the real final kill, whichever tier that turns out to be, can end the fight or pay out.
One real decision I made and want confirmed: splits_into had to go on the ONE canonical “Red Slime” template, not a separate boss-only copy - a second template with the same name would be a landmine for every other place that finds an enemy by name (pack_of, spawn_named_enemy_via_commands). That means an ordinary ambient Red Slime encountered anywhere on floor 1, not just the boss fight, now also splits into 2 Small Red Slimes when killed. I think that’s a fine consequence (slimes splitting is a normal monster trope, and it’s placeholder-stats territory being tuned in the balance re-pass anyway), but it’s a real behavior change worth saying out loud rather than burying.
Wrote real tests for the actual mechanic rather than trusting it by eye: a single splitting kill spawns the right children and never ends the fight, the full 3-tier chain only pays out on the very last Small Red Slime, the Debug cheat’s roster is the single Large Red Slime body (not the split, which only happens once it’s actually fought), and DrawScale actually reaches the ECS only for the two templates that ask for it. cargo test (67 passed), cargo build, and a launch all clean. This closes out the Sewer set’s boss content - Rat Pack, Large Red Slime, Skeleton King are all real, playable fights now. Not yet seen rendered - the draw-scale visuals especially need a real screenshot before I’d call this settled.
The Large Red Slime, corrected: only the boss’s own slimes split
Sent screenshots confirming the whole chain looked and played right - Rat Pack’s four rats with the target cursor working, Large Red Slime visibly bigger, splitting down through Red Slime to a visibly smaller Small Red Slime, target choice holding up across the whole multi-stage fight. But I’d flagged one real consequence for confirmation: an ordinary ambient Red Slime, met anywhere on floor 1, also split now, since I’d put the split behavior on the one shared “Red Slime” template. Told plainly: no, only the boss fight’s own slimes should ever split.
That meant a real rebuild, not a config flip - the mid-tier slime a Large Red Slime spawns and an everyday ambient Red Slime are the exact same template, name, stats and art, so “does this one split” can’t live on the template at all; it has to be a property of this one specific creature. Moved it to a real per-entity marker (components::SplitsInto) instead of a template field: the ordinary “Red Slime” entry went back to carrying nothing, and Large Red Slime now manually attaches the marker to the two slimes it spawns at the moment they’re created, using a new pair of fields (child_splits_into/child_split_count) that only IT sets. An ambient Red Slime never gets the marker and never splits; the boss’s own copies do.
Writing the “does an ordinary Red Slime split” test caught a second real bug along the way, on top of the one being fixed: none of a splitting boss’s own spawned descendants had ever been tagged Boss, only the original body was - meaning the fight’s actual final kill (always some Small Red Slime, several splits removed from the real boss) was never recognized as ending a boss fight, so the guaranteed loot never really fired for the fight that mattered. A flaky test run surfaced it rather than a screenshot this time. Fixed by carrying the Boss tag down through every split, same as the Rat Pack already does for its four rats. Eight tests total now, including the two that mattered most: the full chain still pays out exactly once on its true last kill, and a real ambient Red Slime, spawned the ordinary way, never splits at all. cargo test (68 passed, checked clean across several runs after the flake), cargo build, and a launch are all clean. This closes out the Sewer set for real - all three bosses working, confirmed on screen.
Step 7: a dungeon run keeps one theme all the way through
Asked to confirm something that turned out to be a real bug: every floor of a run was rolling its own independent random theme, so one Crawl or Blades run could genuinely show Forest, then Sewer, then Swamp across its three floors. The fix was small once I found where it happened - MapBuilder::new rolls its own random theme internally whenever it isn’t told one, and the game was calling it that way on every single floor, never actually reusing the previous floor’s pick. Now the first floor of a run rolls once and writes the result straight back into the resource the rest of the run reads, so every later floor sees that same concrete theme instead of “still random” and never rerolls. A fresh run still gets its own independent roll, since the whole resource set gets rebuilt from scratch each time one starts. Debug’s Theme Select was never affected - it was already a fixed, run-long choice. One new test, run 20 times since the roll itself is random, confirming all three floors of a run share the same theme. cargo test (69 passed), cargo build, and a launch all clean.
Step 8: the Swamp set
Second themed enemy roster, following the same process as Sewer: unzip, a real pixel-health pass over every source frame before touching anything (nothing but real negative space this time either - coiled snake gaps, leg/tail gaps), then place all seven onto the three enemy sheets. Only real quirk was Lizardfolk’s Walk folder being named after its own generation prompt again, and Massive Alligator’s own Walk coming back with exactly 6 frames already (this sheet’s own column count) instead of the usual 9.
The floor layout took a real back-and-forth to nail down, since it doesn’t split evenly across 3 floors the way Sewer’s did - floor 0 gets Snake and Alligator, guarded by Massive Alligator; floor 1 adds Bullywug (Alligator and Snake still around), guarded by Hulking Lizardfolk; floor 2 swaps Snake for Lizardfolk, guarded by Bullywug King. Alligator ends up on every floor, the same role Orc plays in Forest - one stat block used everywhere it appears, not tuned per floor. Unlike Sewer, none of the three bosses need the pack/split machinery - each one has its own dedicated, already-generated art, so they’re plain single-enemy fights, closer to Skeleton King than to the Rat Pack or Large Red Slime.
Also fixed something the Swamp work surfaced by asking about it directly: dungeon themes were rerolling per floor instead of staying fixed for a whole run - covered in its own entry above, since it’s really its own fix, not Swamp content.
Two new tests: the exact roster and boss for each floor (worth checking precisely here, since several enemies span more than one floor and a wrong overlap would be easy to miss), and the theme-fallback test updated now that Swamp resolves to itself everywhere instead of falling back. cargo test (70 passed), cargo build, and a launch all clean. Not yet seen rendered.
Step 9: the whole Mountain theme
Once Swamp was done I said I had everything for Mountain, all at once - the tileset, both battle backgrounds, two Victory scenes, a Defeat scene, and seven enemy zips. This one’s different from Sewer and Swamp in one real way: it’s not just a new enemy set on top of an existing theme, it’s a genuinely new fifth theme, the first one added since the original four. Everything about a theme had to get built at once - the tile art, the battle art, the end scenes, and the enemies.
Two real questions before any of it could get wired up. First, I’d delivered two battle backgrounds, an Outside version and an Inside version - asked which one to use, and picked Outside only (the Inside one stays on disk, unused for now, not thrown away). Second, the floor layout for four regular enemies and three bosses across three floors doesn’t split evenly any more than Swamp’s did - went with the suggested layout: floor 0 is just Goat, guarded by Massive Goat; floor 1 adds Yeti and Griffon, guarded by Yeti King; floor 2 swaps Goat for Manticore, guarded by Manticore Alpha. Griffon roams both floors 1 and 2 alongside whatever else is there.
Placed the tileset first - 16 cells, cropped from real transparent divider lines (columns and rows 32, 65, 98, found by actually scanning the alpha channel rather than assuming the same layout Swamp’s sheet used) - then the four battle-background images, resized to fill the cell height and center-cropped to width since the delivered art didn’t match the game’s exact aspect ratio. Both placements verified by diffing every untouched cell was still byte-identical, then just looking at the results - all clean, no seams.
Wrote MountainTheme next so the code would compile again (EndSceneTheme::Mountain and ThemeChoice::Mountain already existed at this point but nothing implemented them yet) - cool grey floor and wall colors, the same Patch/Scatter split as Swamp’s own themed floor cells (snow and ice spread like ground cover, the cairn and the fallen branch are discrete point fixtures). Left the fourth row - the mountain stream, the snow-capped boulder, the dead pine trunk, the ice crystals - unwired for now, same as Swamp’s own fourth row: the art is sitting on the sheet, nothing asked for it to actually place on the map yet.
Then the enemies: unzipped all seven, ran a real pixel-health check over every frame before touching anything, same as always - real near-black content and a handful of flagged interior holes, every one of them just leg gaps, horn gaps, a curled tail, nothing actually broken. Placed all seven onto the three enemy sheets. One real defect this time, not just a spot-check: Griffon’s and Manticore Alpha’s own Attack animations both came back one frame short, 7 instead of the usual 8. Left as-is, that would have shown as a blank flash near the end of the attack, since the game always plays a fixed 8-frame sequence regardless of how many real frames a row actually has. Fixed it by having the placement script hold the last real frame to fill the gap instead of leaving it empty.
Last piece was the two Victory scenes and the Defeat scene. Wiring those in was routine, but the Defeat scene needed the same kind of check Swamp’s did - sampled the real composited image at the default centered spot the corpse would draw at, and it landed right on the dead tree’s own roots. Found a clear spot just below the tree instead, still reading as centered under it.
Wrote two new tests - the exact roster and boss for every floor, and the theme-fallback test updated now that Mountain also resolves to itself everywhere. cargo test (71 passed), a plain cargo build, and a launch check all clean. This is the fifth theme now, and the third full themed enemy roster - only Dungeon still doesn’t have its own set. Not yet seen rendered - floor/wall colors and the Defeat corpse position are my own placeholder calls, worth a real screenshot before trusting them.
Step 10: two real bugs from the first Mountain screenshots
Sent over a batch of real screenshots and caught two things wrong, both worth it since neither was something I could have caught without actually seeing it rendered.
First: my “You have won!” screen showed a giant robot standing square in the middle of a narrow mountain canyon - completely wrong pose for that picture. Turned out my own two Victory source files got renamed backwards from their real content back when I first set them up: the file I called “victory_a” (the one I meant to be the summit) is actually the canyon/pass picture, and “victory_b” is actually the summit. So my code was pairing the canyon scene with the face-camera pose meant for a hero standing still on a summit, and the actual summit scene was getting the walk-away pose meant for someone receding down the pass. Fixed by swapping which picture each pose points at - no art needed to move, just the two numbers in the code that were backwards.
Second, closely related: that same canyon picture had a real solid black band baked into the bottom of the source file, about 90 pixels of it, that I never actually looked for closely enough the first time around - it survived straight through my crop/resize and showed up as a dead grey bar across the bottom of the scene. Fixed by cropping that dead band off the source before scaling it into the cell, so the real scenery now fills the whole frame.
Also moved my Defeat scene’s fallen character - it was sitting behind my own “Press Enter” text box at the bottom of the screen, which meant I’d gotten the math wrong turning a coarse grid position into an actual screen pixel, not just picked a bad spot. Worked out the real conversion this time (each row on that grid is about 160 pixels down a 800-pixel-tall screen, and my prompt box starts around 681px) and picked a shallower spot that’s still well clear of both the tree’s roots and the text box.
cargo test (71 passed), cargo build, and a launch check all still clean after the fixes. Sent this round without having re-seen it myself - waiting on another real screenshot before I’d call the Mountain theme actually done.
Step 11: fine-tuning the two positions from a second look
Sent a fresh round of screenshots. The letterbox on the pass scene was gone and the pose swap was right
- the summit scene got a plain “looks great,” no notes. Two more small things, though, both just positioning, not new bugs like last time.
The hero walking away down the pass was floating above the path instead of standing on it, up near the mountain gap. Moved him south and west - further down toward the near, wide part of the path (closer to the viewer, where the ground he’s meant to be walking makes more sense) and a bit left. That scene had been sharing one fixed spot with three other themes’ own corridor scenes, all of which are already confirmed working, so I gave Mountain its own separate position instead of touching the shared one and risking the other three.
The Defeat corpse needed to move further north and west again, past where I’d put it last time. Found a clear patch of ground in the misty gap between the tree and the cliff on the left, well clear of both the roots and the text box at the bottom.
cargo test (71 passed), cargo build, and a launch check all clean. Both of these are still just my own placement estimates without a render to check them against - another screenshot will tell me if they actually landed right this time.
Step 12: fixed the road position, and the Dungeon set’s own enemies
One more screenshot on the pass scene: the character wasn’t floating any more, but he was standing off to the side of the road next to the old wayside cross instead of on the path itself. Checked the actual picture at the exact height I’d placed him and the road really is dead center there, not off to one side like I’d assumed from the wider view - so I only had to move him back onto center, keeping the southward move from last time.
Then the real next piece of the themed-enemy work: my own Dungeon theme finally gets its own guards instead of borrowing Forest’s Goblins and Orcs. Five zips this time - Guard Woman, Spear Guard, Crossbow Guard, Bow Guard, and Guard Commander - clean folder names, no quirks, though the pixel-health pass came back with the highest near-black percentage of any batch yet, over half the opaque pixels on some of them - makes sense, they’re heavily armored. Every flagged interior hole checked out as real negative space, bow limbs and cloak folds, nothing corrupted.
The floor layout doesn’t look like anything I’ve built before: floor 0 gets Guard Woman and Spear Guard with no boss at all, floor 1 adds Crossbow Guard and a real Guard Commander boss fight, and floor 2 adds Bow Guard and is guarded by an ambush instead of a normal boss. That “no boss on floor 0” and “the boss is a scripted event, not a monster” broke a batch of my own tests that had been using Dungeon as their stand-in example for “a theme with nothing built yet” - which made sense right up until today, since Dungeon really did have nothing of its own until now. Rewrote each of those to point at a level that’s actually still bossless (floor 0) instead of one that now has real data, and added Dungeon’s own version of the per-floor roster test the last three themes already have.
The ambush itself isn’t built yet. Looked into it before touching anything and found a real architecture problem: my battle screen tops out at 4 enemies on screen, and “2 Spear Guards, then 2 Bowmen, then 2 Crossbowmen, all in the same fight” wants 6. That’s not something to guess my way through
- needs an actual decision on whether this becomes three waves of 2 inside one continuous fight or a real redesign to show 6 at once, plus how the ambush even triggers in the first place, since every boss so far has fired by walking onto its own tile, not by getting close to one.
cargo test (72 passed), cargo build, and a launch check all clean.
Step 13: building the Ambush, and item 1 is finally done
Came back to the Ambush with two quick decisions made: three waves of two guards inside one continuous fight, not six on screen at once, and the ambush only fires on the ring around the exit, not the exit tile itself. The wave part turned out to already have almost everything it needed - a splitting boss’s children already know how to join a fight that’s still in progress, so I just gave an ordinary Battle the same trick under a plain name-list instead of a template’s own split fields, and the fight never needed to grow past the two enemies it’s always shown at once.
The real surprise came from actually wiring the trigger in. My floor 2 has no boss at all for this theme now, which I already knew, but I hadn’t worked through what that meant for the win check itself
- with nothing tagged Boss, the game would already think every boss was dead the instant the floor loaded, so walking straight up to the Blade and standing on it would just win, ambush or not. Fixed by giving the Blade its own marker for “this floor’s guard hasn’t been dealt with yet,” checked right alongside the boss-alive check, and only cleared once the ambush’s last wave is actually cleared - not the moment the dialogue fires, since fleeing the fight should leave it exactly as dangerous as it was.
Two more things I only found because I went looking rather than because something broke first: my own balance-sim bot has a catch-all for any state it doesn’t recognize, which would have left it stuck forever the first time a themed run reached the ambush instead of crashing where I’d notice - gave it the same one-line dismiss the chest overlay already gets. And two of my own older tests were quietly counting on every floor having a boss, which used to be true for every theme and now genuinely isn’t for this one - pinned both to a theme that still works the old way rather than leaving them to fail one time in five.
cargo test (76 passed, several repeated runs to be sure nothing was left flaky), cargo build, and a launch check all clean. That closes out the whole themed-enemy-set item - moved the whole thing to docs/done.md and renumbered what’s left.
Step 14: the town cat finally wanders
Small item, but a genuinely new kind of thing for this game - everything else that moves does it on the turn system, and this cat needed to move while I stood still. Raised how often it shows up at all from a bare 2% to 65%, and split that from whether it’s actually the quest cat - now most of the time it’s just an ordinary cat with nothing to say, only about one visit in fifteen is the real one.
Gave it a real per-frame timer instead of a fixed spot, ticked the same place the townsfolk already turn to face me every frame regardless of whose turn it is, so it can genuinely wander while I’m just standing around a shop. It steps one real tile at a time toward wherever it’s currently headed, re-rolling a new spot once it gets there, and re-rolling how long it waits before its next step too, so it doesn’t move like a metronome. Get within a couple tiles and it just stops instead of wandering past me - it has no animation for actually looking at me, only sitting/licking/yawning and no walk cycle at all, so holding still in whatever pose it’s already in is the closest I could get without new art.
Bumping that presence chance up so much surfaced something I hadn’t expected: starting a fresh game in several of my own tests now had a real chance of quietly spawning its own cat, sitting right alongside whatever I’d deliberately placed for the test itself. Had to strip that out explicitly before asserting on my own cat’s position instead of possibly grabbing the wrong one.
cargo test (79 passed, several repeats), cargo build, and a launch check all clean. Set up on its own branch and merged in once everything checked out.
Step 15: a first pass at the comment cleanup
Started on the always-open refactor item, the comment-cleanup half specifically. Went looking for the exact “Console N” off-by-one I’d noted needed fixing and found it wasn’t quite where I remembered - UI_PANEL_CONSOLE‘s own comment was already right - but the real thing was one block earlier: the Shopkeeper’s own three consoles still had their comment reading “43/44/45” from before something got inserted ahead of them, while the actual numbers had shifted to 44/45/46 and nobody went back to fix the comment that pointed at them. Wrote a quick script to check every “Console N” comment in the file against its real constant instead of trusting my own memory of where the bug was, which is exactly how I found it.
Then went through and cut the clearest “used to be X, now it’s Y” and “user’s call on this date, after a recording showed…” passages in consoles.rs, battle/timing.rs, battle/state.rs and battle/damage.rs - kept the actual rule in each case, dropped the blow-by-blow of how I got there, since that’s already sitting in this journal if anyone ever needs it. Left the one comment explaining a past AccessDenied panic alone on purpose - that one’s a permanent regression test’s own justification for existing, which is a different thing than debugging trivia.
This is a first pass, not the whole job - render_helpers/ and the HUD files haven’t been touched at all yet, and there’s more of the same “user’s call, <date>“ phrasing left in battle/‘s other files. Verified every file I did touch is comment-only by diffing just the non-comment lines before and after
-
all four came back byte-identical.
cargo test(79 passed),cargo build, and a launch all clean.
Step 16: the second comment-cleanup pass, and a real finding
Went back to finish the rest of the comment cleanup while the first round of theme-by-theme balance bots ran in the background - render_helpers/, the HUD files, and whatever was left in battle/.
The real finding this time was that most of what I expected to cut wasn’t actually there. I went in assuming render_helpers/ and the HUD files were carrying the same kind of bloat consoles.rs had, since they’re just as comment-heavy - but reading through them, almost all of it turned out to be genuinely earned: exact pixel math for lining up a health bar with its own icon, a real confirmed detail about how bracket-terminal’s own shader multiplies a glyph’s color, why a shrunk tile needs its spacing closed up too or gaps open between them. That’s exactly the kind of thing my own project rules already say to keep - it’s not history sitting in the journal already, it’s the reason a future change won’t quietly break rendering again. So I left those two areas alone rather than cutting for the sake of a lower line count.
What I did find and fix was smaller and more specific: a handful of comments in battle/ that compared current behavior to something removed by name - an old Shield technique, an old per-technique lookup function, an old victory-handling contract - without saying anything a reader could actually use today. Cut those down to just the current rule. Left everything referencing this week’s own themed- enemy and ambush work alone, since those explain a mechanism that’s still there, not something gone.
cargo test (79 passed), cargo build, and a launch all clean. Verified comment-only the same way as the first pass. This closes out the comment-cleanup half of the refactor item for real - what’s left under that item now is just the broader “survey the code for what should be restructured” work, which neither pass touched.
Step 17: surveying the code for real refactor candidates
While the first round of theme-by-theme balance bots ran, did the other half of the refactor item - a real survey of what’s actually worth restructuring, not just the comment pass. Went looking specifically for pure moves and deduplication, nothing that would change behavior, since that’s the whole point of this item.
Both of my two known oversized files have kept growing since the last time anyone looked - the bot file past 1,150 lines now, the battle screen past 850 - and both have real, clean seams once you actually look at what’s inside them rather than just the line count: the bot file is genuinely five separate jobs (movement, battle policy, my own sim settings, exploration, and stall logging) that happen to live in one file, and the battle screen already has three sibling files next to it that the rest of it could split into the same way. Found a handful of smaller things too - the same one-shot-animation assembly copied nine times across two files with only the row number changing, a reward-payment function that lives in the town file but is actually called from two other unrelated places, two BFS implementations that are the same algorithm with one extra check.
Checked every line number and function name I logged against the real file before writing any of it down, rather than trusting a first pass at face value - a couple of small offsets from my own earlier SIM_THEME edit, otherwise everything held up. None of this is done yet - just logged in docs/ideas.md, ordered by what’s actually worth doing first, for whenever this item gets picked up for real.
Step 18: every theme’s own balance numbers, one theme at a time
I wanted each theme’s survival numbers on their own before touching the AOE rework, since mixing all five themes into one random batch would hide which one is actually driving a bad number. The first Forest run I started last session never finished - it turned out a background bot dies with the session that launched it - so I restarted it and ran each theme in turn: all six tiers, 15 runs per class per tier, one core at low priority, about an hour per theme.
- Forest: 30%. A cliff from the base tier (64%) straight down to about 23% at 1 Blade, and flat after that. Most losses at every Blade tier happen on floor 1.
- Dungeon: 1% - 3 wins out of 450. Nothing timed out or got stuck; the bots just die, and 404 of the 447 losses are on floor 1. Looking at the numbers, the Dungeon has no small enemy at all: its most common floor-1 guard (Guard Woman, 14 HP / 7 damage) is Orc-sized, where every other theme opens with a Goblin, Rat or Snake-sized one. Every Dungeon guard is about as strong as its Forest counterpart, but the strong ones show up about twice as often.
- Sewer: 52%, the best so far. No drop after the base tier, but the Demon is a wall: 49 of 75 tier-5 runs die on the final floor. Mage falls apart here (12%).
- Swamp: 52% too, but shaped differently - the base tier is the hardest one (41%), with 38 of 75 runs dying on floor 1, and the classes are closer together than anywhere else.
- Mountain: 59%, the best of the five, and the most even from the base tier through 4 Blades (65-73% each). Like the Sewer, the Demon is a wall: 46 of 75 tier-5 runs die on the final floor.
I put all of it on one report page (every theme’s class-by-tier table plus a “where runs end” bar per tier) so I can compare themes side by side instead of scrolling through terminal output.
Step 19: an enemy HP budget per floor
Looking at why the Dungeon was so much worse, I noticed something bigger: every floor spawns about 50 enemies, always, whatever the theme, floor or tier. The map picks 50 spawn tiles, every one of them rolls an enemy (all the potions and items are shop-only or prefab-only, so the pool is 100% enemies), and the weights only decide which enemies, never how many. So a theme with tough enemies just gets 50 tough enemies.
My fix is a budget: each floor gets a total amount of ordinary-enemy HP to spend, and spawning keeps making weighted picks until it runs out. A theme full of big enemies automatically gets fewer of them. Bosses, prefab guards and the Dungeon Ambush stay outside the budget. The budget is written in base HP and scaled by the tier’s own health multiplier, so every tier places the same enemies and the tiers stay harder through the multipliers they already have. It’s a new enemy_hp_budget knob on each tier in tiers.ron, plus a SIM_HP_BUDGET setting so the bots can try values without editing the file. No budget is set anywhere yet, so the game plays exactly as before. A quick test showed a 245 HP budget on a Forest floor placed exactly 245 HP - 23 or 24 enemies instead of 50.
The plan: find the right budget for a “perfect” Forest, starting with floor 1 only, at the Dungeon Crawl tier only, then scale floors 2 and 3 from there, and then use those same raw HP numbers for every other theme. Forest’s floor 1 currently holds about 490 HP of enemies, and loses 14 of 75 runs there (19%). The target is roughly 93% survival per floor, which gets the Crawl tier to about 80% overall. The first sweep - 90%, 80%, 70% and 60% of today’s floor-1 HP - is running on two cores next to the Mountain run, on its own branch (spawn-hp-budget).
Step 20: the spawning tests get their own file
While the bots ran I moved the ~320 lines of per-theme spawning tests out of spawning.rs into their own theme_tests.rs next to it - a plain move, checked by comparing the sorted lines before and after, and spawning.rs is down from 956 to 630 lines. I also fixed the one compiler warning left in the test build (a test assertion message in battle/damage.rs), so the build is warning-free again. Tests, build and a launch all clean; merged to master.
Step 21: splitting the bot file
Next I did the top item from yesterday’s refactor survey: the 1,196-line Dungeon Crawl bot file is now a dungeon_bot/ folder with one file per job - moving around the map, the battle policy, the SIM_* settings, exploring, and logging stalled runs - while mod.rs keeps just the run loop. Everything is re-exported from mod.rs, so none of the other bot files had to change. One small decision along the way: the survey said to put the settings next to runner.rs, but that would have meant editing imports in five other files, so they stayed inside the folder instead. And the one pathfinding helper only step_toward uses moved into the same file as it, so nothing’s visibility had to change either.
I did the split with a script that cut the file along its top-level items rather than retyping anything, then checked it by comparing the sorted lines before and after - the only additions were module declarations and file headers. Tests, build, a launch and a one-run bot smoke test all clean, all done in a separate worktree so the Mountain run and the budget sweep kept going untouched. Merged to master.
Step 22: splitting the battle screen, and the first budget results
Next on the refactor list was the battle screen’s mod.rs, 863 lines. It already had three sibling files for the drawing, so the rest followed the same pattern: the per-frame timers and ATB gauges, the Actions box and its cursor, what each turn state does, and the victory screen each got their own file, and mod.rs is down to just battle_tick at 93 lines. The only thing that isn’t a pure move is that the five methods battle_tick calls had to become pub(super) to be reachable from the parent file - the same thing the drawing files already do. Checked with the sorted-line diff again, then tests, build and a launch. Merged to master.
Meanwhile the first budget sweep came back - Forest, Crawl tier, a budget on floor 1 only:
| Floor-1 budget | Died on floor 1 (of 75) | Won |
|---|---|---|
| none (~490 HP) | 14 | 64% |
| 440 (90%) | 14 | 69% |
| 390 (80%) | 5 | 80% |
| 345 (70%) | 4 | 79% |
| 295 (60%) | 0 | 89% |
So the budget really matters - somewhere between 440 and 390, floor 1 goes from the place most runs end to barely a threat, and at 390 the whole Crawl tier lands right on its 80% target. With floor 1 under control, floor 2 is now where runs end (10-12 deaths in 75). Next: a bigger 200-run check at 390 to make sure it’s real and not luck, then the same kind of sweep for floor 2.
Step 23: the battle target highlight stops jumping
I noticed that in a fight with several enemies, the highlight started on the last enemy, then jumped to the front one as soon as I picked an attack and had to choose a target. Two separate rules were disagreeing: the highlight shows the fastest enemy, and its tie-break was documented as “the earlier one wins” - but Rust’s max_by_key actually returns the last of several equal values, so in a fight full of same-speed enemies it always landed on the back one. Target picking, meanwhile, always opened its cursor on the first enemy. The same wrong tie-break also meant a True ATB queued attack (which skips target picking) went to the back enemy.
I fixed both: a Speed tie now really goes to the front enemy, and target picking opens right where the highlight already is, so nothing jumps. Added a permanent test for the tie-break. Tests, build and a launch all clean; merged to master.
Step 24: the kill quest asks for enemies from the right dungeon
I got a quest to kill 5 Spear Guards in a Forest dungeon, where no Spear Guard ever spawns. The Townsman’s bounty is always the toughest ordinary enemy on the next floor, but the lookup behind it never checked the theme - it took the highest-HP enemy on that floor from every theme’s roster. On floor 1, Spear Guard (Dungeon) and Alligator (Swamp) tie at 16 HP, and the same max_by_key tie-break I fixed for the battle target picked whichever came last in template.ron - the Dungeon guards. There was a second problem under it: a run’s theme wasn’t even chosen until I walked into floor 1, so the starting town, where the first bounty is set, couldn’t have known it anyway.
The fix: a run now rolls its one theme the moment it starts instead of on floor 1 (still locked for the whole run, and Debug’s Theme Select still overrides it), and the bounty lookup only looks at that theme’s own enemies, with the same Forest fallback the spawner uses and ties going to the first one listed. The bots’ SIM_THEME setting now forces the theme the same way Debug does, before the run starts, so their bounties match too. Added a permanent test that checks every theme’s bounty in the starting town and each later town visit. Tests, build and a launch all clean; merged to master.
Step 25: floor 1’s budget holds up
The bigger check on floor 1’s budget came back: 200 Forest runs at the Crawl tier with a 390 HP floor-1 budget won 80% (161 of 200), and only 12 of them died on floor 1 - 94% floor-1 survival, right on the target. So floor 1 is locked at 390. The floor-2 sweep is next, with floor 1 held at 390: 90%, 80%, 70% and 60% of floor 2’s current ~1050 HP.
One thing that stood out: in these 200 runs nobody died on floor 3 at all - 27 died on floor 2 and none after it. Floor 3 is where the Forest’s final boss waits, so that’s a little surprising, and it means if floor 2 also gets to ~93% survival, the Crawl tier will land well above 80% unless floor 3 gets tougher. Mage is also the one class that still dies noticeably on floor 1 (7 of 40).
Step 26: a saved run remembers its theme
The run save never recorded which theme the dungeon was, so continuing a saved run rolled a brand new random theme for the rest of it - a Forest run could come back as a Swamp, and an accepted kill quest could end up naming enemies that don’t spawn anymore. The save now stores the theme, and continuing a run puts it back before anything else happens. An older save without one still loads fine and just gets a fresh random theme, like before. I added a permanent test that runs all five themes through a save and restore (one theme alone could pass by luck one time in five - I checked that it really does fail without the fix), plus the older-save case. Tests, build and a launch all clean; merged to master.
Step 27: why the Mage keeps losing
Mage has been last or near last in almost every theme, and was still the one class dying on floor 1 after the budget, so I traced it. It isn’t the bot - it casts Ice Armor the first moment it can and recasts whenever it has a spare copy. It’s the class’s numbers:
- Mage’s Defense is -5; every other class is 0 or +2. Defense is subtracted from each hit, so a negative one is a flat +5 damage on every hit that lands - a Goblin’s 6 becomes 11, a Rat’s 5 becomes 10. Against lots of small, fast enemies (the Sewer’s Rats) that almost doubles the damage.
- Ice Armor (+10 Defense for 30 hits) is meant to cover that, but the order of the math means it can’t fully: the enemy’s damage has Ice Armor taken off and is floored at 0 first, and the -5 is added after, so even a fully blocked hit still does 5. With the armor up, a Mage takes about what other classes do against weak enemies, and less against big ones; without it, it takes far more.
- The Mage starts with only one Ice Armor, and more only come from battle loot (one of five Mage loot items, at a 40% drop chance - about 1 win in 12). Once the first 30 hits are used up, the Mage spends the rest of the run with the -5 penalty and nothing to offset it.
- It also starts with no attack technique (Fireball is loot-only), so it has no offensive upside to make up for being the most fragile class.
Nothing changed yet - the fix is a class-design decision.
Step 28: floor 2’s budget barely matters - the Ogres do
The floor-2 sweep came back, with floor 1 held at 390 (75 Forest Crawl runs each):
| Floor-2 budget | Died floor 1 | Died floor 2 | Won |
|---|---|---|---|
| 945 (90%) | 1 | 13 | 81% |
| 840 (80%) | 3 | 12 | 80% |
| 735 (70%) | 4 | 9 | 83% |
| 630 (60%) | 7 | 13 | 73% |
Floor-2 deaths stay at 9-13 whatever the budget - just noise. So I looked at what actually killed each run (the bots log the last fight): across every budget run so far, the single biggest killer is a lone Ogre on floor 2 (55 deaths), then Orcs there, and on floor 1 it’s the Goblin Chieftain boss (22). The bots always take the route through the chest room, and the chest is always guarded by the floor’s toughest ordinary enemy - on floor 2, that’s the Ogre. Those guards (and the bosses) sit outside the budget by design, so shrinking the budget doesn’t touch them. And floor 3 really is nearly harmless at the Crawl tier: 2 deaths there in all these runs.
So the budget is the right lever for floor 1, but floor 2’s difficulty comes from the chest’s Ogre guards and the floor boss, not from how many ordinary enemies are around.
Step 29: the defense formula, fixed
Damage is supposed to be the enemy’s attack minus my armor minus any buffs. It mostly was - but the code took the buffs off first and floored the hit at 0 after each one, and only took the base armor off at the very end. For every class with armor of 0 or more that comes out the same. For the Mage, with its -5 armor, it didn’t: Ice Armor would block a Goblin’s 6 all the way down to 0, and then the -5 added 5 back on. Now everything is subtracted together and the hit is floored at 0 only once, at the end, so a Mage with Ice Armor up takes 1 from a Goblin instead of 5, and every other class’s numbers are unchanged. I added a permanent test with the Goblin, Orc and Ogre cases for both a Mage and a Barbarian. Tests, build and a launch all clean; merged to master.
Mountain also finished, the last of the five themes - its numbers are in step 18 above.
Step 30: Forest’s budget on the Dungeon
The real test of the budget: the same raw HP numbers that worked for Forest (390 on floor 1, 945 on floor 2, floor 3 left alone), run on the Dungeon at the Crawl tier - 75 runs, before the defense-formula fix.
| Dungeon, Crawl | Won | Died floor 1 | Died floor 2 | Died floor 3 |
|---|---|---|---|---|
| No budget | 2 | 56 | 3 | 14 |
| Forest’s budget | 6 | 0 | 11 | 58 |
Floor 1 went from the place almost every Dungeon run ended to zero deaths - with 390 HP to spend on Orc-sized guards, it gets about 25 of them instead of 50. So the budget does exactly what it was for. But the runs now get far enough to meet the Ambush, and that’s the new wall: nearly every floor-3 death starts with “Spear Guard + Spear Guard”, which is the Ambush’s first wave - three waves of two guards back to back, near the end of a floor that still has its full 50 enemies. The overall win rate barely moved (3% to 8%), but the problem moved from “the Dungeon’s roster” to “the Ambush”, which is a much more specific thing to fix.
I also merged master into the budget branch so the next runs include today’s fixes.
Step 31: the Ambush loses its third wave
After the Dungeon budget run showed the Ambush was now where almost every Dungeon run ended, I cut it down: two waves instead of three - 2 Spear Guards, then a Bow Guard and a Crossbow Guard together. No more back-to-back pairs of Bow Guards and Crossbow Guards at the end of a long floor. Added a permanent test for the new waves; tests, build and a launch all clean, merged to master.
One thing I keep coming back to: once a class gets its new weapon, it gets a lot more survivable, so the first floor - before that weapon - is the one that matters most. That’s also exactly where the budget made the biggest difference, in both Forest and the Dungeon. Meanwhile Forest’s budget is running on the Sewer, Swamp and Mountain, one at a time.
Step 32: the bots were losing technique damage
While planning arrows for the Bow and Crossbow Guards, I found a real bug in the balance bots. In the game, a multi-hit or projectile technique doesn’t land all at once: its hits are spaced out over the animation - Whirlwind swings three times a fraction of a second apart, an Arrow Volley’s arrows fly and then hit. The bots play battles as pure numbers and never run any frames, so all of that waited forever. A quick test showed it plainly: a bot’s Whirlwind landed 1 hit out of 3, and its Arrow Volley did no damage at all - the arrows never even left the bow. The same went for Flurry, Javelin Volley, Blizzard, Fireball, and Burn/Poison Shot’s wounds.
So every bot run so far - including all of today’s per-theme and budget numbers - has been underrating the classes’ techniques, the Hunter’s and Mage’s most of all. The fix is small: after every action, the bots now “fast-forward” whatever is still in flight until it has all landed, using the exact same per-frame steps the real game runs. Added a permanent test (Whirlwind lands all 3 hits, Arrow Volley lands), tests, build, a launch and a one-run bot smoke test all clean; merged to master. It also means the enemy arrows will just work for the bots once they’re in.
Step 33: the Bow and Crossbow Guards shoot
The Bow Guard and Crossbow Guard now actually shoot. When one attacks, it plays its attack animation, and at the end of it an arrow flies from the guard to me; the attack itself - the dodge roll, armor, the damage, a Counter - only happens when the arrow lands, the same way my own arrows and fireballs already work. The enemy’s result screen waits for the arrow so it can’t be skipped before the hit, and the balance bots land it like any other projectile. For the art I reused Poison Shot’s straight arrow, turned around to fly back toward me. Added a permanent test (both archers hit only on arrival, a Spear Guard still hits instantly); tests, build, a launch and a Dungeon bot smoke run all clean. Merged to master - the one thing left is seeing it for real in a fight.
Seeing the arrows in a real fight, they were too big - a full cell, the same size as my own Poison Shot arrow - and a few of the angles don’t look right. Sizing first: an enemy’s arrow is now drawn at half size (my own projectiles keep theirs). The angles are next.
Step 34: Forest’s budget on every theme
The last three budget runs came back - Forest’s numbers (390 HP on floor 1, 945 on floor 2) on the Sewer, Swamp and Mountain at the Crawl tier, 75 runs each. These still used the old bots (before the technique-damage fix), but so did the no-budget baseline, so the comparison is fair:
| Theme, Crawl | No budget | Forest’s budget |
|---|---|---|
| Forest | 64% | 81% |
| Sewer | 59% | 81% |
| Swamp | 41% | 76% |
| Mountain | 73% | 68% |
| Dungeon | 3% | 8% (then the 3-wave Ambush) |
Leaving the Dungeon aside, the gap between the easiest and hardest theme went from 32 points (41-73%) to 13 (68-81%). That’s exactly what the budget was for: the same raw HP numbers, and the themes line up. Mountain didn’t move, which makes sense - its floor-1 enemy is an 8 HP Goat, so 390 HP still buys nearly the full 50, and its floor 2 was already under 945. The Swamp gained the most: its base tier had been the hardest one, and with the budget, floor-1 deaths fell from 38 to 11.
Step 35: the Mountain gets a second floor-1 enemy
Looking into why the Mountain sat a little below the other themes with the budget, I noticed its floor 1 only ever had one kind of ordinary enemy: the Goat. Every other theme has two there - a small one and an Orc-sized one - and the Mountain was missing the Orc-sized one, which is also why the budget barely touched it (390 HP of 8-HP Goats is still almost the full 50). So the Mountain now has a Yeti Cub on floor 1: 14 HP, 7 damage, speed 6, spawning next to the Goat. It needed no new art - it reuses the Yeti’s own animations drawn at three-quarter size, the same trick the Small Red Slime already uses. Tests, build and a launch clean; merged to master.
With that in, I started the rerun I’d been waiting on: all five themes at the Crawl tier with the budget (390 / 945), on the fixed bots, one theme at a time. It’s also the first real look at the Dungeon’s two-wave Ambush, the new enemy arrows and the Mage’s defense fix. After this, the plan is to settle the no-Blade budget, then work out the first Blade tier’s budget, and the rest after that.
In a real fight the Yeti Cub looked right - clearly smaller than the Yeti next to it - but on the dungeon map it was a full-size Yeti. The map draws a standing enemy on a plain console, which can’t scale a sprite at all; only the “fancy” consoles used while something is moving can. So now anything with a size scale (the Yeti Cub, and the Large and Small Red Slimes, which had the same problem) is always drawn the fancy way on the map, standing still too, at its own size. Tests (including the one that runs the real render schedule), build and a launch clean; merged to master.
Step 36: spending fewer tokens
My usage report showed most of my tokens going to long sessions with big contexts, so I slimmed down CLAUDE.md, which gets sent with every single request. It went from 31 KB to 11 KB: the rules that only matter for one kind of task (journal and blog, Discord changelog and Windows build, sprite sheets, art prompts, rendering gotchas) moved word for word into their own files under docs/claude/, with a short list saying which one to read before which task. I also added a few token rules, the main one being never to read this journal whole - it’s over 1 MB now. Along the way I checked the fixed-bot rerun from step 35: with the budget, the five themes now sit between 83% and 92% at the Crawl tier, the Dungeon included.
Step 37: the Blade tiers, a rounding cliff, and a new budget unit
I ran Forest’s budget (390 / 945 HP) on every Blade tier for every theme - 15 runs per class per tier, on three bot processes. The themes came apart again: at 1-4 Blades the Sewer and the Swamp won 84-97% of their runs, the Mountain 74-89%, Forest 60-73% and the Dungeon only 45-50%. Almost all of the Forest and Dungeon deaths were on floor 1, deep into the floor (fight 25 or so), out of potions and nearly out of health.
Part of that was a rounding cliff. The Orc, the Guard Woman and the new Yeti Cub have 14 HP, and every class hits for 7, so at Crawl they die in two hits. At 1 Blade enemies get +4% health, so 14 rounds up to 15, while the player’s +5% damage rounds 7 back down to 7 - three hits now, and half again as many enemy attacks in every one of those fights. I checked it with a bot scenario that pins those three enemies back at 14 HP at 1 Blade: Forest went from 66% to 88% and the Dungeon from 48% to 85%, and floor-1 deaths fell from 22 and 29 to 1 each. At 3 Blades the same pin did nothing - there it was plain attrition before the floor-1 boss (the Goblin Chieftain, which the Dungeon’s floor 1 also gets through the Forest boss fallback). Cutting Forest’s floor-1 budget to 320 fixed that: 77-85% at every Blade tier, and the Dungeon went up the same way.
That also showed what an HP budget can’t do. It only ever takes enemies away, and the Sewer’s 5-HP Rats never come close to the cap, so it can make a hard theme easier but never an easy one harder - every theme just drifts toward the same 80-90%. I want each tier to be harder than the one before, and I want every class to get the same dungeon, so the budget needed a better unit.
The new unit is chip damage: roughly how much health a player loses beating an enemy. For each enemy it is the hits it takes to kill it, times how many attacks it gets per player action (battle turns come from a gauge that fills in proportion to Speed), times its damage per attack. So health, speed and attack power all count, and a rounding cliff like the one above is priced in. It is measured against a fixed reference player in tiers.ron - 7 damage with the tier’s Blade buff, speed 8, defense 0, plus the weapon you can expect to be carrying on each floor (0 / 7 / 14) - not against whoever is actually playing, so every class faces the same floors. And a floor with a budget now fills to it: it keeps placing enemies (on spare floor tiles once the usual 50 spawn points run out, never inside a prefab room) until the budget is spent, so a theme of weak enemies gets more of them and a theme of strong ones fewer. Items are placed exactly as before. Each tier gets its own budget, and raising it tier by tier is what makes each tier harder.
Starting point: Forest at the old 390 / 945 HP works out to about 355 / 500 / 650 chip, so I’m calibrating the Crawl tier from there, then each Blade tier.
Step 38: the town cat walks
I had new PixelLab art for the cat - Licking, Sitting and Yawning in all four directions, plus a real 6-frame Walk - and the cat had a movement problem anyway: it would wander for a bit and then stop for good. Its old rule was one tile at a time toward a random spot, and when that one step was blocked (a house, the well) it stayed put but kept the same target, so it tried the same blocked step forever.
Now the cat rests for a few seconds - Sitting if it has no quest for me, Licking if it does - then walks a real path to another spot, gliding tile to tile with the walk cycle facing the way it goes, and rests again. The path goes around houses, townsfolk, signposts and me. If it’s the quest cat, it stops and turns to face me whenever I’m within two tiles; an ordinary cat just walks around me. After I scratch it, it sits for the rest of the visit, and it still yawns where I find it in the dungeon. The new frames went onto the townsfolk sheet (tools/place_cat.py), which grew to 110 rows. Tests, build and a launch clean, checked in the game, merged to master.
Step 39: getting ready to ship
I ran my reference player through real one-on-one fights against every ordinary enemy with a new harness (balance_sim/fight_cost.rs), and the chip formula turned out to price a lone enemy about right. What it misses is crowding: four enemies at once cost almost twice as much each as one at a time, because they all hit you while you’re still killing the first. That’s why a floor full of cheap one-hit Goblins was so deadly. The report page with every number from these runs is at https://claude.ai/artifact/2sVjmEUsqwpsTaXSvCBgxG.
For this release every tier uses the Crawl budget (355 / 500 / 650). The chip is priced at each tier’s own enemy stats, so every tier gets about the same threat, and the Blades are what make me stronger. A quick check came back playable everywhere - Forest 84/60/88/92/60% and the Dungeon 80/52/92/84/40% at 1-5 Blades, where the Dungeon used to be almost unwinnable. Merged to master.
The next balance pass is about the Blades feeling like power: each Blade should show a bigger hit and more health, while the enemies hit harder, so each tier gets harder without the numbers ever standing still. Capping how many enemies a floor can hold should take care of the crowding.