Game-file footprint unchanged: still one line in main.lua.
Three-level navigation as specified in D2: Ctrl+PageUp/Down cycles categories,
PageUp/Down cycles kinds, Shift+PageUp/Down cycles instances of that kind, Home
announces where it is.
- categories.lua: flag-first classification into Creatures, Items, Alchemy,
Interactables, Exits and Terrain features, reusing the game's own 'exit' tag
and excluding the six nameless mechanical templates.
- tracker.lua: scan, cache, navigation, announcements.
Caching keys on level name, player position and turn: position because
everything sorts by distance to the player, turn because creatures move and
containers empty. Scans run on keypress, never per frame.
FoV is respected throughout -- creatures only when currently visible, tiles when
visible or remembered -- so the tracker never becomes a cheat.
Home reports path length alongside the offset, via the game's own
calculatePathFromPlayer. Straight-line distance is misleading indoors, where a
chest three tiles north through a wall can be twenty steps away.
Corrects D4 in docs/05-decisions.md. I had claimed tile.tryInteract cleanly
separates 'the player can do something here' from scenery. It does not: 'wall'
carries a flavour-only tryInteractFunction ('You touch the wall. It's cold and
rough.'), so every wall segment was promoted into Interactables -- 13 in the
starting house, burying the chest and cauldron. Structural tiles
(blocksMovement and blocksVision) are now excluded before the tryInteract test;
containers and furniture block movement but not sight, so they survive.
12 kinds/19 instances instead of 13/32.
13/13 testbed checks, history unit tests passing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8.5 KiB
Decisions
Answers given, and their consequences. Supersedes the corresponding entries in 04-open-questions.md.
D1 (was Q1) — Development uses system LÖVE
Dev setup: C:\Program Files\LOVE\love.exe (11.5, matches build.lua's love = '11.5'), running the source folder directly. lovec.exe in the same directory gives a console window and is what we'll use for debugging.
Tolk DLLs therefore go next to love.exe in C:\Program Files\LOVE\ — that's the executable directory the OS loader searches. (Copies available at D:\code\cafe, D:\code\dragonrpg-mapmaker, D:\code\DragonWarriorAccess.) Needs an elevated copy, one time.
Distribution deferred until the mod works. Noted for later: whatever we ship must place the DLLs beside the executable, so a plain .love file can never be the whole delivery.
D2 (was Q2) — Tracker hierarchy confirmed
Three levels, as proposed:
| Level | Key | Example |
|---|---|---|
| Category | Ctrl+PgUp / Ctrl+PgDn |
"Creatures" |
| Item (kind) | PgUp / PgDn |
"rat" |
| Instance | Shift+PgUp / Shift+PgDn |
that specific rat at (12,7) |
| — | Home |
announce distance + direction of current instance |
Categories accepted: Creatures / Items / Exits / Interactables / Terrain features / Alchemy, conditional on covering everything. See D4 for the coverage check.
D3 (was Q7 + Q10) — Small patches now, upvalue probing kept as a fallback layer
Decision: use small explicit patches in game state files where they genuinely simplify things — but keep the option of switching to zero-edit operation, in case the game ships on Steam and editing files stops being practical for end users.
Why Steam changes things
A Steam LÖVE release is normally a fused executable (the .love archive appended to love.exe). Editing game source then means unpack → patch → repack → replace the exe, which Steam's file integrity check may revert on next update. That's a bad end-user story, and it's exactly the scenario the upvalue technique was designed for.
The design that keeps both doors open
Make the state descriptor the single abstraction, and let each descriptor declare how it gets focus — with two interchangeable backends:
-- accessibility/states/main_menu.lua
return {
title = "Main menu",
-- Backend A (preferred): the patched game file calls access.announce(...) directly.
-- Backend B (fallback): read the same values as upvalues, no game edit needed.
probe = { fn = "keypressed", focus = "selectedOption", list = "optionsList" },
render = function(entry, i, n)
return ("%s, %d of %d%s"):format(entry.title, i, n, entry.isEnabled and "" or ", unavailable")
end,
}
render — the part that carries all the per-screen knowledge — is identical for both backends. Switching modes is a global config flag, not a rewrite:
mode = "patched"→ the driver trustsaccess.announce(entry, i, n)calls injected into game files.mode = "probe"→ the driver wrapsstates.keypressed, reads upvalues viadebug.getupvalue, and calls the samerender.
This costs us little now (we write render either way, plus a ~40-line probe driver) and means a Steam release is a config change plus fixing whichever probes drifted — not a redesign.
Consequences
- Patches stay mechanical and uniform: a single
access.announce(...)line per focus-change site, nothing else. Easy to read in a diff, easy to re-apply. - The git baseline (
vanilla-0.4.2tag) becomes load-bearing — it's how we rebase patches onto a new version. Do this in Phase 0. - The startup self-test is still required, and now checks both backends: which patch sites are present, and which probes still resolve. This is the version-bump canary either way.
- Upstreaming to Spellweaver stays viable, since the patches are small and additive. Worth revisiting once there's something working to show.
D4 — Category coverage check (the condition on D2)
I classified all tile templates statically to confirm the six categories don't leave gaps.
Scale is much smaller than the line counts suggested:
| Template set | Count |
|---|---|
Tile templates (modules/tile/templates.lua, 6745 lines) |
231 |
Actor templates (models/actor/templates.lua) |
40 |
Item templates (modules/item/templates.lua) |
344 |
231 tile templates is very tractable — the line count is inflated by inline behaviour functions, not by breadth.
Parent inheritance is already flattened for us. modules/tile/preprocess.lua copies every missing field down from template.parent at load time. Since main.lua runs preprocessTileTemplates() during love.load, by the time our code runs, require("modules.tile.templates") gives fully-resolved templates — we read template.name / template.description / flags directly with no chain-walking.
(Minor upstream quirk noticed: the flatten is a single pass over pairs(), so a grandchild template may inherit from a not-yet-flattened parent depending on hash order. Pre-existing game behaviour, not ours to fix — but if a template ever looks oddly under-specified, this is why.)
Only 6 templates are genuinely un-nameable (no name, no description, no parent) — all invisible mechanics that should be excluded from tracking:
blank, throw_blocker, movement_blocker, fire_detector, wall_features_position_dummy, ladder_down_hidden.
Every other template resolves to a name/description and lands in a category. The six categories cover the space.
The classifier flags exist and are clean:
| Signal | Count | Maps to |
|---|---|---|
tryInteractFunction |
139 | Interactables / Exits |
loot |
68 | Items (+ Alchemy when the loot is an ingredient) |
blocksMovement |
133 | Terrain (walls) |
blocksVision |
127 | Terrain |
isThreatening |
15 | Terrain features → hazards |
getPassability |
11 | Terrain → water/special |
isLightSource |
50 | (not a category; useful description detail) |
isPushable / isBreakableByHand |
17 / 17 | Interactables |
isWallFeature |
22 | Terrain |
modules/tile.lua:234 defines the runtime flag as:
self.tryInteract = (self.loot or self.tryInteractFunction) and function(self,actor) ... end
⚠ Correction (found during Phase 3 implementation)
I originally claimed here that
tile.tryInteractalone cleanly separates "the player can do something here" from scenery. That is wrong, and the tracker was visibly worse for it.Plenty of templates carry a flavour-only
tryInteractFunction.wallis the worst offender:wall = { name = "a stone wall", blocksMovement = true, blocksVision = true, tryInteractFunction = function(self, actor) console.print("You touch the wall. It's cold and rough.") return 1 end, }So flag-first classification promoted every wall segment into Interactables — 13 of them in the starting house, burying the chest, cauldron and alchemy table under identical noise.
tryInteractmeans "something happens if you press interact", not "this is worth walking to".Fix: exclude structural tiles —
blocksMovement and blocksVision— before thetryInteracttest. That is the solid shell (walls, rock); containers and furniture block movement but not sight, so they survive. Walls are already served by the wall tones and the surroundings scan.Result in the starting house: 13 kinds / 32 instances → 12 kinds / 19 instances, all of them things you would actually want to find.
The flag-first approach still holds — it just needs this one structural filter ahead of it. The lesson is that flags encode what the game engine does, not what the player cares about.
Residual hand-maintained list is therefore small: the 6 exclusions above, the existing exit tag set (way_out, building_door, horse, ladder_up, ladder_down), and an ingredient-node list to split Alchemy out of Items. Dozens of entries, not hundreds — and new content in future versions falls through to a sensible default category rather than vanishing.
Still open
From 04-open-questions.md: Q3 (verbosity: relay vs. augment), Q4 (console auto-speak policy), Q5 (wall tone triggers), Q6 (keybinding integration), Q8 (full keyboard audit as explicit deliverable?), Q9 (which screen reader; do you want me running the game during dev).
None of these block starting Phase 0. Q5 and Q9 become relevant at Phase 2; Q4 at Phase 1.