~/adam.log

Custom Roguelike - Town and Battle Animations: Shopkeeper Reactions, Real Projectiles, and New Enemies

Published 2026-09-24

Town and Battle Animations: Shopkeeper Reactions, Real Projectiles, and New Enemies

Bots: S6-S9, closing out the tier curve and hitting a real gold cliff

S6 (overnight, 250 runs/cell, 18 cores): finished cleanly but the session boundary dropped the live notification - recovered the full result from balance_tests/results.jsonl (the dungeon report always logs there; only the Arena report doesn’t, so that half was lost). Numbers came back badly overshot on 1-2 Blades (89%/91% - essentially as easy as Crawl) from stacking a health AND a gold change in the same round; 3-5 Blades landed close to target for the first time (53%/54%/42% vs 55/45/40).

S7 (80% runs/cell): corrected 1-2 Blades by moving gold and health both toward the harder side - overcorrected the other way (49%/50%, under target and under even S5). 3-5 Blades held steady, confirming that part of the curve is genuinely settled.

S8: tried a careful interpolation between S5’s hard values and S6’s easy ones (gold 1.38/1.47) - landed right back at 88%/87%, barely different from S6’s overshoot.

S9 (deliberately smaller - 60 runs/cell, 6 cores, after repeated connection drops on the long overnight-sized runs): nudged gold down just slightly further (1.33/1.42) and still got 89%/86%. Together with S5 (1.30/1.40 → 53%/46%) and S7 (1.20/1.32 → 49%/50%), this nails down a genuine cliff: everything at gold 1.30 or below is “hard” (~50%), everything at 1.33 or above is “easy” (~87-89%), with nothing in between - almost certainly one specific shop purchase becoming affordable right at that price point, not a smooth economic curve. I noticed separately that every tier is the same 3-floor run with a shop between floors and nothing after the last floor, so gold picked up on a tier’s final floor is never spendable within that run - doesn’t change the relative tuning between tiers (uniform effect) but explains why gold is such an outsized lever overall (it compounds through two real shop visits, not three).

Settled: kept 1-2 Blades on the harder side of the cliff (S5’s values, 53%/46%) rather than chasing the exact 70%/60% target further - still comfortably above the “a real player should clear even a 40%-bot-win-rate tier” floor, and chasing it further meant more long runs for no real gain. 3-5 Blades (53%/53%/42%) are close to target and considered done.

Process: background runs kept dying at session boundaries

Lost a full Arena run (S7) and had a near-miss on S9 to the same failure mode: a long-running background task can get silently orphaned/stopped when the Claude Code session itself restarts or drops connection, with no completion notification and (for the Arena report specifically) no durable record, since only the dungeon report logs to results.jsonl. Mitigations this session: route long-run output to target/balance_runs/ (survives a session restart, unlike files that turned out to live somewhere more ephemeral) instead of the scratch directory; split the dungeon and Arena reports into separate launches instead of one chained script, so the dungeon half (which matters most and persists to the ledger) always lands even if Arena gets cut off; and, after repeated drops, dropped run size way down (60 runs/cell, 6 cores instead of 250/18) - a couple of minutes end-to-end is far less likely to span a disconnect than 15-20.

Locked in and shipped

Final numbers baked into the live data files (not just the simulation’s test.ron overrides): resources/classes.ron (Barbarian speed 8, Rogue speed 8, Mage health 98/defense -5, Amazon defense 2), resources/template.ron (Goblin base damage 6), resources/tiers.ron (the settled 1-5 Blade curve, with 5 Blades now its own entry). cargo build, cargo test and a debug launch all clean. Committed to master for today’s push, alongside doc catch-up for the Achievements work (built 2026-09-21, 16:26-17:37, but never got its own journal write-up or changelog entry until now) - see CHANGELOG.md (both under their real build dates: Achievements filed under 2026-09-21, the balance retune under 2026-09-22) and docs/archive/release_notes_2026-09-21-evening.md / release_notes_2026-09-22.md for the player-facing writeups.

Windows release packaging: automated the build/copy/zip, and flattened the zip

Set up the local Windows release step end to end (not a git-push capability - a separate local
build+package step): cargo clean --release --target x86_64-pc-windows-gnu then cargo build the
same target, copy the exe to /mnt/c/Users/RGBeast/Documents/My Games/five_blades_deep/ five_blades_deep.exe, rebuild five_blades_deep.zip next to it. Ran it for real this session -
clean build, valid PE32+ exe, confirmed with file. The old zip predated resource embedding and
still had a nested five_blades_deep/ folder (exe + a separate resources/ folder) inside
five_blades_deep.zip; since resources are embedded now, that’s pure redundancy. Caught the
leftover nesting (“do we need the layered 2 five_blades_deep folders/files”) - the zip is now
flat, just the exe at its root (zip -j), after working through what actually still needs to
travel with it (nothing else). Left the loose exe’s own folder in My Games (one level) as it was

  • that wasn’t what needed fixing. Full detail in CLAUDE.md‘s “Windows release build” section.

Workflow reset: journal headers, one Discord changelog file, first-person tone

