Adds accessibility/README.md, the player-facing setup and keymap reference -- the thing to read before playtesting. Brings the developer docs in line with what was actually built: - docs/README.md rewritten as an index with current status and a one-paragraph architecture summary. - docs/scratch.md gains a HANDOFF section: state of play, the commands to run after any change, the five traps that each cost real debugging time, what is NOT verified and why, deliberate omissions, and how to rebase onto a future game version. - docs/03-implementation-plan.md gains a status banner listing where the implementation deliberately diverged from the plan (menu narration pulled forward as a blocker, levelCursorPopup not patched, tryInteract insufficient as a tracker filter, testbed added, no settings UI built). - docs/04-open-questions.md gains a resolution banner: Q6 and Q8 remain open. Also records that the testbed check count varies between runs (23-26) because some blocks are conditional, so what matters is zero failures rather than the total. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
13 KiB
Implementation plan
⚠ Status: Phases 0–5 are COMPLETE.
This document is the original plan, kept for its reasoning. Where the implementation diverged, the reason is recorded in scratch.md and 05-decisions.md.
Notable divergences from what is written below:
- Phase 4 was pulled forward, partly into Phase 1's slot. Menu narration turned out to be a hard blocker — the screen name was spoken but not the selected option, so a blind player could not start a new game at all.
levelCursorPopupis NOT patched (contrary to Phase 2a below). It fires from an animation-completion callback and lands a keypress late; theingame_observedescriptor polls the cursor position instead.tile.tryInteractis not a sufficient tracker filter (contrary to Phase 3 below).wallcarries a flavour-onlytryInteractFunction. See the D4 correction in05-decisions.md.- A code-driven testbed was added (
accessibility/testbed.lua), not in the original plan. It found five real bugs that would otherwise have surfaced only during play.- Phase 5's mod-settings screen was not built. Settings exist as fields in the relevant modules and are not yet exposed at runtime.
Phases are ordered so each one is independently playable-and-testable. Nothing later is required for something earlier to be useful.
Phase 0 — Baseline & bootstrap
git initin the game root; commit the pristine tree, tagvanilla-0.4.2. (.gitignore:dist/,*.jsonsave data.)- Vendor
d:\code\love2talkintoaccessibility/vendor/love2talk/(dropobjc/andmain.lua). - Resolve the Tolk DLL placement question (see
04-open-questions.mdQ1) and document the launch procedure. accessibility/speech.lua:pcall(require, "accessibility.vendor.love2talk"); on failure, all functions become no-ops and we log once.speech.say(text, interrupt)— normalisesnil/empty, collapses whitespace, strips the game's inline markup if any.speech.interrupt(text)— convenience for keypress-driven output.- A small queue so consecutive
saycalls in one frame don't clobber each other: buffer within a frame, flush one joined utterance at end ofupdate. This matters a lot — a single game turn can emit severalconsole.printlines.
accessibility/init.luawithpreInit/postInit, everything insidepcallso a mod bug never bricks the game.- Add the bootstrap line(s) to
main.lua. Add mod globals to.luacheckrc.
Test: game launches, speaks "Main menu" on startup.
Phase 1 — Console speech + line-by-line review
This is the highest-value, lowest-risk piece.
accessibility/history.lua
- Wrap
console.print(message, colorTemplate). - Append
{text, colorTemplate, turn = world.turn, time = os.clock()}to an unbounded (or N=1000 ring) list. - Mirror the game's coalescing: if the new message equals the last, increment
timesrather than appending, so review output matches what a sighted player sees ("You feel cold (x3)"). - Speak new lines automatically, non-interrupting, queued.
Review keys (working proposal, all rebindable):
| Key | Action |
|---|---|
Ctrl+[ / Ctrl+] |
Previous / next history line (moves review cursor, speaks it) |
Ctrl+Home / Ctrl+End |
Jump to oldest / newest line |
Ctrl+\ |
Speak current line again; press twice quickly to spell it |
Ctrl+Shift+C |
Copy current line to clipboard (love.system.setClipboardText) |
Entering review mode does not pause the game (it's turn-based; nothing advances without input anyway). Review cursor resets to newest whenever a new line arrives unless the user is actively reviewing.
Open design point: should routine flavour messages (colorTemplate == "flavor", greyed out in-game) be spoken automatically or only on review? Proposal: a config toggle, default on but spoken last in the frame's queue. See Q4.
Test: walk around, take a hit, harvest something — all log text is spoken; review keys walk the full session history.
Phase 2 — Tile inspection & surroundings
The game already has a full examine cursor (states/ingame_observe.lua), driven by moveCursor(dx,dy) and producing text via levelWindow.getCursorPopupStats() → level.getCursorMessage().
2a. Narrate the existing examine mode (nearly free).
Patch views/levelCursorPopup.set(lines, …) to speak lines. That instantly makes the game's own examine mode (default key: the examination command) fully usable — arrow keys move the cursor, each tile is spoken.
Augment the spoken string beyond what's drawn:
- Prepend distance + direction from the player (
"3 north-east"). - Append passability (
"blocked"/"passable"/"water"), whether the tile is currently visible vs. remembered ("remembered"), and whether it holds lootable items (tile.loot and not tile.unlootable). - Announce cursor-hit-edge-of-map rather than silently not moving.
2b. Surroundings explorer (new). A quick "what's around me" that doesn't require entering examine mode.
| Key | Action |
|---|---|
Numpad 1–9 (with a modifier, e.g. Alt) |
Speak the single tile in that direction, without moving |
Alt+5 |
Speak the tile the player is standing on |
| A "scan" key | Speak all 8 neighbours in clockwise order, terse |
2c. Wall tones.
accessibility/assets/walltones/walltone_{n,s,e,w}.wav are present. Play the corresponding tone when the player bumps a wall (movement blocked) and optionally as a continuous ambient cue when a wall is directly adjacent. Use TEsound.play(..., {"sfx","interface"}) so the game's own volume sliders apply. Needs a decision on when they fire — see Q5.
Test: blind-navigate a corridor in the starting area using only examine + surroundings + wall tones.
Phase 3 — Object tracker
The headline feature. Key scheme as specified:
| Key | Action |
|---|---|
Ctrl+Page Up / Ctrl+Page Down |
Previous / next category |
Page Up / Page Down |
Previous / next item within the current category |
Shift+Page Up / Shift+Page Down |
Previous / next instance of that specific object |
Home |
Announce distance + direction of the current item |
Semantics I'm assuming (please confirm — Q2): a category is e.g. "Creatures"; an item within a category is a distinct kind, e.g. "rat"; an instance is a specific rat at (12,7). So Page Down cycles rat → beetle → guard, and Shift+Page Down cycles rat #1 → rat #2 → rat #3.
accessibility/categories.lua — the tracker's own category table, since the game's templatesByTag only defines exit. Proposed categories:
- Creatures — from
level.actors, excluding the player, filtered to visible-or-remembered. Sub-grouped bytemplateId, labelled withactor.nameObserved. - Items — lootable tiles / items on the ground (
getItemOnCoordinates, and tiles withloot). Sub-grouped by item category frommodules/item/categories.lua. - Exits — reuse
level.getListByTag("exit")verbatim (doors, ladders, ways out, horses). - Interactables — tiles with
tryInteractthat aren't exits (containers, levers, alchemy tables, beds, fires). - Terrain features — water, fire, hazards (spore clouds, slime), stairs.
- Alchemy — harvestable ingredient nodes specifically; this is an alchemy game and these matter enough to deserve their own category.
Category membership is determined by a predicate over templateId + tile flags. Building this requires a pass over modules/tile/templates.lua (6745 lines) to classify template IDs — the single biggest chunk of manual work in the project. Mitigation: classify by flags first (tryInteract, loot, blocksMovement, blocksVision) and fall back to an explicit ID list only for the cases flags get wrong. That should keep the hand-maintained list to dozens rather than hundreds of entries, and it degrades gracefully on new content in future versions.
Scanning. One function tracker.rescan() walking x=1..xMax, y=1..yMax, honouring FoV / rememberedFoV exactly as getListByTag does (so the tracker never reveals unseen information — important, this must not become a cheat). Results cached and invalidated on turn advance / level change / FoV recalculation. Sort each bucket by distance to player (sortByDistanceToPlayer logic already exists in modules/level.lua).
Announcements.
- Category change:
"Creatures, 4 kinds". - Item change:
"rat, 3"(3 instances). - Instance change:
"rat 2 of 3, 5 tiles north-west". Home:"5 tiles north-west, 7 steps by path"— usingcalculatePathFromPlayerfor the true walking distance, which is far more useful than Euclidean in a walled map.- Empty category:
"Exits, none".
Extras worth adding (cheap, given the above): a key to move the examine cursor to the tracked object, and a key to auto-path to it (bufferInput("move", calculatePathFromPlayer(x,y), ingame) — the game already does exactly this in ingame_observe.keypressed under confirmTarget).
Test: in a populated level, enumerate every creature, exit and item; verify counts match what examine mode finds; verify nothing outside FoV is ever reported.
Phase 4 — Menus and screens
Now the ~16 states that keep focus in file-locals.
accessibility/states/<statename>.lua — one descriptor per state:
return {
probe = "keypressed", -- which state function to read upvalues from
focus = "selectedOption", -- upvalue holding the index
list = "optionsList", -- upvalue holding the entries
title = "Main menu",
render = function(entry, index, total, state)
return ("%s, %d of %d%s"):format(entry.title, index, total,
entry.isEnabled and "" or ", unavailable")
end,
}
The generic driver (patches/states.lua) wraps states.keypressed: run the real handler, then re-probe the descriptor; if focus changed, speak. It also wraps states.switch to announce the screen title and the initially-focused entry.
States needing descriptors: main_menu, settings_menu, config_menu, keybindings_menu, load_game, new_game, journal, character, research, worldmap, actions_menu, alchemy_table, ingame_dialogue.
Special cases:
ingame_dialogue— must speak the NPC's line and the numbered response options; the game already supports number keys 1–0 to pick a response directly, which is ideal. Needs a "repeat the NPC line" key.journal,character,research— tabbed and paged; need tab-change announcements (activeTabIndex,selectedPage,selectedPageId) plus a "read the page body" key that hands the long text to the history reviewer from Phase 1, so it can be read line by line.menu_text,narration,ingame_message,menu_message— full-screen prose. Route the whole text into the review buffer and speak it; these have no meaningful focus state, so they need no upvalue probing.craft,refine,trade,inventory— already covered by theinventoryCursorPopuppatch from Phase 2a. But they also have a confirmation sub-window with a slider (modules/slider.lua, quantity selection). Left/Right adjusts it (adjustByFivemodifier for ×5) — needs the current value spoken on change.ingame_targeting_melee/ingame_targeting_throw— targeting cursor; reuse the Phase-2 cursor narration plus valid/invalid-target announcement (canThrowOnCell,level.recalculateInteractableArea).
Test: complete a full loop — new game → dialogue → inventory → craft a potion → journal — with the monitor off.
Phase 5 — Player status, polish, packaging
- Status key(s): HP (
player.actor.hp/maxhp), active statuses (views/statusGrid.lua,models/status), current action (player.currentSelectedAction), hotbar contents, level name + in-game time (level.getLevelDescriptionMessage()), player coordinates. - Popup announcements: wrap
popupStack.addand theingame.*Popupmethods so loot/recipe/lore/bestiary/tutorial popups are spoken and reviewable. - Interruptions:
ingame.checkInterruptions(hpLoss)fires on damage — speak an urgent, interrupting alert. - Mod settings screen: speech rate/verbosity, toggles for flavour text, wall tones, auto-speak. Either a new state or (cleaner, more decoupled) a plain
accessibility/config.jsonplus hotkeys to toggle at runtime with spoken confirmation. - Self-test: startup check that walks every state descriptor and every patch target, logging what resolved. This is the version-bump canary.
- Packaging: decide
.love/exe distribution vs. source drop, given the DLL constraint (Q1). Updatebuild.lua'signoreif needed. - Docs: a player-facing
accessibility/README.mdwith the full keymap.
Effort estimate
| Phase | Relative size | Risk |
|---|---|---|
| 0 Bootstrap | S | Low (DLL placement is the only unknown) |
| 1 Console | S | Low |
| 2 Tiles | M | Low — builds on existing examine mode |
| 3 Tracker | L | Medium — template classification is the grind |
| 4 Menus | L | Medium — 13 descriptors, upvalue fragility |
| 5 Polish | M | Low |
The pleasant surprise is that Phases 1, 2a and much of 4 lean on text the game already generates (console.print, getCursorMessage, getCursorPopupStats). We are mostly re-routing existing strings to speech, not writing a description engine.