Custom Roguelike - 9/21/26 — Saves in App Data, Continue Run, a Fuller History Screen, and Town Polish
Published 2026-09-23
Backlog cleanup (16 open items -> 13)
docs/ideas.md condensed: item 1 (the balance pass) shrank from 79 lines to a current-state summary plus the real to-dos, with its bot-building history moved to Done as the “Balance-pass work log”; the standing “fix battle bugs as they turn up” bucket and the 8-way rotations bullet were dropped (not actionable); music and the Options Audio/Window rows became one item; “more quests per townsperson” and “more winnable items” became one Content item; the Steam checklist moved to docs/steam_checklist.md. Renumbered 1-13 and the code/doc comments that quoted numbers updated. Still open: balance to-dos, the AOE nerf, mouse-targeted AOE, battle screen effects, animation leftovers, audio, more class abilities, content, Endless, Steam, story, data protection, bots for the new game, art leftovers.
Backlog trimmed again on the user’s call: the mouse-targeted ranged AOE outside battle (no longer makes sense), the battle hit-impact effect bullet, and the animation-work item (“we are done with all of this”) are gone; the story item now just says the story is decided (a demon is attacking the town) and what is left is getting it into the game; “Content” (more quests per townsperson, more item variety) is next. 11 open items.
More quests for each townsperson (backlog “Content”, built)
The user picked this next (“should be easy”), from the proposals they had already liked. Each person now has a short CHAIN offered in order (by QuestLog::completed, the machinery the Witch already used) and the old “one quest per run for the Townsman/Townswoman/Elder” rule is gone: the Townsman gets a second, bigger bounty (three more of the same enemy) for 3 Healing Potions + a Map; the Townswoman the Old Lantern; the Elder the Old Key (8 copies of one ability) and the Golden Chalice (Reward::EveryClassAbility, one copy of every ability the class can win); the Young Child the Shiny Pebble; the Shopkeeper a second quest, the Coin Purse (Reward::Gold(60)). Only the Shopkeeper and the Witch pay Speed/Defense Potions. Five new quest-item kinds with icons (lantern, key, chalice, coin purse, gem: tools/place_icons.py now takes a per-icon source folder; cells ½ ¼ ⌠ ⌡ ≈), a find_quest helper for the simple find-an-item quests, pay_reward arms for gold and the every-ability reward, the pause hint about “one quest per run” replaced, and docs/quest_text.md has the table. Two permanent tests (balance_sim/quest_chain_tests.rs) run every chain through the real state: order, rewards paid (potions, maps, gold, ability counts), nothing left to offer afterwards, no Speed/Defense from the townsfolk. The bot only plays the Blacksmith’s exchange, so its win rates are unaffected. Not verified on screen: the five new icons in the dungeon.
Town lore: everyone is in town, a random subset offers quests
The user’s idea for getting the story into the game: every townsperson is in town every visit, a random subset gives out quests and the others talk lore. Built: enter_town now spawns all of ALL_TOWNSFOLK (the old 1-2-random-people rotation and pick_townsfolk are gone, and with them the “quest holder must appear” special case) and rolls QuestLog::offering with quests::roll_quest_offers (each giver with a quest left offers with 55%, and if nobody rolls while someone has a quest one is picked, so there is always something to do); quest_offered_by returns nothing for a giver that did not roll, so talk_view falls through to their greeting - which is now LORE: components/talk.rs::lore_lines has 3-7 rotating lines per person (by town visit) about the Demon, the Blades lending strength at a price, the nether opening if you hold too many, the fortress steel, the missing travelers; the Blacksmith shows his ingot line and a lore line under it. The story canon: a demon attacks the town, the Blades were forged to bind it. Texts are in docs/quest_text.md (generated from the code so it cannot drift), the two hints that described the old rotation were rewritten, backlog item 8 (story) updated. Permanent tests: everyone is present on the first visit, the roll is a real random subset and never empty while a quest exists, a non-offering giver falls back to lore, lore rotates and the Elder’s lines mention the Demon, the Blades and the nether. The bot still plays only the Blacksmith (80% in a 100-run check, no timeouts). Not verified on screen: five people standing on their doorsteps at once.
The townsfolk react to the Blades you hold
The user’s next step for the story: the more Blades you own, the more the townsfolk see your power, but the more they worry. QuestLog::blades is read when the player enters the town (tiers::blades_owned, which the balance sim can override) and components/talk.rs::reaction_lines(name, blades) gives every person (Elder, Blacksmith, Townswoman, Townsman, Witch, Young Child, Shopkeeper) two lines per stage - 1-2 Blades admiring but a little uneasy, 3-4 worried, 5 afraid; talk_greeting(name, visit, blades) uses a reaction line on every other visit once you own a Blade and lore on the rest, so both keep rotating (the Blacksmith’s ingot-line companion line uses it too). The texts are in docs/quest_text.md. Permanent test: nothing to say with no Blades, three distinct stages for everyone, every reaction shows up in the rotation, lore still shows up, and the Elder’s worry grows. Not verified on screen; to see the stages put Progress(blades: 3) (or 1, 5) in saves/progress.ron.
Correction from the user: the townsfolk’s reactions follow the DIFFICULTY TIER chosen, not how many Blades are owned (choosing “1 Blade” shows the 1-Blade stage even if you own more). QuestLog::blades became tier_blades, set in enter_town from State::dungeon_tier (Dungeon Crawl 0, “N Blades” N, the final dungeon and Endless 5); Dungeon Crawl has no reactions at all. Docs updated. To preview: pick the tier on the difficulty screen (it needs Blades owned to unlock - Progress(blades: 5) in saves/progress.ron).
Item descriptions quote the real (scaled) numbers
The user noticed the Healing Potion still said “Restores 6 HP” after the x7 stat scale (it heals 42). Descriptions in template.ron now use variables - {heal}, {defense}, {damage}, {bonus}, {initial}, {min}, {max} - which spawner/template.rs::render_description fills in from the template’s FINAL effect/technique numbers at load (after the stat scale, the tier and any balance scenario), so they can never go stale; numbers that never scale (turns, hit counts, percentages) stay written out. Converted: Healing Potion, Defense Potion, Ice Armor, Rend, Garrote, Whirlwind, Fireball, Burn, Throw Spear, Battle Cry, Poison Spear, Trap, Shoot, Poison Shot. The Battle Arena, which is never scaled, still reads “6 HP”. Permanent test description_tests: no placeholder is ever left showing, dungeon text is scaled, Arena text is not.
Mistake fixed: the final victory picture overwrote the Swamp defeat scene
The user’s screenshot of a Swamp defeat showed the village square: I had put the new final victory painting into “slot 17” believing it free (I had only listed the VICTORY slots), but slot 17 is DefeatBackground::Swamp. Restored slot 17 byte-for-byte from the commit before (checked: the whole sheet was identical to the pre-victory one), put the victory scene in slot 18 (the first free slot; FinalTown -> Some(18)) and verified that 18 is now the ONLY slot that differs. tools/place_victory_background.py now refuses to overwrite a slot that already has art unless told force, and the glyph map doc has a complete slot table (battle 0/1/2/14, victory 3/4/6/7/8/12/13/15/16, defeat 5/9/10/11/17, final victory 18). The earlier journal/plan mentions of “slot 17” for the victory scene are history and wrong. Also on the same screenshot: the Defeat screen now has the same bordered black text panels as Victory (screens/end.rs::draw_end_text_panels, shared by both), and its text is updated - the old second line still talked about the Amulet of Yala, which the Dungeon Crawl no longer has; it now says the Demon still stalks the town but the Blades you have earned are yours to keep (Arena: “try again”). Not verified on screen.
Six dungeon intro screens
The user’s idea for the story’s opening: when a difficulty tier is picked, a screen with a bit of lore for that dungeon, then any key starts the adventure. New TurnState::TierIntro between the difficulty screen and class select (screens/title.rs::tier_intro): the dungeon’s name (big text), the lore in a bordered black PanelBox over the title background, “Press any key to continue.” in its own box; any key continues (ignored for the first 350 ms so the Enter that picked the tier cannot dismiss it), Esc goes back to the difficulty screen; Endless has no intro (it goes straight on). The six texts (Dungeon Crawl, 1-5 Blades; progress::tier_intro) escalate the story: the dungeon waking and the first Blade, the town’s hope and dread, the cost the Blades take, the nether pressing on the stone, the last Blade, and the final descent to the Demon that cannot be beaten by strength alone. They are in docs/quest_text.md. Permanent test: every tier has its own text, each fits the box (30 wrapped lines), Endless has none. Not verified on screen: the layout (a box sized from the wrapped line count, 96 wide).
Blade buff now capped by the chosen tier
A player with several Blades doing 16 damage in the Dungeon Crawl was too strong: the Blade buff (+8% health, +5% damage per Blade owned) applied at full strength in every tier. tiers::apply_to_class_stats now counts only min(Blades owned, the tier’s Blade number): Crawl gets none, “2 Blades” at most 2, Endless and the 5-Blade dungeon use everything owned. The balance sim already gives its player exactly the tier’s Blades, so its numbers are unchanged.
Town buildings and props (Block D + a props block)
Two new 16-cell tile sheets from Pixellab went into map_tiles.png (now 37 rows): Block D (roof left/right ends, chimneys, plain/posted plaster, stone base, ochre and blue-grey walls) and a props block (witch potion sign, blacksmith banner, cauldron, herb rack, toadstools, potion shelf, poppies, daisies, wildflowers, a tulip bed, planters, a rose fence, sunflowers). Wall variants 16-63 now all map to the detail blocks. town.rs::cap_roofs gives every roof run its own end pieces and chimneys automatically; the witch’s house (the dark stone one, doorstep 5 - first put by mistake on the blue house next door) got her sign, a cauldron, herbs, toadstools and a potion shelf in its front yard; the blacksmith got a banner over the forge; flower beds, planters and roses are scattered over grass and the cobbled lanes (all impassable, like the other props). The props’ grass background was recoloured to the town’s grass texture. Not yet used: the plaster colours and stone base. Not verified in the game (only in tools/town_preview.py).
Props redrawn on the town’s own textures
The user’s screenshot showed the new props and the windows on the stone houses standing on the wrong background (cream plaster in a stone wall, Pixellab’s grey stone instead of the town’s beige cobblestone). tools/blend_town_props.py adds a Block F (rows 37-40, Wall variants 64-79: the shuttered window, flower window, the witch’s sign and the door, redrawn on the town’s dark stone wall) and redraws the blacksmith banner/shield on the stone wall and the shelf, planter and cobble cauldron on the town’s cobblestone, by flood-filling only the background connected to each cell’s border. The smith’s and the witch’s houses use the stone versions (chars i I 8 j in the template).
Story moves into the town; bordered title menus; flower windows
The user’s feedback: the story text belongs after class select, in a box on the town map. TurnState::TierIntro and its screen are gone; start_game arms tier_story_pending and the end of enter_town opens tier_story_open, and State::tier_story_overlay (drawn during AwaitingInput) shows the tier’s story in a bordered black PanelBox over the town, swallows input (key and mouse) while open, and closes on any key after 350 ms (a dismissing Enter is armed in pending_enter_release so it cannot also talk to someone). It shows once per run, not on later town visits. Also: “Choose Your Adventure” and the difficulty list now draw their options inside bordered black boxes (render_helpers::panel_menu_row_centered, a heading box and a hint box); and the flower-box window (which sat on plain cream) is redrawn on the lattice wall by tools/fix_flower_window.py. Not yet verified on screen: the menu box layout, the overlay box position.
Planters and the potion shelf moved onto the grass
The planter boxes and the witch’s potion shelf stood on the cobbled lanes. tools/planter_on_grass.py adds grass-backed versions (Block F cells 5-6, Wall variants 68-69) and town.rs puts them on the grass in front of the houses, off the paths (the shelf beside the witch’s herb rack and cauldron).
Barrels, crates and the hay cart on the cobblestone
They were drawn on Pixellab’s dark grey stone and showed as dark squares on the beige cobbles. tools/market_props_on_cobble.py redraws the three cells (Wall variants 25-27) on the town’s cobblestone; all their uses are on cobble, so it is an in-place change.
Difficulty menu on one row, shop sign, flower windows (for real this time)
The difficulty screen repeated a name and a description per dungeon; it is now ONE row of dungeon names (Left/Right, or Up/Down, moves the highlight; number keys and Enter still select) with only the highlighted dungeon’s description (and “[Blade earned]”) underneath, in the bordered box. The shop got a hanging sign on its wall (Block F cell 7, the second potion-sign art on the lattice wall; the old standing sign is gone). The flower-box window is now on the lattice wall: my first attempt (tools/fix_flower_window.py) wrote to the wrong atlas cell (row 26 col 2 is the FORGE, variant 22 - variant 18 is row 25 col 2) and was lost before it was ever committed, so the forge was never damaged in a commit; the script now targets row 25 and uses a looser cream test so the cell’s edge shading does not survive as a cream frame. Lesson: variant N is at row 25 + (N-16)/4, col (N-16)%4.
Locked dungeons shown greyed out
The difficulty row now always lists all seven dungeons (progress::all_tiers); the ones not yet unlocked are dark grey and can be highlighted but not started (Enter and the number keys ignore them), and their description reads “Locked. Earn N Blades to unlock it.” (Endless: “Defeat the Demon to unlock it.”). A new player sees “Dungeon Crawl” in green and the rest greyed.
History moves to the adventure menu; title screen simplified
The title screen no longer has the O (Options) and H (History) shortcuts - any key continues, Esc quits. “Choose Your Adventure” now has four entries: Main Game, Battle Arena, Options, History (the play-history/stats screen, whose Esc returns to that menu). Number keys 1-4 work.
Menu heading is the game’s name
The heading above the adventure and difficulty menus now reads “FIVE BLADES DEEP” (same as the title screen) instead of “Choose Your Adventure”.
Per-class, per-dungeon stats and bordered History screens
For the coming achievements (“every class has beaten every dungeon”, “played Endless”) stats.rs now records, per class: dungeon runs and wins keyed by tier label (tier_runs/tier_wins), the fewest turns of any win per tier (State.run_turns, one per PlayerTurn), deaths, quests completed and gold earned in dungeon runs (chests when the loot box closes, quest gold rewards); and globally the enemies defeated by type (enemy_kills). The Debug class is never recorded (all record_* skip it; the item counter checks the user’s class). Old saves load fine (new fields default to empty; past wins have no tier and cannot be backfilled). Under cfg(test) Stats::save does nothing so tests cannot overwrite real history. The History screen is rebuilt from bordered black boxes: overview table (with a Dungeons column: tiers beaten out of six), a class page with the tier-by-tier record and ability usage, Items (I) and Enemies (E) pages. Permanent test: stats::tier_stats_tests. Not verified on screen: the layout.
History overview: items and enemies in their own boxes
The I and E hotkeys and their pages are gone. The History overview is now the class table in a shorter top box (rows two apart), with an “Items used” box bottom-left and an “Enemies defeated” box bottom-right that are always on screen (lists sorted by count, second column when there are more than 12). Enter on a class still opens its page.
Class page spacing
The class page’s dungeon rows and ability list were single-spaced and crowded; both are two rows apart now (abilities in two columns of eight).
Save files move to a per-user app-data folder
src/paths.rs picks one save folder at startup from the build target: %APPDATA%\FiveBladesDeep (Windows), ~/Library/Application Support/FiveBladesDeep (macOS), $XDG_DATA_HOME or ~/.local/share/five_blades_deep (Linux); FIVE_BLADES_SAVE_DIR overrides it. All eight save files (stats, progress, keymap, fullscreen, battle_speed, atb_mode, menu_memory, last_battle_action) load and save through paths::save_file; the first launch copies an old saves/ folder next to the game into the new place (the old files are left alone). Under cfg(test) the folder is a private per-process temp dir, so tests and the balance simulation never touch real saves. Checked by launching with XDG_DATA_HOME pointed at a scratch folder: all eight files appeared. Not tested on Windows or macOS (no build available here).
Settings in one file
keymap, fullscreen, battle_speed, atb_mode, menu_memory and last_battle_action are now entries of one RON map in settings.ron (paths::read_setting/write_setting, drop-in replacements for read_to_string/write, so each type’s own text format is unchanged). If settings.ron does not exist yet the old separate files are read; the first write folds them in and deletes them. Permanent test in paths.rs covers the shared file and the old-file pickup. stats.ron and progress.ron stay their own files (they get protected next).
Protected saves
stats.ron and progress.ron (what Blades and the coming achievements rest on) are now stored as FBD1 <checksum> <hex of the text XOR a fixed key stream> (paths::read_protected/write_protected, an in-house FNV-1a checksum, no new dependency). An edited file fails its checksum: it is renamed <name>.tampered and the game starts that file fresh. Old plain files are accepted until the first protected save is written (a protected_saves marker in settings.ron), so existing progress carries over; after that a plain file counts as edited. This only deters casual editing - the key is in the game, so anyone reading the code can get past it. Permanent tests in paths.rs (round trip, edit detection, plain-file rejection). Not yet checked on Windows/macOS.
Windows save folder: Local, not Roaming
The Windows save folder is %LOCALAPPDATA%\FiveBladesDeep (AppData\Local), not %APPDATA% (AppData\Roaming, which follows a user between machines on some networks); the fullscreen and key settings are per-machine anyway. Falls back to %APPDATA% if LOCALAPPDATA is missing. Steam Auto-Cloud can sync either.
Fullscreen on the monitor the window opened on
Bug from the first Windows build: with fullscreen on, the game opened on the second monitor with no way to move it. Cause (bracket-terminal 0.8.7 hal/native/init.rs): with_fullscreen(true) fullscreens on el.available_monitors().next(), the FIRST monitor the system lists, not the primary one or the one the window opened on (so a window manager such as DisplayFusion placing new windows on the launching monitor is ignored). Fix: main() builds with with_fullscreen(false), waits 400 ms (so such a window manager can place the window first), and calls set_fullscreen(Some(Borderless(None))) on the window through BACKEND (winit: None = the window’s current monitor); this has to happen between build() and main_loop, which takes the window out of BACKEND. New dependency line glutin = "=0.29.1" (already locked via bracket-terminal, only so the type can be named). Compiles for the Windows target; not tested on a multi-monitor Windows desktop.
Options: reset progress and history
A fourth row in Options’ Gameplay box, “Reset progress and history” (main menu only - greyed in the pause menu, since a run is in progress there), opens a confirmation box; Y deletes progress.ron (Blades, unlocked dungeons) and stats.ron (history) and clears the in-memory Stats; Escape/N cancels, other keys are ignored so a held Enter cannot cancel it. Controls and settings are kept. When the run save exists it is deleted here too. Not yet checked on screen.
Saving a run in the town
Design (user, 2026-09-20): the run is saved automatically only when the player arrives in the town, the save is consumed when it is loaded (“strict”), starting a new run asks before it replaces a saved one, and Debug never saves. Built as screens/run_save.rs: RunSave (class, tier label, floor just left, in-start-town flag, turns, health, gold, carried items by name and count, carried quest items, the quest log) written through paths::write_protected to run.ron; save_run() runs at the end of enter_town (skipped once right after a restore); start_game became start_game_with(class, Option<RunSave>), and on a restore skips the starting kit and the stats “run started” count, replays the Blacksmith’s weapon upgrades to rebuild the weapon, respawns the items, restores health/gold/floor/quest items and the quest log (visits minus one, since enter_town adds one). The quest types hold &'static str texts serde cannot read back, so QuestLog::to_saved/from_saved convert to all-String mirrors (SavedQuestLog; loading leaks a few dozen bytes). The file is deleted on Continue, when a run ends in a death or victory (return_to_title), and by Options’ reset. The adventure menu shows “Continue Run” (class, dungeon, next floor) first when a save exists, and “Main Game” asks for a Y before replacing it. Permanent tests: a full save/restore round trip (gold, health, floor, items, weapon tier, quests, turns, visits) and that Debug/Endless do not save. Not saved: temporary buff effects (Defense/Speed potions). Known loophole (ideas.md item 13): quitting to the title from inside the dungeon keeps the last town’s save. Not yet tried in the real game.
Battle damage numbers on top, with a black outline
The floating “-N” numbers were drawn on DAMAGE_POPUP_CONSOLE (23), which the later-registered battle sprite consoles (28-43: character/enemy battle, attack, death, the final boss…) paint over, so the numbers hid behind the sprites; and a plain no_bg console has no background, so they had no border. New console 55, DAMAGE_TEXT_TOP_CONSOLE (fancy, console 19’s grid/font, registered last, added to the per-frame cls sweep), and render_helpers::print_outlined_fancy: eight black copies of the text offset about two pixels each way, then the red text on top. Same one-cell-north set_fancy anchor correction as the portrait helpers. Not verified on screen (position and outline thickness may need a correction round).
Shopkeeper quest: the perk applies at once
Bug: handing in the Shopkeeper’s crate quest set shop_perk, but only enter_town reads it, so the town shop was better stocked and cheaper from the NEXT visit, not the one the player was standing in. Reward::ShopPerk now also adds two to every counter item’s stock and takes one coin off every price (minimum 1) on the shop already built, once (only when the perk is newly granted); later visits build the perked shop as before. Permanent test: run_save::tests::shop_perk_applies_to_the_counter_immediately.
No quests on the last town visit
The town visit right before the final floor (FINAL_FLOOR = 2, the third floor) can no longer start a quest, since the boss kill ends the run and it could never be handed in. enter_town sets QuestLog.last_visit (recomputed each visit, not saved); quest_offered_by then returns nothing, the town cat does not appear, and talk_view shows talk::encouragement_lines (a couple of lines per townsperson, in docs/quest_text.md) instead of the usual lore. Handing in a quest that is already complete, and the Blacksmith’s ingot exchange, still work. Permanent test: run_save::tests::nobody_offers_a_quest_on_the_last_visit_before_the_final_floor. Also FINAL_FLOOR replaced the two hardcoded map_level == 2 checks in advance_level.
9/21 playtest fixes: pause-menu save notice, Amazon attack swap
Playtest of the last stretch: saves, settings, fullscreen, menus, History, the story box, the town art and the battle numbers all confirmed good. Asked for: (1) the pause menu should say what quitting does - in the town “Your run is saved. Quit to the title and choose Continue Run to return here.”; in the dungeon “If you quit now, Continue Run takes you back to your last visit to the town.” (only for runs that can be saved); (2) the Amazon’s basic attack looked too much like Throw Spear, so her basic-attack animation and her Javelin Volley animation swapped rows between character_attack.png and character_technique.png (tools/swap_amazon_attack_volley.py; not yet seen in the game).
The run save exists only while in town
To stop save-scumming (the user: “if you exit in the dungeon you lose the save”): advance_level (leaving the town for a floor) now deletes run.ron, so quitting to the title, dying or force-closing the game inside the dungeon can never be undone by continuing; arriving in town saves again. The pause menu says so in the dungeon (red: “You are in the dungeon: if you quit now, this run is lost.”). Replaces the earlier “quit mid-dungeon keeps the last town save” behaviour (backlog item 13 updated).