# Game architecture — findings Target: *Sulphur Memories: Alchemist* 0.4.2 "sanctified", LÖVE 11.5, Lua 5.1/LuaJIT. ## 1. Top-level layout | Path | Role | |---|---| | `main.lua` | `love.load`, requires everything, runs `preprocess*()` for all templates, switches to `main_menu`. | | `states.lua` | Tiny state machine. **The single most important hook point.** | | `states/*.lua` (28 files) | One file per screen. Each returns a table of LÖVE callbacks. | | `modules/` | Reusable objects: `console`, `level`, `tile`, `item`, popups. | | `models/` | Game data + singletons: `world`, `player`, `keybindings`, `config`, `journal`, `actor`. | | `views/` | Pure drawing: `levelWindow`, `inventoryWindow`, `*CursorPopup`, `hotbar`, `hpbar`. | | `actions/`, `animations/`, `generators/`, `shaders/` | Gameplay verbs, tweens, map gen, GLSL. | Global variables (declared in `.luacheckrc`): `width`, `height`, `tileSize`, `UIScale`, `TEsound`, and all the font objects (`mediumfont`, `smallfont`, …). No module system beyond `require`, so **every module is a singleton in `package.loaded`** — this is what makes monkey-patching viable. ## 2. `states.lua` — the master hook ```lua function states.setup() for _,callback in pairs(love2dCallbacksList) do states[callback] = function(...) stateFiles[currentState][callback](unpack({...})) end love[callback] = love[callback] or states[callback] end end function states.switch(newState, params) ... end function states.getCurrent() ... end ``` Every LÖVE callback (`keypressed`, `draw`, `update`, `mousepressed`, …) funnels through `states[callback]`. State files are lazily `require`d into `stateFiles[stateName]` on first switch. Consequences: - Wrapping `states.switch` gives us a **screen-change event** for free (announce the new screen). - Wrapping `states.keypressed` gives us a **global hotkey layer** that can consume a key before the game sees it, in any screen. - Because state tables are plain Lua tables held in `stateFiles`, we can wrap individual state callbacks *after* they are loaded. ## 3. Console — already a single chokepoint `modules/console.lua` exposes exactly: ```lua console.print(message, colorTemplate) -- colorTemplate is "flavor" or nil (combat/positive/... are commented out) console.clear() console.draw() / console.update(dt) / console.prepare() / console.refreshOutput() ``` `buffer` is a file-local list of `{message, times, textHeight, lifetime, colorTemplate}`. Repeated identical messages **coalesce** into one entry with `times` incremented — the drawn text becomes `"msg (x3)"`. Lines expire (`lifetime = 3` s) and scroll away, so the visual buffer is not a usable history. **We need our own unbounded history list**, appended from a wrapper around `console.print`. Grepping shows `console.print` is called from `models/actor.lua`, `modules/tile/templates.lua`, `actions/*`, etc. — all of them go through this one function. Nothing else writes game text to the log. ## 4. The level model `modules/level.lua` (1807 lines) — `newLevel(params)` returns a closure table `self`. Relevant public surface: **Data** - `self.grid[x][y]` → array (stack) of tile objects, bottom-to-top. Top-most non-hidden tile is what the player "sees". - `self.actors` → flat array of actor objects. - `self.FoV[x][y]`, `self.rememberedFoV[x][y]` → booleans (currently visible / previously seen). - `self.xMax`, `self.yMax`, `self.cursorX`, `self.cursorY`, `self.playerActor`, `self.name`. **Queries we will use** | Function | Use for accessibility | |---|---| | `getCursorMessage()` | Returns the description string for the tile under the cursor (actor first, then top visible tile). **Already the exact text we want to speak.** | | `getLevelDescriptionMessage()` | Level name + in-game datetime. | | `getActorOnCoordinates(x,y,cond)` | Actor lookup. | | `getItemOnCoordinates(x,y)` | Item lookup. | | `getTile(x,y,templateId,fromBottom)` | Tile lookup. | | `getListByTag(tag)` | **Returns `{ {x,y}, … }` of all *seen* tiles matching a tag, already sorted by distance to player.** This is the seed of the object tracker. | | `getClosestActorOfType(x,y,templateId)`, `countActorsById`, `getAnyActorById` | Actor tracking. | | `isVisible(x,y)`, `isNearVisible(x,y)`, `isPassable(x,y,…)`, `isWaterTile(x,y)` | Tile probing for the surroundings explorer. | | `calculatePathFromPlayer(x,y)` | Returns a `{dx,dy}` step list — used for "how do I get there". | | `calculateStepFromPlayer(x,y)` | Single next step. | | `setCursor(x,y)` / `moveCursor(dx,dy,…)` / `resetCursor()` / `getCursorPosition()` | Drive the examine cursor programmatically. | | `playerInput(cmd,x,y,state)` / `bufferInput(cmd,array,state)` | Issue player actions. | **Important caveat:** `getListByTag` reads `templatesByTag`, and `modules/tile/templatesByTag.lua` currently defines **only one tag** (`exit`, containing `way_out`, `building_door`, `horse`, `ladder_up`, `ladder_down`). The object tracker will need its own, much richer category table — see `03-implementation-plan.md`. ## 5. Actors, tiles, items `models/actor.lua` — actor objects carry `templateId`, `x`, `y`, `name`, `nameObserved`, `nameAsSubject`, `nameAsOwner`, `description`, `hp`, `maxhp`, `visibilityAlpha`, `isPlayerControlled`, `parent` (the level). Template tables are large data files: `modules/tile/templates.lua` (6745 lines), `models/actor/templates.lua` (2887), `modules/item/templates.lua` (7152). They are preprocessed at load. Tiles carry flags we can categorise on: `blocksMovement`, `blocksVision`, `tryInteract`, `loot`, `unlootable`, `isHidden`, `description`, plus `templateId`. `modules/item/categories.lua` already defines 7 player-facing item categories (All / Potions / Ingredients / Raw materials / Consumables / Tools / Texts). Reuse these names verbatim for consistency. ## 6. Keyboard vs. mouse — better than expected **Every one of the 28 states already implements `keypressed`.** The game is a keyboard-first roguelike with mouse as an alternative, not a mouse-only game. Verified by reading `inventory`, `craft`, `ingame`, `ingame_observe`, `ingame_dialogue`, `main_menu`. `models/keybindings.lua` is a full rebindable-keys system with **contexts** (`ingame`, `aiming`, `menu`, `menuGlobal`, `menuHotkeys`, `hotbar`, with a `parents` inheritance chain), persisted to `keybindings.json`. Public API: ```lua keybindings.getCommand(scancode, filter) -- scancode -> command name keybindings.scancodeToDirection(scancode, filter) -- -> dx, dy keybindings.isCommandDown(command, filter) keybindings.getKeyForCommand(command, filter) keybindings.checkForConflicts(filter, newScancode) ``` So the real accessibility gap is **not** "can't reach it with the keyboard" — it is **"nothing tells you what is currently focused."** That reframes the whole job from *reimplementing UIs* to *narrating existing focus state*. Much smaller, much more mod-friendly. ### The narration chokepoint Menu-ish states converge on a `updateCursorPopup()`-style helper that ends in: ```lua cursorPopup.set(inventoryWindow.getCursorPopupStats()) -- craft, inventory, refine, trade cursorPopup.set(message, x, y, alignX, alignY) -- ingame_observe (levelCursorPopup) ``` - `views/inventoryCursorPopup.lua` → `set(lines, x, y, alignX, alignY)` where `lines` is `{ {text=…, icon=…}, … }`. - `views/levelCursorPopup.lua` → same signature, `lines` from `levelWindow.getCursorPopupStats()` which just forwards `levelModel.getCursorMessage()`. Patching `set` on these two view modules narrates the focused item in **`inventory`, `craft`, `refine`, `trade`, `ingame_observe`** at once. Five screens, two wrappers, zero game edits. ### The states that *don't* use cursorPopup `main_menu`, `journal`, `character`, `research`, `worldmap`, `settings_menu`, `config_menu`, `keybindings_menu`, `load_game`, `new_game`, `actions_menu`, `alchemy_table`, `ingame_dialogue`, `menu_text`, `narration`, `ingame_message`. These hold focus in **file-local variables** — typically `selectedOption`, plus `optionsList` / `options` / `activeTabIndex` / `selectedPage`. Example, `states/main_menu.lua`: ```lua local selectedOption local optionsList = { {id=1, title="Continue", isEnabledFunction=…, callback=…}, … } ``` File-locals are not reachable through the returned state table — **but they are upvalues of the state's own functions**, and `debug.getupvalue` reaches them. Verified working on this Lua: ``` 1 selectedOption 3 2 optionsList