Before the packaging work above, defined what “get ready for a push/build/new changelogs” should
mean going forward - a real process change, not a one-off:

  • journal.md headers no longer close on a calendar-day boundary. Every session’s ##/###
    work keeps landing under whatever # header is open, however many days that takes, until I
    explicitly say to finish the section - at which point it gets renamed from a bare date
    placeholder to a pure theme title (no date at all), and that’s the trigger for a blog post + a
    fresh tag set for that section. This header (# 9/22/26) is itself one such open placeholder -
    it stays a bare date until closed.
  • Discord changelogs moved to one running file, docs/discord_changelog.md, instead of a new
    docs/archive/release_notes_<date>.md per push - that was about to become a lot of files. Scope
    for a new entry is everything since the newest confirmed section - real mistake caught the
    same day: two drafted-but-never-actually-posted entries were sitting in the file as if they’d
    gone out; pasted back the real text of my actual last Discord post, which didn’t match, and the
    two drafts got merged into the one real combined post that followed.
  • Discord posts are now first person, as the developer (“I added”, “I fixed”), not
    third-person/“you’ll notice” distance.
  • Blog posts (a journal section closing), Discord changelogs, and my own git push/build are three
    independent triggers now, not implicitly linked the way “one journal header = one day = one
    push” used to imply.
  • The journal itself is written first person, from my own point of view, not as a record of
    someone else’s decisions
    - no more “the user”/“Claude” as separate actors, just I/my throughout,
    same as it’ll read on the blog.
    Full rules written into CLAUDE.md‘s “Journal” and “Discord changelog” sections and into memory,
    so this survives a session restart.

Final dungeon: the Rooms architect, reserved for the 5 Blades dungeon’s last floor only

New branch final-dungeon-rooms-builder. My idea: the room-based map builder fits the final demon
floor thematically better than a random Drunkard’s-Walk/Cellular-Automata pick might hand it, and
it already has the lowest enemy density of any architect (RoomsArchitect in rooms.rs spawns
exactly one enemy per room, monster_spawns.push(room.center()) per room past the first) - fewer,
more spread-out fights suits a boss approach better than a packed cave or winding tunnel would.

Scope, nailed down after “flee to another room” got over-read as a new spatial chase mechanic and
questioned back to me - corrected that directly: no chase, no relocation logic at all. The
in-battle flee (Stab -> heal -> phase advance, scripted in final_boss.ron/battle/boss.rs) is
completely unchanged. The only real change is which architect builds the floor.

Implementation: MapBuilder::new gained a force_rooms: bool parameter (both real call sites
updated - run_flow::advance_level, screens/title::spawn_title_background passes false) that
skips the random rng.range pick entirely in favor of RoomsArchitect when true.
run_flow::advance_level needed a small reorder: the floor-number increment (player.map_level)
used to happen after MapBuilder::new (since the player’s start position needs the finished map),
but force_rooms needs to know the floor number before building it - split into two steps
(increment first, then build the map, then position the player) rather than duplicating the “is
this the final floor” check with stale timing.

Also removed RoomsArchitect from the random per-floor pool entirely (a follow-up, mid-edit) -
it used to be a real ~1-in-3 pick on every ordinary floor; now rng.range(0, 2) covers only
Drunkard’s Walk and Cellular Automata, and Rooms is reachable exclusively through force_rooms.

Two permanent tests in map_builder::force_rooms_tests (20 and 100 seeded trials respectively,
since a single roll wouldn’t catch a regression to the old random behavior): force_rooms: true
always produces a non-empty rooms list (the one field only RoomsArchitect ever populates -
confirmed neither other architect touches it), and force_rooms: false never does. cargo build,
cargo test (39 passed) and a debug launch all clean.

Not yet done: this hasn’t been through the balance sim. Rooms’ lower enemy density could shift
the 5 Blades tier’s just-settled win rate (42% dungeon average, see the S1-S9 sweep above) - worth
a bot run before or shortly after this merges, not folded into this same commit.

Balance check, and a confidently wrong answer about the flee mechanic

Sim check (150 runs/cell, 5 Blades only): overall 35% vs. the settled ~42% baseline - if
anything the room-forced floor trended harder, not easier as I’d worried (Barbarian 37%->21%,
Rogue 47%->32% the biggest drops; Mage actually went up 33%->43%). My guess: a room layout removes
the option to skirt around a fight an open cave/tunnel sometimes allows.

The mistake: asked what the current flee/boss-movement algorithm was, and got back a confident
answer that fleeing was purely a battle-state reset with zero map-position change - only
apply_stab/flee_instead_of_dying in battle/boss.rs had been read directly, never the call
sites around them in battle/resolve.rs. I was sure real relocation code already existed (“that
was the whole point of the flee”) and I was right: relocate_final_boss (added 2026-09-20, commit
ba01431, still there unchanged) already teleports the boss to a random walkable tile 25-70 BFS
steps from the player every time he flees, healed and a phase on, battle closed. A real research
gap - every call site around apply_stab/flee_instead_of_dying should have been checked, not
just the two functions themselves.

Final boss relocation now picks an actual room, not any walkable tile

Once the real mechanic was on the table, raised a concrete problem with it now that the final
floor is force_rooms-built: a raw “25-70 BFS steps away” tile can land him mid-corridor instead
of in a room at all, and a winding corridor path makes step-count a bad proxy for “a different
room” - a structurally close room can require a long path (high step count, wrongly excluded) and
vice versa.

Fix: Map gained a rooms: Vec<Rect> field (empty except on a force_rooms floor - carried over
from MapBuilder::rooms in run_flow::advance_level, right before the Map moves into its
resource). relocate_final_boss (battle/resolve.rs) now picks a random room OTHER than
whichever one contains the player (Rect::point_in_rect), target = that room’s center - the old
BFS-distance-field logic stays only as a fallback for when there’s no room list at all (not a real
path today, since the final boss only ever exists on the one floor that always has one).

New permanent test, boss_sim::final_boss_flees_into_a_real_room_not_a_corridor (20 trials): after
a Stab on the real 5-Blades final floor, the boss’s new position is always inside some room and
never the player’s own room. cargo build, cargo test (40 passed) and a debug launch all clean.
Still on final-dungeon-rooms-builder, not yet merged.

Post-fix balance re-check (150 runs/cell, 5 Blades only): 35% overall, identical total win
count to the pre-fix check (263/750 both times) - just a different class distribution, within
noise. Confirms room-targeting changes where he lands, not really how hard the floor is.

Backlog item 12: Fortress Ingot, and a docs note (picked 1, 2, 5 of the five sub-items)

Same branch. Fortress Ingot no longer spawns on the 5 Blades dungeon’s final floor (reused
on_final_boss_floor - renamed from force_rooms now that it gates three unrelated things: map
generation, the win condition, and this). New permanent test checks both directions (an ordinary
floor still places it; the final one never does). docs/ideas.md item 1 now notes the Blade buff
is capped by the chosen dungeon tier (the fix shipped 2026-09-21; the doc note was just missing).
cargo test (41 passed), cargo build, debug launch all clean.

History screen’s two single-spaced lists (item 12’s other named sub-item) is still open - a real
layout trade-off (shrink rows-per-column vs. grow the boxes taller) I needed to call before any
pixel numbers got touched.

History screen: a real screenshot reframed the “single-spaced” complaint entirely

Sent an actual screenshot instead of picking A/B - the real problem wasn’t row spacing at all, it
was that “Name: Count” left the count landing at a different column per entry depending on how
long the name was, reading as crowded/misaligned rather than gappy. Fixed in list_in_box
(screens/stats_view.rs): name left-justified in a 17-char field (exactly fits “Goblin
Chieftain:”, confirmed the longest name across every item/enemy in template.ron - nothing else
comes close), count right-justified in a small 3-char field right after. First pass was a full
17+4 right-justify against col_step’s far edge; asked for something closer to the middle of the
row instead of flush right, so the count field shrank to 3 with no extra padding beyond that.
cargo build/cargo test (41 passed) clean - not seen rendered yet at that point, so treated as a
first guess pending a screenshot, per the usual pixel-layout caveat.

Screenshot came back - it looks great, no further correction needed.

Item 10: townsfolk’s Quest Available / Quest Complete animations wired up

Dropped the rest of item 10 (Block D’s plaster colours/stone base, more barrel-type props - “I
would only add in more barrels etc to the town but even then I don’t think we need them”), but
wanted the two unused townsfolk animations wired in - “the names are pretty self-explanatory.”

Traced it first: each of the 5 townsfolk has 3 authored animations (Idle, Quest Available, Quest
Complete) x 4 facings on shopkeeper_idle.png, but only Idle (constant, everywhere) and Quest
Complete (only in the one-off victory ending screen, screens/end.rs) were ever actually played -
Quest Available was never constructed anywhere in the real game, despite being fully authored.

Fix: town_state::face_townsfolk_toward_player (runs every town-tick frame already, for facing)
now also picks the animation from the real quest state via quests::talk_view‘s TalkAction:
TurnIn or (the Blacksmith’s) HandInIngot -> Quest Complete, Accept(_) -> Quest Available,
anything else -> Idle. New permanent test
(quest_chain_tests::townsfolk_animation_matches_their_quest_state) checks all three states
against the real frame ranges idle_frames_for_townsperson computes - caught two real test-setup
subtleties along the way: log.offering: None doesn’t mean “nobody offering” (it skips the
offer-list gate entirely and falls through to each person’s own quest chain - Some(vec![]) is
the real “nobody” state), and the test’s own spawn geometry put the person facing West, not the
assumed South.

cargo test (42 passed), cargo build, debug launch all clean. Still stacked on
backlog-item-12-followups (a different backlog item, item 10 not item 12) rather than its own
branch off master - branching cleanly mid-session risked tangling the working tree with the other
two branches’ uncommitted changes already present, so left it stacked and flagged it.

A yellow “!” above anyone with a quest, and clearer Quest Available art planned

Wasn’t sure the Quest Available animation change alone was obvious enough - planning better,
more obvious art for it separately. Also asked for a yellow “!” over any townsperson currently
offering a quest, on top of the animation.

Added straight into town_state::face_townsfolk_toward_player (already loops every townsperson
every frame computing their quest state for the animation pick) rather than a separate pass:
collects the position of everyone currently Quest Available, then draws a yellow “!” (to_cp437('!')
on ENTITY_CONSOLE, the dungeonfont console entities already render through) one cell above each
of them, gated by the player’s real field of view the same way ordinary entities are. Had to be
careful about one thing: the badge draw can’t live inside the early return that skips the rest of
the function when nobody’s animation frame happened to change that tick, since every console gets
wiped every frame - it would flicker. Draws unconditionally whenever anyone is Quest Available,
the animation-frame update stays behind its own early return.

cargo test (42 passed), cargo build clean. Not yet seen rendered - first guess on the exact
pixel placement/color, pending a screenshot.

The screenshot came back - two real bugs, both fixed

Got a real screenshot: what showed up over everyone’s heads was a potion icon, not a “!”, and it
flickered while walking.

Wrong font, not a wrong index. to_cp437('!') drawn on ENTITY_CONSOLE/dungeonfont.png
doesn’t give a literal exclamation mark - dungeonfont.png is the item/ability icon sheet, and
its CP437 index for ! has been overwritten with real icon art (a potion, apparently), despite
being “CP437-based” only in how cells are addressed, not what’s actually drawn there. Wanted the
literal string “!”, which meant the real text font, terminal8x8.png, not the icon sheet. Moved
the badge onto BIG_TEXT_TOP_CONSOLE (console 54, shares console 19’s grid - same 32px-cell
resolution as the entities, so the existing pixel math needed no changes, and it’s registered last
of all so it draws over everything) instead of ENTITY_CONSOLE.

Flicker: gated to the wrong lifecycle. The badge draw lived inside face_townsfolk_toward_player,
which only runs during TurnState::AwaitingInput - but walking passes through PlayerTurn/
MonsterTurn too, and every console gets wiped every single frame regardless of state. So the
badge vanished on every frame that wasn’t AwaitingInput, i.e. constantly while moving. Pulled it
into its own method, draw_townsfolk_quest_badges, called unconditionally every tick from main.rs
right after the console-clear sweep, independent of TurnState entirely (a cheap no-op outside town,
since the query it runs just finds no Townsperson entities anywhere else).

cargo test (42 passed), cargo build, debug launch all clean. Waiting on another screenshot to
confirm both are actually fixed now - I can’t render bracket-lib output myself to check.

The “!” badge: too big, and jerky on movement (two more real fixes)

Sent a recording this time rather than a still (only the file path came through to me, not the
actual image, so working from the description: “a little too big” and “jerky in the movement when
I move”).

Size: terminal8x8.png on BIG_TEXT_TOP_CONSOLE/console 19’s grid is already scaled 4x (its
real 8px glyphs stretched to fill a 32px cell) - a “!” at that full size reads as oversized next
to a character sprite. Moved to DAMAGE_TEXT_TOP_CONSOLE, the fancy counterpart on the same
grid/font, drawn with set_fancy at 0.5 scale instead of the plain console’s fixed 1.0.

Jerky: the real cause, and a more interesting one - the badge was always using the camera’s
integer at-rest offset (camera.left_x/top_y), even while the camera was actively panning
smoothly during my own walk animation. Every other entity’s screen position during that pan comes
from camera_render_offset, a fractional offset recomputed every frame of the glide
(systems/entity_render.rs‘s whole second branch exists just for this) - my badge never used it,
so it stayed snapped to its old integer cell for the whole glide and only jumped once movement
finished, while everything else slid smoothly underneath it. Fixed by computing the same
fractional offset every frame (camera_render_offset, falling back to the integer one only when
the camera is genuinely at rest) and switching to set_fancy throughout - the same formula
entity_render‘s panning branch already uses, just reused here instead of reinvented. Needed
&mut self (a SubWorld in this legion version can only be built From<&mut World>, no
immutable path) - draw_townsfolk_quest_badges picked that up, no other change needed at its one
call site since tick already holds &mut self.

Applied the same Y-anchor-offset gotcha this project has confirmed independently several times
now (set_fancy renders one cell north of the same plain set() position) - written out in full
in the code (- 1.0 + QUEST_BADGE_Y_ANCHOR_OFFSET) even though the two terms cancel numerically,
so the actual intent (one row above the entity, then the fancy correction) stays visible instead
of a bare, unexplained pos.y.

cargo test (42 passed), cargo build, debug launch all clean (there was already an unrelated
release binary running from earlier - left it alone, never kill the game process by name). Still
a first guess on the exact scale/feel pending a real look - working from a description this round,
not an image.

Still jerky - fixed the actual timing bug this time

Said it was still jerky, then described it more precisely: “an offset it shouldn’t have” during
movement. That’s exactly what a one-frame-stale read looks like, and it turned out that’s exactly
what was happening: draw_townsfolk_quest_badges was called at the very TOP of tick(), before
this frame’s own movement/glide processing (MovingAnimation.elapsed_ms advancing) had even run
yet - so it always read LAST frame’s camera offset, one full frame behind the actual sprite, every
single frame of every glide. Moved the call to the very end of tick(), right before
render_draw_buffer, after all of that frame’s own state processing (whichever TurnState arm
ran) has already happened - the same fix in spirit as the plain flicker fix earlier, just for
staleness instead of absence.

cargo test (42 passed), cargo build, debug launch all clean. Waiting on a real look before
calling this one settled.

Merged the quest-badge work, and item 12’s History screen fix turned out already done

Confirmed the quest badge looked right, so I merged backlog-item-12-followups straight into
master (fast-forward, no conflicts) - cargo build/cargo test (42 passed) clean on master
afterward. While cleaning up the backlog list I realized item 12’s actual History-screen complaint
(the item/enemy count lists not lining up) had already shipped a couple commits back
(926709e, column alignment) - the two-layout-options framing in docs/ideas.md was stale, and a
duplicate note about the town’s unused Block D plaster walls (already recorded as dropped under
item 10) didn’t need to exist twice either. Moved the finished piece to docs/done.md, folded the
one still-open thread (Endless has no story intro) into item 6 where it actually belongs, and
renumbered 13-15 down to 12-14.

A new branch for townsfolk animation content, and the cat’s quest states were already right

Picked up backlog item 5 (Content) with a specific ask: make sure the townsfolk have good
animations and that every direction they can face actually uses the right one. New branch,
content-townsfolk-animations, off master per the standing rule for a new backlog item.

A quick research pass (an Explore subagent) found the six human townsfolk (Townsman, Townswoman,
Elder, Young Child, Witch, Blacksmith) already had full Idle/Quest Available/Quest Complete
coverage across all four facings, wired up and tested back on 2026-09-22 - nothing to fix there.
The two real gaps were the Shopkeeper (one fixed frame, never turns, no quest-state animation) and
the cat (south-facing only, its own three-state scheme). I walked through what I wanted for the
cat out loud: Licking = quest available, Yawning = the animation for finding it in the dungeon and
turning the quest in, Sitting = idle, both when the quest’s accepted and still in town and after
it’s finished. Checked the actual code before touching anything - spawn_cat, set_nearest_cat_ animation, run_flow.rs‘s dungeon spawn - and it already matched every part of that mapping
exactly, already shipped from before. The only real gap was the dungeon turn-in line itself
(“Meow! The cat leads you to a hidden stash of potions.” didn’t match the “Good Kitty” framing I
wanted) - changed it to “The cat followed you into the dungeon and found some potions for you.
Good kitty.” cargo build/cargo test (42 passed) clean.

The Shopkeeper’s quest-talk spot: one exact tile, not a radius

Noticed the Shopkeeper’s talk range (SHOPKEEPER_TALK_RADIUS, a 2-tile Chebyshev circle) let his
quest box trigger from several tiles around him, not just the one spot in front of his counter I
actually intended - he stands behind a counter, so the only real approach is the single apron tile
directly across it. Talkable gained an exact_spot: Option<Point> field: when set, it overrides
radius entirely and requires the player on that one tile. The Shopkeeper is the only user of it
so far, set to his own position plus two tiles south (one counter tile, then the apron beyond it).

Broke the one existing test that exercised this (shopkeepers_crate_then_coin_purse, which
teleports the Shopkeeper right next to the player rather than actually walking there) - fixed by
moving his exact_spot along with him in that fixture. Flagged a separate, real question for
myself while I was in there: the balance sim’s town bot still paths toward the Shopkeeper’s own raw
position, not the new exact spot, and I was honestly not sure that pathing was ever reliable in the
first place - a pre-existing question, not something this change made worse, and not something I
chased down this session. cargo build/cargo test (42 passed) clean.

The Shopkeeper gets real reactions: Selling, turning for a customer, and Quest Complete

Talked through the design before writing any code, the way big visual changes on this project are
supposed to go. What I wanted: the Shopkeeper turns to face West whenever someone’s browsing his
counter (he never turns for quest talk specifically - that’s the one tile directly below him,
reserved separately), and a real Quest Complete pose once I actually had one to give him. First
pass tied the “someone’s browsing” check to live ShopStock entities; a screenshot showed the
Rogue standing on a counter slot with nothing on it yet (I’m planning to add another item there)
and the Shopkeeper wasn’t turning - so I switched the check to the map’s real TileType::Counter
tiles to his west instead of live stock, so a future item slot already works today, not just once
something’s actually sitting on it.

Sent Shopkeeper.zip (PixelLab): a real West idle loop, a South idle loop, and a Quest Complete
loop, none of them the size the zip’s own metadata claimed (declared 32x32, real canvas 44x44 - the
usual gotcha). A per-frame pixel-health scan found the usual near-black outline (floored to 30 as
always) and exactly one genuine single-pixel hole, in two of the frames, both fully boxed in by
solid black neighbors - patched before cropping rather than left as a stray transparent speck.

ShopkeeperAnimation (Selling/FacingCustomer/QuestComplete) slotted into the sheet’s own
already-reserved-but-blank rows: row 0 (replacing the old Idle_Selling), row 1 (new, West), and a
brand new row 85 appended past the sheet’s old end for Quest Complete, rather than reusing rows 2-3
(row 3 specifically can never hold real content - its column 5 is glyph 32, the cls() fill cell).
State::update_shopkeeper_animation swaps his pose every tick, unconditionally, the same way the
quest badge draw call had to be - gating it to AwaitingInput alone would flicker the pose while
walking past mid-glide, the exact bug the badge already taught me to avoid. Bonus: the final victory
scene’s cheering line-up already wanted a “quest-complete cheering loop” for him in its own
long-standing comment, but had nothing to give it before this - now it actually plays one instead
of falling back to Selling. cargo build, cargo test (42 passed), and a real cargo run launch
all clean.

Elder, Townswoman, Townsman, Young Child and the Witch all get new animations

Five more zips landed in the same session: Town_Elder.zip, Townswoman.zip, Townsman.zip,
Young_Child.zip, Witch.zip - a full replacement of their existing Idle/Quest Available/Quest
Complete rows across all four facings (60 rows total on shopkeeper_idle.png), not new content.

Same canvas-size gotcha as the Shopkeeper’s zip (44x44 real, sometimes 48x48 - inconsistent even
within one character’s own frames). The pixel-health scan flagged a lot more “holes” this round -
191 of them, some in clusters of a dozen-plus pixels, especially on the Witch - enough that I
actually looked at the worst offenders instead of trusting the count. Every one I checked turned
out to be real, intentional negative space (the gap between the Witch’s staff-holding arm and her
robe, her hat brim against her hood, a townsman’s legs standing apart) rather than the kind of
single stray missing pixel the Shopkeeper batch actually had. Good reminder that my own flood-fill
hole detector can’t tell “a gap between two body parts” from “a missing pixel” - it just tells me
where to look.

Hit the same folder-naming quirk the original townsfolk batch hit for Idle folders, this time for
Quest Available/Quest Complete: PixelLab names a custom animation after its own truncated prompt,
not its role, so four of the five zips had one oddly-named third folder I had to place by
elimination and confirm by eye - Townsman’s “shoulders hunch inward, nervous tremor” and
Townswoman’s “shifts her weight, leaning” both turned out to be their Quest Available (a nervous,
asking-for-help pose); the Witch’s “leaps upward, exuberant” and Young Child’s “sudden buoyant
upward surge” were both Quest Complete (a joyful jump). cargo build, cargo test (42 passed),
and a launch check all clean.

Blacksmith is not getting new art

Asked what should happen to the Blacksmith - he’s the one person from the original townsfolk batch
who wasn’t in this new wave of zips. First I assumed only two poses made sense for him (Idle and
Quest Complete, since his Fortress Ingot hand-in is always available rather than offered/accepted
like a real quest) and confirmed that already matches how talk_view behaves - TownsfolkAnimation ::QuestAvailable can never actually be selected for him regardless. Then the real answer: there’s
no new Blacksmith animation coming at all, not now and not later - he keeps his original 2026-09-19
art permanently. Saved that as a standing project note so a future session doesn’t flag him as
missing from the next batch of this same pass.

Wrapped up the whole townsfolk-animation thread in docs/ideas.md/docs/done.md: the “quest
available idle animation never played” line under item 5 was stale (it’s not just played now, it’s
been fully replaced), so I moved the real writeup to Done and merged content-townsfolk-animations
into master (fast-forward, cargo build/cargo test clean on master afterward).

Visual projectiles for battle techniques - scoping, real art, and Arrow Volley wired up

Picked up item 2’s long-standing “visual projectiles for ranged techniques” sub-bullet - right now
a technique like Arrow Volley just loops the caster’s own portrait animation in place while damage
lands instantly; nothing ever visibly leaves the caster and travels to the target. Research first
(an Explore subagent): the battle screen already has a real fractional coordinate grid
(BATTLE_PORTRAIT_COLS/ROWS) that set_fancy can already draw arbitrary positions on (proven by the
existing wiggle/hit-flash effects), and Battle::dying_effects is the exact template for “a
transient visual with its own position and timer, ticked and drawn independently of any character
portrait.” The one real gap: nothing currently makes a technique’s damage WAIT for anything - it
lands the instant a hit is queued, before any animation plays.

Before writing any code, pulled every battle technique straight from template.ron (not guessing)
to scope which ones actually need a travelling object: Arrow Volley, Javelin Volley and Fireball
read as genuinely “thrown/shot at a target”; Blizzard is a storm, not a straight throw; Amazon’s
Poison Spear (“a venomous jab”) and Mage’s Burn read as more melee/status than travel, flagged as
open judgment calls rather than guessed at.

Generating the actual art myself, via the PixelLab API (tools/pixellab_objects.py, already
in the repo with a token in five_blades_deep/.env - had to source it inline into the shell each
call, since it’s not exported by default) rather than waiting on hand-off zips like every earlier
art batch this session. Checked the real API schema before spending anything (fetched the OpenAPI
spec directly, not guessed) - view only accepts "top-down" or "sidescroller", and battle is a
side-view portrait scene, not the dungeon’s top-down tiles, so every existing icon batch’s
"top-down" choice would have been wrong here. One batch (Arrow/Javelin/Fireball/Ice Shard, 4
items in one call since the API charges the same 20-40 generations whether you name 1 item or 64)
came back solid on the first try.

Direction turned out to matter more than I’d assumed. Called out that Blizzard’s ice should
fall out of the sky onto the enemy independent of the caster’s own position (NW-to-SE), while a
thrown/shot weapon travels caster-to-target, which - given the real layout (caster fixed bottom-
left, enemies upper-right) - is actually SW-to-NE, not the plain “horizontal” I’d been picturing.
That needed a second, purpose-built generation batch for the falling motif (Falling Ice Shard/
Icicle/Snow Cluster - the Icicle came back cleanest). Checked the exact orientation of every
candidate by eye (cropping and looking, not guessing from geometry) and found Arrow/Javelin were
already correctly SW-to-NE as generated, while Fireball (generated NW-to-SE) and the Falling Icicle
(generated NE-to-SW) each just needed a plain mirror flip - confirmed by actually applying the flip
and looking at the result before trusting it, not assumed from the math alone. Zero extra API spend
for either fix.

Built the actual system for Arrow Volley end to end: a new resources/battle_projectiles.png
(8 cols x 32px, native resolution - no padding surprise from this API endpoint, unlike the
character-animation zips) built fresh via tools/place_battle_projectiles.py, with all four
generated projectiles placed (only Arrow wired to code yet - Javelin/Fireball/Falling Icicle are
sitting on the sheet ready for their own projectile_for_technique entry later). A pixel-health
scan found nothing to fix on any of the four this time.

The real mechanical change: HitQueue (battle/damage.rs) no longer always calls strike_enemy
the instant a hit is queued/popped - launch_hit now checks whether the technique has a
ProjectileKind and, if so, spawns a Projectile (on a new Battle::projectiles, same
tick-and-retain shape as dying_effects) instead, deferring the real damage/flash/popup until
tick_projectiles sees it actually arrive (PROJECTILE_TRAVEL_MS after launch, comfortably under
the queue’s own 150ms launch interval so two are never in flight from the same queue at once). Had
to move the “queue is fully done, push the summary line” check (maybe_finish_hit_queue) so it
fires from BOTH tick_hit_queue and tick_projectiles - the old version fired the instant
remaining drained, which for a projectile technique would show “Hit all enemies 9 times” before
the 9th arrow had actually landed. Threaded the live theme’s enemy_formation_rows down from
resolve_player_action (where the resource is actually available) as a plain (f32, f32) tuple
rather than passing the resource itself through battle::damage - keeps that whole module free of
any dependency on the resource system, matching how it was already structured.

New console (PROJECTILE_CONSOLE, 61, fancy, same BATTLE_PORTRAIT grid as ENEMY_ATTACK_CONSOLE),
registered and drawn last so a projectile never appears to fly behind a portrait. Wrote two
permanent tests (battle::damage::projectile_tests) rather than trusting the timing by eye - one
proving a projectile technique’s damage really does wait for tick_projectiles to advance its
travel timer before landing, one proving every other technique is completely unaffected (still
lands instantly, exactly as before this system existed). cargo build, cargo test (44 passed,
+2 new), and a real launch all clean.

Arrow Volley and Javelin Volley both fall from the sky, not a caster-to-target throw

Got real feedback after Arrow Volley’s first pass: I actually wanted it (and Javelin Volley) to
fall out of the sky onto the target, same idea as Blizzard, not travel a straight line from the
Hunter/Amazon’s own position. Worth remembering this distinction for whatever comes after Blizzard:
a “volley”/storm technique rains down independent of the caster, a single hurled bolt (Fireball)
still travels caster-to-target - the mechanical split isn’t “ranged vs. melee,” it’s “a barrage vs.
one clean throw.”

Confirmed the fix was a plain vertical flip before touching any code - cropped both Arrow and
Javelin, flipped them, and looked at the result rather than trusting the geometry (SW-to-NE and
NW-to-SE are perpendicular diagonals, not mirror images of each other, so which flip axis is
correct isn’t obvious without actually checking). Both read exactly right once flipped: point
leading down-right, trailing up-left. Wired up Javelin Volley at the same time, since it needed the
identical treatment - a new ProjectileKind::Javelin and one more projectile_for_technique entry.

The real code change: damage::projectile_origin now decides a launched projectile’s starting
point based on its own kind, not always the caster’s fixed spot - Arrow and Javelin (and Blizzard,
once it’s wired) originate from a point up-and-left of the TARGET (SKY_FALL_OFFSET, a first
guess pending a screenshot), completely independent of where the caster actually stands. Fireball
keeps the old caster-origin behavior for whenever it gets wired up, since it’s the one kind that’s
actually supposed to travel a direct line - PLAYER_GRID_POSITION stays in the code
(#[allow(dead_code)], a real, deliberate flag rather than a stray leftover) for exactly that.

cargo build, cargo test (44 passed, same 2 projectile tests - the timing/queue behavior they
cover didn’t change, only where a projectile starts), and a real launch all clean. Next up once
this reads right on screen: Blizzard, using the same sky-fall shape these two just proved out.

Arrow Volley’s timing: a real wind-up, and the shoot pose stops outliving the volley

Sent a recording this time instead of a still - two real problems, both about SEQUENCING rather
than the direction work above. Pulled the actual frames out of the gif myself (PIL, no ffmpeg on
this machine) to see exactly what was happening rather than going on the description alone:

  1. The Hunter’s own shoot animation kept looping for about a second after the last arrow had
    already landed and the damage summary was in the log
    - the technique animation for a
    multi-hit technique loops (repeats: true) for the whole HitQueue’s span by design, but nothing
    ever stopped that loop once the queue actually finished; it just kept cycling through the result
    screen’s own trailing auto-advance hold (RESULT_AUTO_ADVANCE_MS), reading as the Hunter still
    winding up to fire with nothing left to shoot.
  2. The volley started in the exact same instant the shoot animation did - wanted a real 1-2
    second beat where the Hunter’s own animation plays alone first, then the arrows start.

Both fixed without adding any new timers, by leaning on machinery that already existed:

  • tick_battle_timers now only keeps ticking a REPEATING action animation while battle.hit_queue
    is Some or battle.projectiles isn’t empty (volley_in_progress) - a non-repeating animation
    (plain Attack/Defend/a single-hit technique) is completely unaffected, still always ticks. Once
    the queue’s fully done, the Hunter’s pose just freezes on whatever frame it was on instead of
    continuing to cycle.
  • The wind-up reuses start_hit_queue/tick_hit_queue‘s own existing timer instead of a separate
    mechanism: a projectile technique no longer fires its first hit synchronously the instant the
    technique is chosen - the WHOLE queue (including hit 1) now waits out a real PROJECTILE_WINDUP_MS
    (1500ms) first, the same way every later hit already waits out HIT_QUEUE_INTERVAL_MS. Since
    volley_in_progress counts a non-empty hit_queue regardless of whether anything’s launched from
    it yet, the animation keeps looping through the whole wind-up for free - no extra state needed.

Updated the one existing test that assumed the old immediate-first-hit behavior (a_projectile_hit_ waits_for_arrival_before_dealing_damage) rather than leaving it silently wrong.

Also noticed something I didn’t touch: with SKY_FALL_OFFSET at (-1.5, -2.5), a single-enemy
fight’s arrow spawns at row -1.5 - off the top of the visible screen - so most of its short flight
is invisible above the arena and it only really appears right before impact. Flagged it rather than
guessed at a fix, since I can’t render and check this myself and didn’t want to bundle a third,
unrequested change into the same round of feedback.

cargo build, cargo test (44 passed), and a real launch all clean.

Confirmed it looks good - trimmed the wind-up by half a second (1500ms -> 1000ms) per feedback,
cargo test still 44 passed.

Blizzard joins the sky-fall projectiles; scoped what Fireball/Burn/Poison Shot actually need

Wired up the Mage’s Blizzard the same way as Arrow/Javelin Volley - genuinely just a new
ProjectileKind::FallingIcicle (row 3, art already sitting on the sheet from the earlier batch)
plus one more projectile_for_technique entry. No other code changed at all: TechniqueEffect:: AoeMultiHit‘s dispatch already threads projectile_for_technique(&name) through generically for
any AOE technique, so Blizzard picked up the wind-up, the travel timing and the sky-fall origin for
free. cargo build, cargo test (44 passed) and a launch all clean.

Asked to keep going with Fireball, Burn and Hunter’s Poison Shot next. Fireball is the same shape
of work as the three AOE techniques (just the direct caster-to-target line instead of a sky-fall,
and it’s a FlatDamage single hit rather than an AoeMultiHit, so it doesn’t go through HitQueue
today at all - will need routing through the same queue machinery as a 1-hit case, or an equivalent).
Burn and Poison Shot are a real step up in scope, though: both are DamageOverTime techniques that
apply a status effect directly (dot::apply) with no strike_enemy call anywhere in their current
path - nothing to “defer until arrival” the way every projectile so far has worked, since there’s no
instant hit to delay. Giving them real projectile art means generalizing what a Projectile does on
arrival beyond a hardcoded strike_enemy call. Flagged this as a real design step in docs/ideas.md
rather than just another one-line projectile_for_technique entry, so it doesn’t get bundled in
without a look first.

Fireball, Burn and Poison Shot round out the projectile pass

Fireball turned out simpler than it looked: it’s a FlatDamage single hit, not an AoeMultiHit, so
it never touched HitQueue before today - but rather than build a separate one-off mechanism for a
single deferred hit, just routed it through the exact same start_hit_queue a real volley uses, as
a “queue of one.” Free wind-up, free travel timing, free deferred-message handling, no new machinery

  • the only real wrinkle was the log wording: hit_queue_summary was written assuming a real
    multi-hit technique, so a true single hit read as “Hit 1 times for 14 total damage.” instead of the
    plain “Deal 14 damage.” it should say. Fixed by special-casing total_hits <= 1 - safe, since no
    real multi-hit technique in this game ever uses exactly 1 hit.

Burn and Poison Shot were the actual design step I flagged earlier: both are DamageOverTime
techniques that apply a status directly, with no strike_enemy call anywhere in their path to defer
in the first place. Generalized Projectile itself - amount: i32 became effect: ProjectileEffect
(Strike(i32), the old behavior, or ApplyDot { damage, turns, label }) - so tick_projectiles
now matches on what a projectile is actually carrying instead of assuming it’s always a hit. A new
damage::launch_dot is DamageOverTime‘s own version of launch_hit, but called directly rather
than through HitQueue: Burn/Poison Shot don’t loop their own cast animation the way a multi-hit
volley does, so there’s nothing to synchronize a wind-up against - the projectile just launches the
instant the technique is chosen, same as the animation itself.

Art-wise: Burn just reuses Fireball’s own sprite and direct-line travel (both are the Mage hurling
one bolt of fire - only the arrival effect differs). Poison Shot needed real thought: it’s a single
shot, not a volley, so it should travel the direct caster-to-target line like Fireball, not fall
from the sky - but the only Arrow art on the sheet is now oriented for Arrow Volley’s sky-fall pose.
Rather than reuse the wrong orientation, composited a SECOND arrow row (5, past the mandatory blank
glyph-32 row) from the ORIGINAL un-flipped frame - the exact art Arrow Volley itself started with,
before the sky-fall correction flipped it.

Two more permanent tests for the new launch_dot path (waits for arrival before arming; a
non-projectile DOT stays completely unaffected). cargo build, cargo test (47 passed) and a real
launch all clean. Every named technique from the original scoping pass (Arrow Volley, Javelin
Volley, Blizzard, Fireball, Burn, Poison Shot) now has a real projectile. Still open: whether
Amazon’s Poison Spear gets one too - nobody’s actually decided that yet.

Real feedback right after: Fireball’s own wind-up read wrong - “for normal projectiles that aren’t
AOE it should be cast right away.” Makes sense in hindsight - the wind-up exists so a LOOPING volley
animation gets a beat before the volley starts, but Fireball’s cast animation was never a loop to
begin with (repeats in resolve.rs only covers MultiHit/AoeMultiHit), so gating it the same way was
never actually buying anything. start_hit_queue now only applies PROJECTILE_WINDUP_MS when
total_hits > 1 - a single-hit projectile technique launches immediately, same as a melee one
always has, and only its own travel time delays the actual damage. Updated the one test that
exercised this to match, cargo test still 47 passed.

Poison Shot was firing at the start of its own attack animation, not the end

Real bug this time, not a feel tweak: Poison Shot’s arrow appeared the instant the technique was
chosen - the very start of the Hunter’s attack animation, before the bow had even drawn - when it
should leave at the END, once the animation’s actually played out. Root cause: launch_dot (Burn/
Poison Shot’s own path, added earlier today) launched its projectile immediately with no regard for
the caster’s own cast animation at all - unlike the AOE techniques, which already had a real
wind-up to synchronize against their own looping animation, a DamageOverTime technique’s one-shot
animation was never accounted for.

Added a real two-stage flow: Battle::pending_dot now holds the hit until the caster’s own
player_action_animation finishes (OneShotAnimation::total_duration_ms(), the same duration
already used elsewhere for flash timing) - only once that elapses does tick_pending_dot turn it
into an actual Projectile, which THEN travels and arms the dot exactly as before. Had to widen the
result screen’s auto-advance guard (screens/battle/mod.rs) to also hold off while pending_dot
is Some or projectiles isn’t empty - it previously only checked hit_queue, which a DOT
technique never touches at all, so the result screen could have dismissed itself mid-flight.

Rewrote the permanent test for this to build a real OneShotAnimation with a known duration and
walk through all three stages (waiting on the cast animation, in flight, armed on arrival) rather
than just the last one. cargo build, cargo test (47 passed) and a real launch all clean.

Orc and Ogre get new animations - the enemy side of the art pass

New branch, enemy-animations-orc-ogre, off master (kept separate from the still-unmerged battle
projectile branch - unrelated work). Orc.zip/Ogre.zip: both already-existing, non-boss enemies,
so this was a pure asset swap onto rows already reserved on all three enemy sheets
(enemy_idle.png/enemy_battle.png/enemy_attack.png) - no code changes needed at all, just
tools/place_orc_ogre.py. Same gotchas as every batch so far: metadata.json’s declared 32x32 was
wrong (real canvases 40-44px), and the pixel-health scan flagged a good number of “holes” that all
turned out, on an actual look, to be real negative space (arm-vs-body gaps, a mid-stride leg gap,
weapon-vs-body gaps) rather than corruption - same conclusion three batches running now, worth
remembering that a high hole count on this kind of art isn’t automatically a problem. Orc’s Walk
folder had the usual “named after its own prompt” quirk; Ogre’s was named normally. cargo build,
cargo test (42 passed - no new tests needed, nothing but art changed) and a real launch all clean.

New backlog item: a themed enemy set per dungeon

Asked to add this to the list: a distinct set of enemies for each dungeon theme (Forest/Dungeon/
Sewer/Swamp), not just the themed tilesets that already exist - the Sewer set is already done on
the user’s end, but explicitly not being implemented yet, just tracked. Also asked me to think
through how to actually SEE a theme’s tileset and its enemies together once this happens, not just
the floor art alone - the existing Debug-class Theme Select screen already forces every floor of a
run onto one theme for exactly this kind of testing, so the natural extension is making sure
whatever enemy-spawn pool a themed dungeon draws from is also theme-aware, and worth checking
whether that screen (or the balance sim’s own bots) already surfaces which enemies actually showed
up during a themed run, or if that needs its own small addition once real per-theme rosters exist.
Not scoped further than that - explicitly a “later” item, logged in docs/ideas.md.