fuzzyworld

explodorama / commit

Controller support: tile-first board play, dialogs, button pictures; Crate Pass (0.2.0 work)

ebcd0a8f1c9b846da6dab5d6e45165367caef641 · Pavel Safronov · 2026-11-01 19:04:58 -0800 · parents d94649bad7ef

Claude Code usage: this commit 99.0K out / 23.7M cached · cost unknown (no per-model trailer) · session 9 tally 564 in / 159.4K out / 30.1M cached · usage by commit and session

Controller support: tile-first board play, dialogs, button pictures; Crate Pass (0.2.0 work)

Play with a gamepad (standard mapping: PlayStation, Xbox, Nintendo), on by default:
- D-pad moves a cursor over the tiles you can act on (lit blue); a label lists what
  each face button would do there, with its button picture. Cross/Square/Triangle/
  Circle do Move/Bomb/Charge/Wait at once; impossible actions are greyed, ones that
  cost a life are marked red (the cursor tile turns red when every action does).
  LB puts the cursor away, RB/View is "What is that?", Start the menu, R3 resets
  the view, the right stick looks around, the left stick does nothing for now.
- Dialogs, the shop, the gem and charge offers and the death/cleared screens work
  by pad: face buttons act on the footer buttons, the d-pad moves a selector, LB/RB
  switch tabs, the right stick scrolls.
- Button pictures on the action bar (beside the icon) and in dialogs, per
  controller family; keyboard hints hidden while a pad is in use.
- Pure, tested modules: padMap, spatial, padTiles, padTarget, targetLabel, padGlyph,
  padDialogMap, reveal; DOM glue in gamepad.ts and padDialog.ts.
- Art: Kenney Crosshair Pack (cursor) and Input Prompts 1.5A (glyphs), CC0, in
  README Attribution and docs/art-provenance.md (audit: 138 verbatim files).

Wall Pass is now Crate Pass (display text only; internal key unchanged).

Records the decision that a screen shows the whole board when it fits (Q52, UI
rework, not built yet) in decisions.md and PLAN.md 19.2.

FW-Tokens-In: 564
FW-Tokens-Out: 159444
FW-Tokens-Cached: 30064919
FW-Session: 57b15695-9776-427b-a60b-bc52ce4e32b6
FW-Rtk-Saved: 3017946
FW-Rtk-Cmds: 10912
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
 .gitignore                                         |   3 +
 CHANGELOG.md                                       |   6 +
 PLAN.md                                            |  37 ++-
 README.md                                          |  45 ++-
 assets/kenney-crosshair/License.txt                |  19 ++
 assets/kenney-crosshair/crosshair041.png           | Bin 0 -> 336 bytes
 assets/kenney-input-prompts/License.txt            |  28 ++
 .../playstation_button_color_circle.png            | Bin 0 -> 527 bytes
 .../playstation_button_color_cross.png             | Bin 0 -> 509 bytes
 .../playstation_button_color_square.png            | Bin 0 -> 414 bytes
 .../playstation_button_color_triangle.png          | Bin 0 -> 512 bytes
 assets/kenney-input-prompts/switch_button_a.png    | Bin 0 -> 491 bytes
 assets/kenney-input-prompts/switch_button_b.png    | Bin 0 -> 465 bytes
 assets/kenney-input-prompts/switch_button_x.png    | Bin 0 -> 512 bytes
 assets/kenney-input-prompts/switch_button_y.png    | Bin 0 -> 475 bytes
 .../kenney-input-prompts/xbox_button_color_a.png   | Bin 0 -> 491 bytes
 .../kenney-input-prompts/xbox_button_color_b.png   | Bin 0 -> 465 bytes
 .../kenney-input-prompts/xbox_button_color_x.png   | Bin 0 -> 512 bytes
 .../kenney-input-prompts/xbox_button_color_y.png   | Bin 0 -> 475 bytes
 decisions.md                                       | 156 +++++++++-
 docs/art-provenance.md                             |  15 +-
 src/changelog.ts                                   |  19 ++
 src/main.ts                                        | 342 +++++++++++++++++++--
 src/render/board.ts                                |  24 +-
 src/render/camera.ts                               |  18 ++
 src/render/reveal.ts                               |  33 ++
 src/render/sprites.ts                              |  12 +-
 src/sim/demo.ts                                    |   2 +-
 src/sim/state.ts                                   |   4 +-
 src/style.css                                      | 167 ++++++++++
 src/ui/controls.ts                                 |  36 ++-
 src/ui/dialog.ts                                   |  10 +
 src/ui/gamepad.ts                                  | 115 +++++++
 src/ui/info.ts                                     |   2 +-
 src/ui/padDialog.ts                                | 240 +++++++++++++++
 src/ui/padDialogMap.ts                             |  96 ++++++
 src/ui/padGlyph.ts                                 |  34 ++
 src/ui/padMap.ts                                   | 192 ++++++++++++
 src/ui/padTarget.ts                                |  59 ++++
 src/ui/padTiles.ts                                 | 121 ++++++++
 src/ui/spatial.ts                                  |  48 +++
 src/ui/targetLabel.ts                              |  51 +++
 test/gamepad.test.ts                               | 212 +++++++++++++
 test/hazards.test.ts                               |   2 +-
 test/padDialog.test.ts                             |  88 ++++++
 test/padGlyph.test.ts                              |  43 +++
 test/padMap.test.ts                                | 338 ++++++++++++++++++++
 test/padTarget.test.ts                             |  59 ++++
 test/padTiles.test.ts                              | 133 ++++++++
 test/powerups.test.ts                              |   2 +-
 test/reveal.test.ts                                |  32 ++
 test/spatial.test.ts                               |  68 ++++
 test/targetLabel.test.ts                           | 124 ++++++++
 tools/compose-icons.sh                             |   2 +-
 54 files changed, 2972 insertions(+), 65 deletions(-)

diff --git a/.gitignore b/.gitignore
index 53e4a57..f87fd02 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,3 +11,6 @@ dist/
 
 # The balance lab's bundle (tools/balance/run.mjs).
 .balance/
+
+# Private progress view (local tooling output, never committed)
+STATUS.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 43b27e3..d9947c8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,12 @@ saved before the change is dropped and the next visit starts a new run.
 
 ### Changed
 
+- **Controller support, stage 1** (Q29, 2026-11-01): the pure core, not yet wired in: `ui/padMap.ts` (gamepad snapshot to intents, with repeat, stick hysteresis, deadzone, controller family) and `ui/spatial.ts` (directional focus), with tests.
+- **Controller support, stage 2** (Q29, 2026-11-01): board play with a gamepad, wired in: `ui/gamepad.ts` (reads the first standard pad, routes intents to the game), the demo's scenes can be switched with the d-pad, and a face button, LB or Start ends it. Board play is "tile first" (below): LB puts the cursor away (or what is left picked), RB and View are "What is that?".
+- **Controller support, stage 3: dialogs and the banner** (Q29, 2026-11-01): a whole run is playable by pad. A dialog's footer buttons sit on the face buttons with their pictures (primary on Cross, the others on Triangle then Square, the one that leaves on Circle, which closes any dismissable dialog); where they do not fit, or the body has controls, a d-pad selector (`body.pad .pad-sel`, yellow outline) moves over the body's elements and the footer and Cross presses the focused one. Gem offer and shop cards: moving selects (and plays the clip), Cross takes (the shop: Buy). LB/RB switch almanac tabs, the right stick scrolls the body, the death/cleared/victory banner's buttons are on Cross, Triangle, Square, and the seed field is skipped. `ui/padDialogMap.ts` (pure, tested) and `ui/padDialog.ts` (DOM glue, watches the dialog with a MutationObserver so rebuilt bodies keep the selector); `dialog.ts` gains `dismissDialog()` and a `dialogopen` event.
+- **Controller board play, tile first** (2026-11-01): after trying the aim / step / cycle models, a cursor tile: the d-pad moves a dashed square between the actionable tiles (all lit blue; `ui/padTiles.ts`, pure), a line over the action bar lists what the tile offers per button with a faded ghost of the result, and the face buttons (Cross Move, Square Bomb, Triangle Charge, Circle Wait on your own tile) do it at once. Buttons that cannot be used there are greyed on the action bar, ones that would cost a life get a red outline and a warning mark (`isDeadly`). LB puts the cursor away, RB and View are "What is that?". The left stick does nothing (`LEFT_STICK_ENABLED = false`, code kept); a diagonal d-pad release no longer fires a step. Removed: `padCycle.ts`, the cycle and step aims. `View.lit` and `View.ghost` in `render/board.ts`.
+- **Controller glyphs on the action bar** (Q45, 2026-11-01): while a controller is in use, Move/Go, Bomb, Charge and Wait show the pad's own button (PlayStation Cross, Square, Triangle, Circle in the colour variants; Xbox and other pads A, X, Y, B; Nintendo pads their own letters by position, so the bottom button shows B; Kenney Input Prompts) beside the icon, in place of the keyboard hint, and go back to the hint on a key or a click. The cursor's label shows the same pictures before each line, and a cursor tile where everything possible costs a life is drawn red (`cursorDeadly` in `ui/padTiles.ts`). `ui/padGlyph.ts`; `padFamily` knows 'nintendo'.
+- **Wall Pass renamed Crate Pass** (user request, 2026-11-01): it walks through crates, not walls. Display text only; the internal key stays `wallPass` and the sprite stays `powerup_wall_pass`.
 - **The in-game menu no longer carries the Kenney credit** (user request, 2026-10-31); it stays in the README.
 - **A demo scene for rubble, and a timeline on every scene** (user request, 2026-10-31): the new `rubble` scene starts with a pillar already cracked and plays until the ceiling falls, then two turns of waiting to see the rubble (`SceneDef.maxSteps` and `tail`; the Acid scene ended when the pillar cracked, which is why the Almanac's Rubble clip never showed any). A scene now records its `moment`. A bar under the demo's caption and under each dialog's clip (`ui/timeline.ts`) fills a turn at a time, with a mark at the moment.
 - **Two copies of the game** (user request, 2026-10-31): the released one (built from a git tag, `/explodorama/`) and a **next** copy built from `main` (`/explodorama-next/`, deployed with `fw deploy --as explodorama-next`; tab title "EXPLODORAMA! (next)"). Each has its own saves, options, scores and last-played version (`explodorama-next.*` keys), so a rules change on `main` never drops a released run. The released game's menu has a **Try it early** link and its Changes dialog a **Coming next** teaser (read from the next copy's `next.json`); the next copy's Changes dialog lists **Not released yet**. A run finished on the next copy is marked **Early adopter** on its board and counted in one shared key (`explodorama.earlyRuns`) the released copy shows in its menu. Developer mode is separate and works on either.
diff --git a/PLAN.md b/PLAN.md
index fe25ca5..6e89685 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -215,7 +215,7 @@ dialog saying what it does (`chargeInfo`). Icons are the user's choices (§13).
 
 | Charge | Rule |
 |---|---|
-| **Pierce** | The next `BOMB_BOOST` (3, user's choice) bombs' flame passes **through** soft blocks. Icon: the Wall Pass icon. |
+| **Pierce** | The next `BOMB_BOOST` (3, user's choice) bombs' flame passes **through** soft blocks. Icon: the Crate Pass icon. |
 | **Power** | The next 3 bombs' flame reaches to the walls (soft blocks still stop it). Icon: the Fire+ icon. |
 | **Nuke** | *New.* The next 3 bombs also burn every tile within `NUKE_RANGE` (2, user's choice over 3) steps, Manhattan, through anything; walls stand. Escapable: a fuse of 3 gives 3 steps. Icon: bomb + skull badge. |
 | ~~**Diagonal**~~ | *Became a power-up the same day* (user request, §3.2). |
@@ -262,7 +262,7 @@ The NES original had exactly 8 power-ups, one hidden per level under a soft bloc
 | Bombs | **Bomb +1** (capacity) | yes |
 | Flames | **Fire +1** | yes |
 | Speed (roller skates) | **Run** (called Skates until 2026-10-22) | yes |
-| Wallpass | **Wall Pass** — walk through soft blocks | no — always lost on death (*kept since 2026-10-23, see §9*) |
+| Wallpass | **Crate Pass** — walk through soft blocks | no — always lost on death (*kept since 2026-10-23, see §9*) |
 | Detonator | **Detonator → unlocks Detonate** | no |
 | Bombpass | **Bomb Pass** — walk over bombs | no |
 | Flamepass | **Flame Pass** — immune to flame (tune later if too strong); walk through it (with a Star too, since 2026-10-22) | no |
@@ -295,7 +295,7 @@ NES rules kept (they create the classic tension):
   *Replaced 2026-10-23:* the gem's choice dialog opens 0.45 s after the pickup instead, once the
   player has arrived and the gem has risen away.
 - *Built (2026-10-20):* the pickup is under any crate except the exit's. Draw weights (to tune):
-  Bomb+ 20, Fire+ 20, Fuse+ 6, Coin bag 12 (10 coins), Heart 4, Wall Pass 8, Bomb Pass 8,
+  Bomb+ 20, Fire+ 20, Fuse+ 6, Coin bag 12 (10 coins), Heart 4, Crate Pass 8, Bomb Pass 8,
   Flame Pass 5, Detonator 9, Star 8 (8 turns), and since 2026-10-22 Kick 8, Throw 8, Teleport 3;
   since 2026-10-23 Run 10, and they weight the gem's offer (`POWER_UP_WEIGHTS` in `sim/turn.ts`). Sprites: §13 (since 2026-10-22, a bomb or the astronaut with a
   modifier badge for Bomb+, Fire+, Fuse+, Run, Kick and Throw; gems before). A death keeps stats, every power-up and pass (since 2026-10-23, user request; the planned Keep / Fresh choice was dropped
@@ -320,7 +320,7 @@ The user wants to start thinking about making power-ups **exclusive**: picking o
   Open: do the stat gems (Bomb+, Fire+, Fuse+) also take slots (e.g. "Bomb+ ×2" in one slot,
   stacking within it), or stay uncapped as in the NES? Instant ones (Heart, Coin bag, Star)
   would not take a slot.
-- **Synergies and trade-offs** make choices interesting: e.g. Bomb Pass + Kick, Wall Pass +
+- **Synergies and trade-offs** make choices interesting: e.g. Bomb Pass + Kick, Crate Pass +
   Pierce, Flame Pass + Line bombs. A power-up could also have a downside (a "cursed" version).
 
 **Inconsistencies with the current plan, to resolve before building** (CLAUDE.md asks to raise
@@ -693,7 +693,7 @@ Sprite assignments (✅ = drawn now; the rest are copied and waiting for their f
 | ✅ decoy | `character_green_front`, faded |
 | ✅ auto-move | The Go button keeps Move's walk icon, with a green border (until 2026-10-23 it turned into `flag_green_a`). On the board, since 2026-10-23 (user request), the whole way is highlighted blue, destination included, with no flag on the destination and no dots. |
 | ✅ intent arrows | drawn red lines with a head, not sprites |
-| ✅ power-ups | badged (`assets/composed/`): `powerup_bomb_plus` Bomb+ (bomb + plus), `powerup_fire_plus` Fire+ (bomb + larger), `powerup_fuse_plus` Fuse+ (bomb + timer), `powerup_run` Run (the Move figure + fast-forward), `powerup_kick` Kick (bomb + arrow right), `powerup_throw` Throw (bomb + arrow up-right), `powerup_teleport` Teleport (Move figure + outbox), `powerup_detonator` Detonator (bomb + !), `powerup_wall_pass` Wall Pass (crate + ✕), `powerup_bomb_pass` Bomb Pass (bomb + shield), `powerup_flame_pass` Flame Pass (fireball + shield); `star` Star (and a gold ring), `heart` Heart, `hud_coin` Coin bag; in the gem's choice dialog and the almanac. On the board, since 2026-10-23, every level's pickup is a gem, `gem_blue` / `gem_green` / `gem_red` / `gem_yellow` (random colour), with no glow (the yellow glow was removed on 2026-10-23, user request). (Replaced and removed on 2026-10-22: the gems as fixed power-ups, `spring_out`, `sign_right`, `bush`, `ladder_top`, `torch_off`, `window`.) HUD: `icon_fuse` fuse (the Fuse +1 badge's timer, ink, white on the bar; the torch `torch_on_a` until 2026-10-22), small pass icons. |
+| ✅ power-ups | badged (`assets/composed/`): `powerup_bomb_plus` Bomb+ (bomb + plus), `powerup_fire_plus` Fire+ (bomb + larger), `powerup_fuse_plus` Fuse+ (bomb + timer), `powerup_run` Run (the Move figure + fast-forward), `powerup_kick` Kick (bomb + arrow right), `powerup_throw` Throw (bomb + arrow up-right), `powerup_teleport` Teleport (Move figure + outbox), `powerup_detonator` Detonator (bomb + !), `powerup_wall_pass` Crate Pass (crate + ✕), `powerup_bomb_pass` Bomb Pass (bomb + shield), `powerup_flame_pass` Flame Pass (fireball + shield); `star` Star (and a gold ring), `heart` Heart, `hud_coin` Coin bag; in the gem's choice dialog and the almanac. On the board, since 2026-10-23, every level's pickup is a gem, `gem_blue` / `gem_green` / `gem_red` / `gem_yellow` (random colour), with no glow (the yellow glow was removed on 2026-10-23, user request). (Replaced and removed on 2026-10-22: the gems as fixed power-ups, `spring_out`, `sign_right`, `bush`, `ladder_top`, `torch_off`, `window`.) HUD: `icon_fuse` fuse (the Fuse +1 badge's timer, ink, white on the bar; the torch `torch_on_a` until 2026-10-22), small pass icons. |
 | ✅ Blob | `slime_normal_rest` (nowhere to go), `_walk_a` / `_walk_b` alternating; mirrored when heading right. `_flat` spare (the death animation flips `_rest` over instead, the same for every enemy). |
 | ✅ Seeker | `fly_rest`, `fly_a`, `fly_b`, as the Blob |
 | ✅ Drifter | `saw_rest`, `saw_a`, `saw_b`; spins while moving |
@@ -962,8 +962,18 @@ src/
     sprites.ts           which Kenney sprites the board loads (§13)
     effects.ts           turns a turn's events into short animations (appear, slide, swell,
                          go away); no input lock — see decisions.md 2026-10-22
-    camera.ts
+    camera.ts            the view: follows the player, drag/look pan, `revealTile` keeps the controller's target on screen
+    reveal.ts            controller target: how far to move the view to keep a tile on screen (pure)
   ui/                  DOM: action bar, HUD, shop, death choice, certificate, About
+    padMap.ts            controller: gamepad snapshot to intents (pure; decisions.md 2026-11-01)
+    spatial.ts           controller: which element to focus next in a direction (pure)
+    gamepad.ts           controller: reads the pad each frame, routes intents to main.ts (stage 2)
+    padTarget.ts         controller: the first target, and jumping between targets (pure)
+    padGlyph.ts          controller: which button picture goes on each action button (pure)
+    targetLabel.ts       controller: the one-line label of what confirming does (pure)
+    padTiles.ts          controller: actionable tiles, what each button does on a tile, cursor moves (pure)
+    padDialogMap.ts      controller in dialogs: footer buttons to face buttons, which action a face button does (pure)
+    padDialog.ts         controller in dialogs and the banner: DOM glue (selector, pictures, tabs, scroll; stage 3)
   audio.ts             Kenney pack sound effects (§13); ZzFX for any it lacks
   persist.ts           localStorage save/load, export/import
   main.ts
@@ -1075,7 +1085,7 @@ tools/                 compose-icons.sh (composed sprites and the app icons), sc
     while carried, its 3 hits being the charge's uses.
   - **Teleport → a charge of 3 jumps**, as ICBM has 3 launches, with its reach as now (Run adds a
     step; Diagonal makes it a square).
-  - **Wall Pass and Bomb Pass stay power-ups for now**: the user wants to playtest them more (the lab
+  - **Crate Pass and Bomb Pass stay power-ups for now**: the user wants to playtest them more (the lab
     finds them strong but in line with Run or Kick).
   - Then run the lab's `powers` experiment again on the same seeds, and the 3s are first guesses.
 - **Decided (user, 2026-10-25): on a death, the carried charge drops where the player fell**, with
@@ -1115,7 +1125,7 @@ in each part). *Agreed 2026-10-24; order: the balance lab (19.1) first.* Nothing
 
 ### 19.1 A balance lab: measuring what an addition does to the game
 
-**The question:** some power-ups feel overpowered (Flame Pass, Wall Pass, Bomb Pass), but by how
+**The question:** some power-ups feel overpowered (Flame Pass, Crate Pass, Bomb Pass), but by how
 much? And would they be fairer as charges? Today that is judged by feel.
 
 **Why not search the whole game.** The sim is pure and deterministic (`step()`), so any game can be
@@ -1152,14 +1162,14 @@ well *locally*, over the next few turns, and that is how it is used below.
     (the same seed and turn without it would have died: a counterfactual replay).
 - **Structural measures, no bot needed:** on many random boards, from the player's tile, count what
   a power-up changes outright. Examples: the share of bomb placements with an escape route
-  (Flame Pass makes it 100%, which shows why it feels free); tiles reachable in 3 turns (Wall Pass,
+  (Flame Pass makes it 100%, which shows why it feels free); tiles reachable in 3 turns (Crate Pass,
   Bomb Pass, Run, Teleport); blast reach (Fire +1, Power). Deterministic and quick, a good first
   report before the bots exist.
 - **The report:** a table per experiment — each power-up's effect on levels survived and lives lost,
   with a confidence interval, sorted strongest first — published as an HTML page (an artifact), and
   kept in `docs/balance/` so a change can be checked against the last run.
 - **Power-up or charge:** the rules get variant switches the lab can turn on (for example
-  "Flame Pass as a charge of 5 turns", "Wall Pass as 3 uses"), so the same experiment compares
+  "Flame Pass as a charge of 5 turns", "Crate Pass as 3 uses"), so the same experiment compares
   the versions before the game commits to one.
 
 *Decided (user, 2026-10-24):* **the full version, with bots** (the structural measures come along
@@ -1230,6 +1240,9 @@ rather than the game; to look at. *Decided (user, 2026-10-25): no squeezing is t
 
 ### 19.2 Bigger boards for later levels
 
+> **Superseded in part (2026-11-01):** the fixed 9 × 13 view is to be replaced by showing the whole board when the screen has room (decisions.md, "A screen shows the whole board when it fits"). Not built yet.
+
+
 **Today:** every level is 9 × 11, the whole board on screen at once (a choice made 2026-10-23). It
 suits the first levels; later ones feel small. *This changes that choice.*
 
@@ -1382,7 +1395,7 @@ part of the ceiling down — a way to reshape the board, with a risk.
 - **A broken pillar brings the roof down:** the 8 tiles around it show falling dust and a
   countdown of **4 turns** (*user, 2026-10-24*; like a bomb's timer, and in the danger overlay). Then rubble falls:
   anything still there is crushed — the player, enemies (a trap to set), drops, bombs — and the
-  tiles become **rubble: impassable for good** (no blast, Pierce or Wall Pass gets through), so
+  tiles become **rubble: impassable for good** (no blast, Pierce or Crate Pass gets through), so
   the board can't be opened up into one big room. The pillar's own tile becomes floor.
   *Decided (user, 2026-10-24).*
 - **Two new ways to die** (*user, 2026-10-24*):
@@ -1436,7 +1449,7 @@ many levels as you can, each harder than the last.
 - **Its own high scores:** a separate board, ranked by levels cleared, then by fewer turns; each
   entry shows its loadout and seed.
 
-- **No passes in a loadout** (*decided, user, 2026-10-24*): Flame Pass, Wall Pass and Bomb Pass
+- **No passes in a loadout** (*decided, user, 2026-10-24*): Flame Pass, Crate Pass and Bomb Pass
   never come in a gauntlet start.
 
 **The passes in the whole game** (*user, 2026-10-24*): they should either **leave the power-ups**
diff --git a/README.md b/README.md
index 9778047..07012be 100644
--- a/README.md
+++ b/README.md
@@ -223,7 +223,7 @@ piled up). The top bar shows it too.
 
 | Charge | Icon | What it does |
 |---|---|---|
-| **Pierce** | the Wall Pass icon (crate with ✕) | **3 bombs** whose flame goes **through** crates, burning every one in reach. The charge button puts one down. |
+| **Pierce** | the Crate Pass icon (crate with ✕) | **3 bombs** whose flame goes **through** crates, burning every one in reach. The charge button puts one down. |
 | **Power** | the Fire +1 icon | **3 bombs** whose flame reaches all the way to the walls (crates still stop it). |
 | **Nuke** | bomb with a skull badge | **3 bombs** that also burn **every tile within 2 steps** (across plus down), through crates and round corners; walls still stand. It can catch you too: get 3 steps away. |
 | **Shield** | the Move figure in a ring | **Works by itself while you carry it**, level after level: a ring round you until something hits you. It absorbs that hit (flame, enemy or spikes); under a falling ceiling it **pushes you out** to an empty tile next to you, if there is one, and anything else that hits you that turn. Then it is used up and the charge slot is empty. |
@@ -374,7 +374,7 @@ go: **Blast 2 → 4 (max 5)**.
 | **RPG** | bomb with a forward-arrow badge | **Bomb** can also fire a bomb in a straight line (or **F**, then a direction): it flies until the next tile is a wall, crate, bomb or enemy and lands just before it, then counts down as usual. |
 | **Detonator** | bomb with a ! badge | Your bombs pulse with a red glow: **tap one** and it goes off this turn (or **X**, then tap it). No button. |
 | **Proximity alert** | bomb with a warning-sign badge | Your bombs go off the turn an enemy is **right next to** one — one that walks up, or one already there when you place it — wherever you are standing. (Until 2026-10-25, anywhere in its reach.) The danger overlay shows it ahead, from the enemies' arrows. |
-| **Wall Pass** | crate with an ✕ badge | Walk through crates. |
+| **Crate Pass** | crate with an ✕ badge | Walk through crates. |
 | **Bomb Pass** | bomb with a shield badge | Walk over bombs. |
 | **Star** | star | Nothing hurts you for 8 turns (a gold ring; the top bar counts down); you can walk through flame meanwhile. |
 | **Heart** | heart | One more life. |
@@ -491,11 +491,44 @@ what it does.
 | Object | From level | What it does |
 |---|---|---|
 | **Bush** | 2 | Walk into it. **It hides you:** enemies chasing you head for where they last saw you instead. It hides drops lying in it too; an enemy in one shows faint, arrows and all, so nothing that can hurt you is hidden. Flame burns it, and the bushes touching it **smoulder** (glowing) and catch fire the next turn: a slow fire front. |
-| **Metal crate** | 3 | Grey. Like a pillar, but anywhere: nothing breaks it, nothing gets through (not a Pierce bomb's flame, not Wall Pass); a Nuke's blast goes round it. Placed only where every tile stays reachable. |
+| **Metal crate** | 3 | Grey. Like a pillar, but anywhere: nothing breaks it, nothing gets through (not a Pierce bomb's flame, not Crate Pass); a Nuke's blast goes round it. Placed only where every tile stays reachable. |
 | **Oil** | 5 | A dark, glossy spill, safe to walk on — until flame touches any part of it: then **the whole spill burns at once** (and lingers a turn, like flame), setting off any bomb lying in it. It is also **slippery**: step (or run) onto oil and you **slide straight on** over the spill, stopping on its last tile in that line (or short of a bomb or an enemy), picking up what lies on the way; if the slide ends in flame, you burn. A diagonal step doesn't slide. Every enemy but the flying Ghost and Wisp slips too, and its arrows show where the slide ends. |
 | **Spikes** | 8 | Up one turn, down the next. Up, they cost a life to whoever stands on them (they show in the danger overlay when up). Enemies never step on them. |
 | **Oil barrel** | 11 | A crate with a warning sign: it blocks like a crate. Flame **bursts** it: oil spills on it and the floor round it, and the **next turn** it explodes with a blast of 2, lighting the spill. It isn't your bomb: it doesn't count to your limit, and the Detonator can't set it off. |
 
+### Controller
+
+A gamepad with the standard layout (a PlayStation, Xbox or Nintendo pad, over USB or Bluetooth, in a browser)
+plays the board **tile first**. Press the **d-pad** and a dashed square (the cursor) appears on the
+nearest tile in that direction that you can do something on (or on your own tile if there is none);
+every such tile lights up blue, and the d-pad moves the square between them. A line over the action
+bar lists what the tile offers, one line per button ("Cross  Move to this tile", "Square  Place a
+bomb on this tile", "Circle  Wait a turn"), and a faded ghost shows the result of the first one. The
+four face buttons are the action bar's: **Move/Go** (Cross, A), **Bomb** (Square, X), **Charge**
+(Triangle, Y) and **Wait** (Circle, B, only on your own tile), with their pictures on the buttons
+while the pad is in use. Pressing one **does that action on the cursor's tile at once**. On the
+action bar, the buttons that cannot be used on that tile are greyed (pressing one just shakes it),
+and one that would cost a life gets a red outline and a warning mark; it is still done if you press
+it. With no cursor showing, a face button only shows the cursor on your own tile. **LB** puts the
+cursor away. **RB or View** is "What is that?": it aims at what can be explained, the d-pad jumps
+between those tiles, and Cross (or RB again) explains the tile; LB leaves it. **Start** opens the
+menu (and closes it), **R3** brings the view back to you, and the **right stick** looks around the
+board. The left stick does nothing for now. A key or the mouse hands the board back to the keyboard
+and mouse.
+
+Dialogs and the screens after a death, a level or a run are played by pad too, so a whole run needs
+no keyboard or mouse. A dialog's buttons sit on the face buttons, with their pictures on them: the
+main one (Choose in a gem's choice, Walk there, Take in a charge swap) on **Cross**, the others, left to right, on
+**Triangle** then **Square**, and the one that leaves (Close, Cancel, Back, Got it) on **Circle**,
+which closes any dialog that can be closed. Where that does not fit (the menu, the almanac, New run,
+the changes list) a **d-pad** selector with a yellow outline moves over the buttons, links and
+switches, and Cross presses what it is on; it starts on the main button (Resume in the menu, Start
+in New run), and Circle still presses the one that leaves. In a gem's choice or the shop, the d-pad moves over the
+cards (which selects them and plays their clip) and Cross takes the selected one (in the shop, Cross
+buys and Triangle and Square are Restock and Next level). **LB and RB** (and View) switch the almanac's tabs,
+and the **right stick** scrolls a long dialog. The seed field in New run is skipped (type it with a
+keyboard). The banner's buttons are on Cross, Triangle and Square the same way.
+
 ## Running it
 
 ```sh
@@ -597,7 +630,9 @@ Art: [New Platformer Pack](https://kenney.nl/assets/new-platformer-pack) 1.1,
 [UI Pack](https://kenney.nl/assets/ui-pack) 2.0 (with its Kenney Future font) and
 [UI Pack: RPG Expansion](https://kenney.nl/assets/ui-pack-rpg-expansion),
 [Game Icons](https://kenney.nl/assets/game-icons) and
-[Board Game Icons](https://kenney.nl/assets/board-game-icons), all by
+[Board Game Icons](https://kenney.nl/assets/board-game-icons) and
+[Crosshair Pack](https://kenney.nl/assets/crosshair-pack) (the controller's target square) and
+[Input Prompts](https://kenney.nl/assets/input-prompts) (the controller buttons on the action bar, in dialogs and on the banner), all by
 [Kenney](https://www.kenney.nl), under [CC0](http://creativecommons.org/publicdomain/zero/1.0/)
 (public domain), the [Splat Pack](https://kenney.nl/assets/splat-pack) (the oil spills) and
 [Foliage Sprites](https://kenney.nl/assets/foliage-sprites) (the bushes). Kenney doesn't require credit, but the pack was chosen deliberately and the game
@@ -606,6 +641,8 @@ looks the way it does because of it, so it is credited here (it was in the game'
 The files used are copied, unchanged and under their original file names, into
 [`assets/kenney/`](assets/kenney/), [`assets/kenney-icons/`](assets/kenney-icons/) and
 [`assets/kenney-board-icons/`](assets/kenney-board-icons/) (Game Icons and Board Game Icons),
+[`assets/kenney-crosshair/`](assets/kenney-crosshair/) (one dashed square),
+[`assets/kenney-input-prompts/`](assets/kenney-input-prompts/) (twelve button pictures),
 [`assets/kenney-ui/`](assets/kenney-ui/) (keeping the pack's
 colour folders), [`assets/kenney-ui-rpg/`](assets/kenney-ui-rpg/) and
 [`assets/kenney-splat/`](assets/kenney-splat/) (a few of the Splat Pack's splats, scaled down to
diff --git a/assets/kenney-crosshair/License.txt b/assets/kenney-crosshair/License.txt
new file mode 100644
index 0000000..9912528
--- /dev/null
+++ b/assets/kenney-crosshair/License.txt
@@ -0,0 +1,19 @@
+	

+

+	Crosshair Pack

+

+	by Kenney Vleugels (Kenney.nl)

+

+			------------------------------

+

+	License (Creative Commons Zero, CC0)

+	http://creativecommons.org/publicdomain/zero/1.0/

+

+	You may use these assets in personal and commercial projects.

+	Credit (Kenney or www.kenney.nl) would be nice but is not mandatory.

+

+			------------------------------

+

+	Donate:   http://support.kenney.nl

+

+	Follow on Twitter for updates: @KenneyNL (www.twitter.com/kenneynl)
\ No newline at end of file
diff --git a/assets/kenney-crosshair/crosshair041.png b/assets/kenney-crosshair/crosshair041.png
new file mode 100644
index 0000000..fc3db6f
Binary files /dev/null and b/assets/kenney-crosshair/crosshair041.png differ
diff --git a/assets/kenney-input-prompts/License.txt b/assets/kenney-input-prompts/License.txt
new file mode 100644
index 0000000..e618524
--- /dev/null
+++ b/assets/kenney-input-prompts/License.txt
@@ -0,0 +1,28 @@
+	

+

+	Input Prompts (1.5A)

+

+	Created/distributed by Kenney (www.kenney.nl)

+	Creation date: 11-07-2026

+

+			------------------------------

+

+	License: (Creative Commons Zero, CC0)

+	http://creativecommons.org/publicdomain/zero/1.0/

+

+	You can use this content for personal, educational, and commercial purposes.

+

+	Support by crediting 'Kenney' or 'www.kenney.nl' (this is not a requirement)

+

+			------------------------------

+

+	• Website : www.kenney.nl

+	• Donate  : www.kenney.nl/donate

+

+	• Patreon : patreon.com/kenney

+	

+	Follow on social media for updates:

+

+	• Twitter:	twitter.com/KenneyNL

+	• Instagram: 	instagram.com/kenney_nl

+	• Mastodon:	mastodon.gamedev.place/@kenney
\ No newline at end of file
diff --git a/assets/kenney-input-prompts/playstation_button_color_circle.png b/assets/kenney-input-prompts/playstation_button_color_circle.png
new file mode 100644
index 0000000..02a04ae
Binary files /dev/null and b/assets/kenney-input-prompts/playstation_button_color_circle.png differ
diff --git a/assets/kenney-input-prompts/playstation_button_color_cross.png b/assets/kenney-input-prompts/playstation_button_color_cross.png
new file mode 100644
index 0000000..ccad428
Binary files /dev/null and b/assets/kenney-input-prompts/playstation_button_color_cross.png differ
diff --git a/assets/kenney-input-prompts/playstation_button_color_square.png b/assets/kenney-input-prompts/playstation_button_color_square.png
new file mode 100644
index 0000000..cb71652
Binary files /dev/null and b/assets/kenney-input-prompts/playstation_button_color_square.png differ
diff --git a/assets/kenney-input-prompts/playstation_button_color_triangle.png b/assets/kenney-input-prompts/playstation_button_color_triangle.png
new file mode 100644
index 0000000..8f10c35
Binary files /dev/null and b/assets/kenney-input-prompts/playstation_button_color_triangle.png differ
diff --git a/assets/kenney-input-prompts/switch_button_a.png b/assets/kenney-input-prompts/switch_button_a.png
new file mode 100644
index 0000000..9202083
Binary files /dev/null and b/assets/kenney-input-prompts/switch_button_a.png differ
diff --git a/assets/kenney-input-prompts/switch_button_b.png b/assets/kenney-input-prompts/switch_button_b.png
new file mode 100644
index 0000000..acbc022
Binary files /dev/null and b/assets/kenney-input-prompts/switch_button_b.png differ
diff --git a/assets/kenney-input-prompts/switch_button_x.png b/assets/kenney-input-prompts/switch_button_x.png
new file mode 100644
index 0000000..a7949a5
Binary files /dev/null and b/assets/kenney-input-prompts/switch_button_x.png differ
diff --git a/assets/kenney-input-prompts/switch_button_y.png b/assets/kenney-input-prompts/switch_button_y.png
new file mode 100644
index 0000000..1f854de
Binary files /dev/null and b/assets/kenney-input-prompts/switch_button_y.png differ
diff --git a/assets/kenney-input-prompts/xbox_button_color_a.png b/assets/kenney-input-prompts/xbox_button_color_a.png
new file mode 100644
index 0000000..0cc14ca
Binary files /dev/null and b/assets/kenney-input-prompts/xbox_button_color_a.png differ
diff --git a/assets/kenney-input-prompts/xbox_button_color_b.png b/assets/kenney-input-prompts/xbox_button_color_b.png
new file mode 100644
index 0000000..126ea29
Binary files /dev/null and b/assets/kenney-input-prompts/xbox_button_color_b.png differ
diff --git a/assets/kenney-input-prompts/xbox_button_color_x.png b/assets/kenney-input-prompts/xbox_button_color_x.png
new file mode 100644
index 0000000..cf4edb1
Binary files /dev/null and b/assets/kenney-input-prompts/xbox_button_color_x.png differ
diff --git a/assets/kenney-input-prompts/xbox_button_color_y.png b/assets/kenney-input-prompts/xbox_button_color_y.png
new file mode 100644
index 0000000..7f8dfb6
Binary files /dev/null and b/assets/kenney-input-prompts/xbox_button_color_y.png differ
diff --git a/decisions.md b/decisions.md
index 2949886..c9f27f8 100644
--- a/decisions.md
+++ b/decisions.md
@@ -161,7 +161,7 @@ fix, two parts:
 ### 2026-10-20 — M6 part 1: power-ups, the ending, mid-run save
 
 - **Interim death rule until Keep / Fresh:** a death keeps stats, Skates, coins and charges and
-  loses Wall Pass, Bomb Pass, Flame Pass, the Detonator and a Star — the NES rule plus "Keep".
+  loses Crate Pass, Bomb Pass, Flame Pass, the Detonator and a Star — the NES rule plus "Keep".
   Keep / Fresh (PLAN.md §9) will replace the stats/Skates part.
 - **The power-up is under any crate but the exit's**, including near the start, so an early
   Bomb+ or Fire+ is possible. **Skates are on level 1 or 2**, chosen by the seed, and not in the
@@ -486,7 +486,7 @@ icon now uses the new figure too (made by me, for consistency).
 ### 2026-10-22 — Badged icons for the rest of the abilities and passes
 
 User request, symbols named by the user: Teleport = the Move figure + an outbox (Game Icons
-`export`), Detonator = bomb + `exclamation`, Wall Pass = crate (`block_planks`) + `cross`, Bomb
+`export`), Detonator = bomb + `exclamation`, Crate Pass = crate (`block_planks`) + `cross`, Bomb
 Pass = bomb + shield (Board Game Icons `shield`), Flame Pass = fireball + shield. Built by
 `tools/compose-icons.sh` like the first six. `bush`, `ladder_top`, `torch_off` and `window` are
 no longer used and were removed; `lever` stays (the "Run settings" info icon). Star, Heart and
@@ -669,7 +669,7 @@ picked up. The new set and icons are the user's; the open points were the user's
 
 | Charge | Effect | Icon |
 |---|---|---|
-| Pierce | next 3 bombs' flame goes through crates | Wall Pass icon |
+| Pierce | next 3 bombs' flame goes through crates | Crate Pass icon |
 | Power | next 3 bombs reach to the walls | Fire+ icon |
 | Nuke (new) | next 3 bombs also burn every tile within 2 steps (Manhattan), through anything but walls | bomb + Board Game Icons skull badge |
 | Diagonal | diagonal steps for the rest of this level and all of the next ("round" = level) | Move figure + diagonal-arrows badge |
@@ -846,11 +846,11 @@ User request: the demo's scenes should show every feature of the game except the
 exit, and one more scene on the enemies' move arrows ("a good idea to watch those"). The scenes
 are now a table (`SCENES` in `sim/demo.ts`), each with its enemies, what the player starts with
 (a power-up held, or a charge lying two steps away to pick up first) and its moment — e.g. Kick:
-a kick and then a kill; Wall Pass: a step onto a crate; Nuke: a kill off the bomb's row and
+a kick and then a kill; Crate Pass: a step onto a crate; Nuke: a kill off the bomb's row and
 column; arrows: an enemy killed on the tile it stepped onto, the bomb waiting there. 27 scenes:
 kill, arrows, escape, chain, lingering flame, the enemy types, drops, the gem (uncovered and
 taken; its choice dialog isn't shown), Bomb +1, Fire +1, Fuse +1, Run, Kick, Throw, Teleport,
-Diagonal, the Detonator, Wall Pass, Bomb Pass, Flame Pass, Star, Pierce, Power, Nuke, Shield,
+Diagonal, the Detonator, Crate Pass, Bomb Pass, Flame Pass, Star, Pierce, Power, Nuke, Shield,
 Freeze and Decoy. Heart and Coin bag are left out: they only change numbers in the top bar,
 which the demo hides. The random play now also uses Run, Kick, Throw, Teleport and Diagonal
 when held. Pierce rarely found two crates in a row, so its room gets extra crates. Every
@@ -1993,7 +1993,7 @@ PLAN.md §19.2, as agreed. Choices made while building:
 
 From the lab's numbers (PLAN.md §19.1), the user decided: **Flame Pass becomes a charge counted in
 flame hits** — it saves the player from 3 fire hits, then runs out (not a number of turns, as
-proposed) — and **Teleport a charge of 3 jumps**. **Wall Pass and Bomb Pass stay power-ups** for
+proposed) — and **Teleport a charge of 3 jumps**. **Crate Pass and Bomb Pass stay power-ups** for
 now, to be playtested more. Scheduled as the next task, after the bigger boards (being playtested
 by the user). Backlogged in PLAN.md §18.
 
@@ -2087,7 +2087,7 @@ The user's order: stages 1–3 of the plan.
    enemy for long (stalls 8%); 1/9 keeps it at 3% and wins at 93%. The average bot still dies
    early (about 5 levels), as before: the careful bot is no measure of difficulty for people.
 3. **A death drops the charge carried** where the player fell, with what was left; the retry has
-   it lying there (or on the nearest open floor, if the player fell inside a crate with Wall Pass).
+   it lying there (or on the nearest open floor, if the player fell inside a crate with Crate Pass).
    A raised Shield or Flame Pass is kept. A pending choice now carries how much is left too.
 
 `SIM_VERSION` 35. The lab now also measures each charge carried from level 1.
@@ -2395,3 +2395,145 @@ had put it there on purpose. Now a drag "holds" the view (`held` in camera.ts):
 player but not the view (`keepView` shifts the offset by the move). It follows the player again
 when a turn would take the player out of the view (checked after the turn, not while dragging, so a
 far look stays put) and on a new level, a new run or the demo (`resetView`). Supersedes "the next turn brings the view back" in the 2026-10-25 entry above.
+
+### 2026-11-01 — Controller support: pure core first (Q29, stage 1)
+
+Controller support is mandatory before Steam. It works like the keyboard plus a way to aim at tiles (first a tile cursor, then, in stage 2, a target among the blue tiles), and
+calls the same functions (tapTile, direction, action buttons, inspect), so every rule stays shared.
+Stage 1 is two pure modules with tests, nothing wired into the game yet: `ui/padMap.ts` (a gamepad
+snapshot to intents) and `ui/spatial.ts` (which element to focus next in a direction).
+Map (W3C standard mapping; face buttons and shoulders superseded by stage 2 below): D-pad and left stick move (stick in 8 sectors, d-pad can give diagonals,
+the caller filters by the Diagonal power); A confirm; B cancel; X Bomb; Y Wait; LB/RB previous/next
+(charge/move on the board, tabs and scenes elsewhere); View: What is that?; Start: menu; R3: reset
+view; right stick looks around. A held direction repeats after 300 ms, then every ~110 ms; the left
+stick turns on at 0.5 and off at 0.35; the right stick has a 0.2 deadzone. The first frame of a
+fresh pad only records what is held, so the press that wakes the pad in Chrome does nothing.
+Dialogs: Cross/Triangle/Square pick buttons directly in small dialogs (Q42, to be settled in
+stage 4). Desktop wrapper for Steam: Electron (Q41). Full plan lives in the queue (Q29).
+
+Stage 2 (board play, wired in; `ui/gamepad.ts`): the pad calls the same functions as keys and taps
+and never fakes pointer events. First attempt, a free tile cursor (the d-pad moved it, A tapped
+it), was replaced the same day by Pavel's design, which reuses the action bar: the face buttons are
+Cross Move/Go, Circle Wait, Square Bomb, Triangle Charge (the intent also carries the raw face
+index, since dialogs will map them differently); LB cancels, RB and View ask "What is that?", Start
+is the menu, R3 is `resetPan` (the view follows the player again, not the full `resetView`). RB or View
+turn "What is that?" on and aim at the nearest tile with something to explain
+(`padTarget.explainableTiles`: what `tileInfo` explains, not plain pillars or the outer wall);
+the d-pad jumps between them, the label says "Find out what this is", RB or View again explains
+the tile (the call a tap makes in inspect mode), LB cancels.
+Pressing a face button picks that action as its action-bar button does, so the blue tiles show
+(`pickTargets`, the same list a tap uses), and aims at the nearest of them (`padTarget.ts`
+`firstTarget`), drawn with a dashed square from Kenney's Crosshair Pack, breathing slightly. The
+d-pad and left stick then jump between those tiles (`nextFocus`, board coordinates, nothing wraps);
+the right stick looks around. The same face button again does it (`tapTile`, so choices, the
+deadly check and Freeze work as for a tap); another one switches action; LB (`cancel()`, shared
+with Esc) puts it away. Wait, and Go (Move with a flag set), have no tile: they aim at the
+player's own tile and the same button does them. With nothing aimed, the d-pad and left stick walk
+as the arrow keys do (a diagonal only with the Diagonal power). A one-line label over the action
+bar, only while the pad is in use, says what confirming does (`ui/targetLabel.ts`, covering
+Move/Run/Diagonal/Slide/Kick, Bomb/Throw/RPG/Detonate, each charge, Wait and Go, and "costs a
+life!"). The view moves for the target only near its edge (`revealTile`) and then stays put; the
+aim ends when a turn is played, a level or run starts, or a mouse, touch or key acts. LittleJS's
+own gamepad reading is disabled (`setGamepadsEnable(false)`). The pad is ignored while the window
+is unfocused, a standard pad is preferred, and a non-standard one only gets one "not recognised"
+toast. In the demo the d-pad left/right switch scenes, at most every 400 ms, and Cross, Circle,
+Square, Triangle, LB or Start end it.
+
+Action-bar glyphs (Q45): while `body.pad` is set, each of the four action buttons shows its face button
+(`ui/padGlyph.ts`, Kenney Input Prompts, the colour variants, Default size) where the key hint sits,
+and the hint is hidden (not removed, so the button keeps its size). The family comes from
+`padFamily` when the pad connects (`controls.setPadFamily`): PlayStation gets Cross, Square,
+Triangle and Circle; Xbox and any other standard pad get A, X, Y and B. They revert with the
+existing removal of `body.pad` on a pointer or key.
+
+Stage 3 (dialogs and the banner; `ui/padDialogMap.ts` pure and tested, `ui/padDialog.ts` DOM glue,
+Pavel approved the layout): a dialog's footer buttons map to face buttons: the primary one to
+Cross, the others left to right to Triangle then Square, and the button that dismisses (by label:
+Close, Cancel, Back, Got it, Keep playing, Resume; only if the dialog is not `required`) to Circle.
+Circle with no such button closes via `dismissDialog()` (what Esc does). If the buttons do not fit
+(more than three besides the dismissing one), or the body has controls to move between (menu,
+almanac, New run, the changes list), "selector mode" applies: a d-pad selector (nextFocus over
+`getBoundingClientRect`) covers the body's buttons, links, switches and the footer, and Cross
+clicks the focused one. Cards (`.offer-choice`: gem offer, shop) are not "controls": moving onto one
+clicks it (selects it, plays its clip) and Cross presses the footer's primary (gem offer) or the
+selected card's Buy (shop, where Cross is thus taken and Restock and Next level get Triangle and
+Square; a plain Cross there would have left the shop). The body is watched with a
+MutationObserver, so any rebuild (shop purchase, almanac tab, Back) re-applies glyphs and the
+selector (same index; the shop and gem cards prefer the one with `aria-pressed`); a new dialog
+(event `dialogopen`) starts the selector on the footer's first primary button in selector mode
+(Resume, Start), else the `aria-pressed` item (the almanac's current tab), else the first element.
+In selector mode Circle presses the footer's dismissing button when there is one (so Back runs its
+action), else closes. The shop's Buy always uses the card marked `aria-pressed` at press time. Moving
+the selector scrolls the body clear of its sticky tabs, clip or coin line. LB/RB click the previous/next `[data-tab]`; the
+right stick scrolls `#dialog-body` (900 px/s at full push). A text field is never selectable (a
+pad cannot type): the seed stays for keyboard. The banner's visible buttons use the same mapping
+(first Cross, then Triangle, Square). Known gap: Back from an almanac entry or a change returns to
+the almanac's current tab, not to the entry that was opened (the scroll position is kept). View
+(like RB) also switches tabs, as the pad cannot tell them apart.
+
+After Pavel's first pad test (board only): the right-hand face buttons stay. The left stick is off
+(`LEFT_STICK_ENABLED = false`; `mapPad` takes a `leftStick` argument so its tests still run). The
+d-pad with no aim does not walk any more: it aims the neighbour tile (`aim.button = 'step'`), and
+the label and the later confirm both come from `directionPlan` / `diagonalPlan`, the very function
+the arrow keys run, so every keyboard rule (kick, run, Bomb Pass choice, the deadly refusal) holds;
+a step that can do nothing shows "Can't go there" and the crosshair without the blue tile. Cross
+confirms any aim (`confirmAim`); Circle backs out of any aim (`padBack`: the pick it made goes, the
+flag stays) and, with none, aims Wait (so Wait needs Cross: one slip no longer ends a turn).
+LB / RB are `prev` / `next` (LB no longer cancels, RB no longer inspects; View alone does): on the
+board they cycle through one list (`padCycle.ts`): for Move, Bomb, Charge, Wait in that order, each
+button's `pickTargets` tiles (nearest first), or a single item for a button that goes at once (Wait,
+Go); it is built when the first press finds no cycle aim, kept in the aim, and thrown away with the
+aim at the end of a turn. The label gets "CURR/ALL · ". Cross picks the item's button as a face
+button would and taps its tile (`tapTile`, so a choice dialog asks; a deadly item is still played, the label having warned "(costs a life!)", as with a picked action by touch: only the d-pad step and a tap with nothing picked refuse). The d-pad does nothing
+while cycling. In dialogs LB / RB switch tabs; View does nothing there. The demo is ended by LB no
+more (only face buttons, Start).
+
+The cycle aim looks as if its action were picked without picking it: `drawBoard` takes `pick` (the
+action whose tiles are lit blue, and which of them cost a life, computed when the aim is made) and
+`ghost` (an action and tile whose result the existing commit preview draws faded: bombs with their
+badges and blast, a decoy, the moved player). The d-pad step aim uses `ghost` alone. No separate
+sprite table was needed, since the preview plays the action on a copy of the state. Not previewed:
+Wait (nothing changes), and a Freeze or ICBM beyond what the commit preview already draws.
+
+**Tile first (supersedes the aim, d-pad step and LB / RB cycle models above, 2026-11-01, Pavel after
+testing them).** The pad now has a cursor, a tile among the actionable ones (`ui/padTiles.ts`,
+pure and tested): the player's own tile (Wait) and every tile `pickTargets` gives for Move, Bomb or
+Charge. All of them are lit blue (`View.lit`) and the cursor is the dashed square. The d-pad moves
+it with `nextFocus` on board coordinates; the first press with none showing puts it on the nearest
+actionable tile that way, or on the player's own tile. It is dropped when a turn ends, a level or
+run starts, a dialog opens or a mouse/key takes over, and on any other change it stays if its tile
+is still actionable, else goes to the nearest one that is, else goes (`refreshCursor`). The face
+buttons act on the cursor's tile AT ONCE, through the path a tap with that button picked takes
+(`selected` set, then `tapTile`; Wait is `pick('wait')`): a choice dialog for a bomb still asks.
+There is no second press to confirm. A button's availability on the tile comes from `resolvePick`
+(Wait: only the player's tile). A button is marked deadly by `isDeadly` of the action it resolves
+to there (`{id, target}`), and of waiting for Wait: the same test as the board's red tiles and the
+Wait button's red. A deadly action is played, since the button was marked; only a tap or key with
+nothing picked refuses. Greyed ones flash when pressed. With no cursor, a face button only shows the
+cursor on the player's own tile. The label lists the possible buttons with their names; the ghost
+is the first possible of Move, Bomb, Charge (none on the own tile). LB backs out; RB and View are
+"What is that?" (aim kind `inspect`, unchanged); in dialogs LB / RB (and View) switch tabs. Removed:
+`padCycle.ts`, the cycle and step aims, same-button-confirm and Circle-backs-out. The keyboard's
+`directionPlan` stays (the arrow keys use it).
+
+Glyph layout and families: the action bar's picture now sits beside the icon on one row (36px icon,
+32px picture, both able to shrink on a narrow bar; the key hint is hidden under `body.pad`), and
+the cursor's label draws the button picture before each line instead of its name. A Nintendo
+family (`padFamily`: ids with nintendo, switch, pro controller or vendor 057e) uses the Switch
+set (plain: the pack has no coloured variant); glyphs are chosen by physical position, so the
+bottom button (index 0, Move) shows B, left (Bomb) Y, top (Charge) X and right (Wait) A. The cursor
+tile is drawn red when every action possible there would cost a life (`cursorDeadly`); if only
+some would, it stays blue and the red-marked buttons say which.
+
+### 2026-11-01 — A screen shows the whole board when it fits (supersedes the fixed 9 × 13 view)
+
+Decided by the user, knowing it changes the earlier rule that no screen sees more than another
+(PLAN.md §19.2, decisions.md 2026-10-24/25: the view is 9 columns × 13 rows). From the UI rework
+(Q52) on, the number of tiles shown depends on the screen: the view grows with the space, up to the
+whole board, and a screen too small for the board scrolls as before (phones keep the 9 × 13 view or
+whatever fits at a playable tile size). So early boards (9 × 11) always show fully, and a large
+screen shows a late 13 × 17 board whole. Trade-off accepted: a big screen sees more of the board
+than a phone; scores from different screens are therefore not strictly like for like. Rules and
+SIM_VERSION are unchanged (the view is render and UI only). Still to do when the camera part is
+built: update PLAN.md §19.2 and the README, and decide whether the score board notes the screen
+class. Not built yet.
diff --git a/docs/art-provenance.md b/docs/art-provenance.md
index ec7c8d9..ffa2731 100644
--- a/docs/art-provenance.md
+++ b/docs/art-provenance.md
@@ -19,7 +19,7 @@ Makers used below:
 
 | Group | Files | Maker |
 |---|---|---|
-| Kenney, unchanged | 125 images and fonts | Kenney |
+| Kenney, unchanged | 138 images and fonts | Kenney |
 | Splat Pack splats | 14 | Kenney, scaled 256 → 128 px (alpha identical after scaling; saved as grey + alpha) |
 | Pointing hand `cursorHand_beige.svg` | 1 | Kenney, three shapes cut out of the RPG Expansion vector sheet |
 | Sounds | 12 | Kenney, converted `.ogg` → `.mp3` |
@@ -119,6 +119,19 @@ Badge = green disc with dark rim, upper right, Kenney symbol on it. Base sprites
 | kenney-icons/White/warning.png | game-icons | PNG/White/1x/warning.png |
 | kenney-icons/question.png | game-icons | PNG/Black/2x/question.png |
 | kenney-ui-rpg/panel_beigeLight.png | ui-pack-rpg-expansion | PNG/panel_beigeLight.png |
+| kenney-crosshair/crosshair041.png | crosshair-pack | PNG/Black Retina/crosshair041.png |
+| kenney-input-prompts/playstation_button_color_circle.png | input-prompts_1.5A | PlayStation Series/Default/playstation_button_color_circle.png |
+| kenney-input-prompts/playstation_button_color_cross.png | input-prompts_1.5A | PlayStation Series/Default/playstation_button_color_cross.png |
+| kenney-input-prompts/playstation_button_color_square.png | input-prompts_1.5A | PlayStation Series/Default/playstation_button_color_square.png |
+| kenney-input-prompts/playstation_button_color_triangle.png | input-prompts_1.5A | PlayStation Series/Default/playstation_button_color_triangle.png |
+| kenney-input-prompts/switch_button_a.png | input-prompts_1.5A | Nintendo Switch/Default/switch_button_a.png |
+| kenney-input-prompts/switch_button_b.png | input-prompts_1.5A | Nintendo Switch/Default/switch_button_b.png |
+| kenney-input-prompts/switch_button_x.png | input-prompts_1.5A | Nintendo Switch/Default/switch_button_x.png |
+| kenney-input-prompts/switch_button_y.png | input-prompts_1.5A | Nintendo Switch/Default/switch_button_y.png |
+| kenney-input-prompts/xbox_button_color_a.png | input-prompts_1.5A | Xbox Series/Default/xbox_button_color_a.png |
+| kenney-input-prompts/xbox_button_color_b.png | input-prompts_1.5A | Xbox Series/Default/xbox_button_color_b.png |
+| kenney-input-prompts/xbox_button_color_x.png | input-prompts_1.5A | Xbox Series/Default/xbox_button_color_x.png |
+| kenney-input-prompts/xbox_button_color_y.png | input-prompts_1.5A | Xbox Series/Default/xbox_button_color_y.png |
 | kenney-ui/Extra/icon_play_light.png | ui-pack | PNG/Extra/Double/icon_play_light.png |
 | kenney-ui/Extra/icon_repeat_light.png | ui-pack | PNG/Extra/Double/icon_repeat_light.png |
 | kenney-ui/Green/button_rectangle_depth_flat.png | ui-pack | PNG/Green/Double/button_rectangle_depth_flat.png |
diff --git a/src/changelog.ts b/src/changelog.ts
index 417469c..e3a6940 100644
--- a/src/changelog.ts
+++ b/src/changelog.ts
@@ -48,6 +48,25 @@ export const RELEASES: Release[] = [
 
 /** Finished since the latest release, not yet part of one. Moved into RELEASES when one is tagged. */
 export const UNRELEASED: Change[] = [
+  {
+    kind: 'added',
+    icon: 'star',
+    title: 'Play with a controller',
+    text: [
+      'A gamepad now plays a whole run: the board, the menu, power-up choices, the shop, the almanac and the screens after a death or a level. The buttons on screen show which controller button presses them.',
+      'On the board, the d-pad moves a square between the tiles you can act on (they light up blue), a line says what each button would do there, and the four face buttons (Cross, Square, Triangle and Circle on PlayStation; A, X, Y and B on Xbox) do it at once. Buttons that cannot be used are greyed, and ones that would cost a life are marked in red. LB puts the square away.',
+      'In dialogs, Cross, Triangle and Square press the buttons, Circle closes, the d-pad moves a highlight over the choices, LB and RB switch the almanac’s tabs, and the right stick scrolls.',
+    ],
+    date: '2026-11-01',
+  },
+  {
+    kind: 'changed',
+    icon: 'star',
+    title: 'Wall Pass is now Crate Pass',
+    text: ['The power-up lets you walk through crates, not walls, so it has a name that says so. It works exactly as before.'],
+    clip: 'wallPass',
+    date: '2026-11-01',
+  },
   {
     kind: 'changed',
     icon: 'star',
diff --git a/src/main.ts b/src/main.ts
index b732dc3..5185b1e 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -6,9 +6,11 @@ import {
   setCanvasClearColor,
   setCanvasPixelRatio,
   setDebugWatermark,
+  setGamepadsEnable,
   setGLEnable,
   glSetAntialias,
   setShowSplashScreen,
+  timeDelta,
   setTilesPixelated,
   PostProcessPlugin,
   postProcess,
@@ -21,7 +23,7 @@ import {
 } from 'littlejsengine'
 import { BACKGROUND, drawBoard, drawIndicators, playerPos, toTile, toWorld } from './render/board'
 import { animateTurn as animateBoard, clearAnimation, newAnimation, pruneAnimation } from './render/animate'
-import { endPan, keepView, panView, resetPan, updateCamera, viewRect } from './render/camera'
+import { endPan, keepView, panView, resetPan, revealTile, updateCamera, viewRect } from './render/camera'
 import { CHARGE_ICONS, MONO_ICONS, POWER_UP_ICONS, TEXTURES } from './render/sprites'
 import { isAvailable, isValidTarget, ACTIONS } from './sim/actions'
 import { approachTile, canPick, membersOf, pickChoices, pickTargets, resolvePick, tapChoices, tapMeaning } from './sim/picks'
@@ -40,7 +42,14 @@ import { VERSION } from './changelog'
 import { channelBox, channelLine, loadNextInfo, newSinceLastVisit, openChanges } from './ui/whatsnew'
 import { CHANNEL, storageKey, TITLE } from './channel'
 import { createControls } from './ui/controls'
-import { closeDialog, dialogOpen, openDialog, setDialogInDemo, type DialogButton } from './ui/dialog'
+import type { Step } from './ui/spatial'
+import { createPadInput } from './ui/gamepad'
+import { glyphFor } from './ui/padGlyph'
+import type { PadFamily } from './ui/padMap'
+import { padDialog } from './ui/padDialog'
+import { explainableTiles, firstTarget, jumpTarget } from './ui/padTarget'
+import { actionableTiles, cursorDeadly, cursorLines, FACE_TO_BUTTON, firstCursor, ghostAction, keepCursor, moveCursor, tileActions, type BarButton, type TileAction } from './ui/padTiles'
+import { closeDialog, dialogOpen, MENU_TITLE, openDialog, setDialogInDemo, type DialogButton } from './ui/dialog'
 import { actionInfo, CHARGE_TEXT, chargeInfo, hudInfo, tileInfo, type Info } from './ui/info'
 import { openChargeOffer } from './ui/chargeOffer'
 import { openTapChoice } from './ui/tapChoice'
@@ -78,6 +87,17 @@ let selected: ActionId | null = null
 /** "What is that?" is on: the next tap on the board explains the tile instead of acting. */
 let inspecting = false
 let hover: Vec | null = null
+/**
+ * The controller's "What is that?" aim: the tiles that have something to explain, the current one,
+ * and what pressing again says. Gone when "What is that?" ends, or a mouse, touch or key acts.
+ */
+interface Aim {
+  button: 'inspect'
+  tiles: Vec[]
+  target: Vec | null
+  label: string
+}
+let aim: Aim | null = null
 /** What animates the board between turns (render/animate.ts). */
 const anim = newAnimation()
 let snapCamera = true
@@ -120,6 +140,8 @@ function resetView(): void {
   selected = null
   inspecting = false
   destination = null
+  aim = null
+  cursor = null
   snapCamera = true
   resetPan()
 }
@@ -312,6 +334,9 @@ function animateTurn(result: ReturnType<typeof step>, action: Action): void {
   // A view the player dragged somewhere stays there (Q40); a new level starts following again.
   if (newLevel) resetPan()
   else if (!demo) keepView(from, state.player)
+  // A turn played ends the aim and the cursor (the tiles and the label were for the board as it was).
+  aim = null
+  cursor = null
   if (newLevel) snapCamera = true
   if (newLevel || result.events.some((e) => e.type === 'levelCleared')) destination = null
 }
@@ -330,6 +355,8 @@ function showChargeOffer(): void {
 }
 
 function afterChange(): void {
+  if (aim && (state.status !== 'playing' || !inspecting)) aim = null
+  refreshCursor()
   danger = predictDanger(state)
   // The walk is worked out again every turn, since bombs and crates change; reaching the
   // destination, or it becoming unreachable, ends auto-move.
@@ -369,6 +396,7 @@ function afterChange(): void {
  *   flame can't hurt the player (Flame Pass, a Star), then it is walked into like floor.
  */
 function tapTile(p: Vec): void {
+  aim = null
   if (state.status !== 'playing') return
   if (inspecting) {
     inspecting = false
@@ -414,6 +442,7 @@ function refuse(p: Vec): void {
 /** The "What is that?" button: on, or off again (dropping any action picked). */
 function inspect(): void {
   if (state.status !== 'playing') return
+  aim = null
   inspecting = !inspecting
   selected = null
   afterChange()
@@ -511,7 +540,7 @@ function showMenu(): void {
   const where = state.status === 'playing' ? `Level ${state.level}, turn ${state.turn}. ` : ''
   const modes = [state.options.dev && 'Developer', state.options.immortal && 'Immortal'].filter(Boolean)
   openDialog({
-    title: 'Menu',
+    title: MENU_TITLE,
     body:
       `<p>${where}Seed ${state.seed}. The game saves after every move, so you can close it and come back.</p>` +
       (modes.length ? `<p class="mode">${modes.length > 1 ? 'Modes' : 'Mode'}: ${modes.join(', ')}.</p>` : '') +
@@ -638,6 +667,7 @@ function autoStep(): void {
  * so this cancels it. With "What is that?" on, it explains the button instead.
  */
 function pick(id: ActionId, pressed = selected === id): void {
+  aim = null
   if (inspecting) {
     inspecting = false
     afterChange()
@@ -662,6 +692,21 @@ function pick(id: ActionId, pressed = selected === id): void {
  */
 function direction(d: Dir): void {
   if (state.status !== 'playing') return
+  aim = null
+  runStep(directionPlan(d))
+}
+
+/** What a direction key would do: the action and tile, and whether it asks which or is refused. */
+interface StepPlan {
+  id: ActionId
+  p: Vec
+  /** A bomb two actions could deal with: the options to ask about. */
+  ask?: ActionId[]
+  /** A shortcut that would cost a life: refused. */
+  refuse?: boolean
+}
+
+function directionPlan(d: Dir): StepPlan | null {
   // Nothing picked: a step, or a kick into a bomb. A picked button reaches further too (a run,
   // a teleport, a throw; an RPG, an ICBM, a slide as far as they go): the nearest tile that way
   // it can use.
@@ -673,13 +718,22 @@ function direction(d: Dir): void {
     if (!id) continue
     // A bomb two actions could deal with (kick or walk onto it) asks which, picked or not.
     const options = selected ? pickChoices(state, picked, p) : tapChoices(state, p)
-    if (options.length > 1) return openTapChoice(options, (choice) => act({ id: choice, target: p }), (c) => isDeadly(state, { id: c, target: p }))
+    if (options.length > 1) return { id, p, ask: options }
     // Nothing picked and one safe option on a bomb: that one, as a tap takes it (even if a deadlier one comes first).
-    if (!selected && options.length === 1) return act({ id: options[0]!, target: p })
+    if (!selected && options.length === 1) return { id: options[0]!, p }
     // Nothing picked, a key is a shortcut, as a tap is: never one that costs a life.
-    if (!selected && isDeadly(state, { id, target: p })) return refuse(p)
-    return act({ id, target: p })
+    if (!selected && isDeadly(state, { id, target: p })) return { id, p, refuse: true }
+    return { id, p }
   }
+  return null
+}
+
+function runStep(plan: StepPlan | null): void {
+  if (!plan) return
+  const { p } = plan
+  if (plan.ask) return openTapChoice(plan.ask, (choice) => act({ id: choice, target: p }), (c) => isDeadly(state, { id: c, target: p }))
+  if (plan.refuse) return refuse(p)
+  act({ id: plan.id, target: p })
 }
 
 const controls = createControls({
@@ -715,6 +769,33 @@ const ACTION_KEYS: Record<string, ActionId> = {
 /** Q / E / Z / C: a diagonal step up-left, up-right, down-left, down-right (with Diagonal). */
 const DIAGONAL_KEYS: Record<string, Vec> = { KeyQ: { x: -1, y: -1 }, KeyE: { x: 1, y: -1 }, KeyZ: { x: -1, y: 1 }, KeyC: { x: 1, y: 1 } }
 
+/** A diagonal step (Q / E / Z / C), as a key takes it: never one that costs a life. */
+function stepDiagonal(diag: Vec): void {
+  runStep(diagonalPlan(diag))
+}
+
+function diagonalPlan(diag: Vec): StepPlan | null {
+  const p = { x: state.player.x + diag.x, y: state.player.y + diag.y }
+  if (!isValidTarget(state, 'diagonal', p)) return null
+  return { id: 'diagonal', p, refuse: !selected && isDeadly(state, { id: 'diagonal', target: p }) }
+}
+
+/**
+ * Esc: puts away one thing, nearest first: "What is that?", then the picked
+ * action or the flag. False when there was nothing
+ * to put away.
+ */
+function cancel(): boolean {
+  if (inspecting) inspecting = false
+  else if (selected || destination) {
+    selected = null
+    destination = null
+  } else return false
+  aim = null
+  afterChange()
+  return true
+}
+
 window.addEventListener('keydown', (e) => {
   // The dialog has the keyboard while it is open (Esc closes it, Tab moves between its buttons).
   if (e.repeat || dialogOpen()) return
@@ -723,24 +804,14 @@ window.addEventListener('keydown', (e) => {
   // (or Diagonal) picked.
   const diagonalKeys = isAvailable(state, 'diagonal') && (!selected || selected === 'move' || selected === 'diagonal')
   const diag = diagonalKeys ? DIAGONAL_KEYS[e.code] : undefined
-  if (diag) {
-    const p = { x: state.player.x + diag.x, y: state.player.y + diag.y }
-    if (!isValidTarget(state, 'diagonal', p)) return
-    if (!selected && isDeadly(state, { id: 'diagonal', target: p })) refuse(p)
-    else act({ id: 'diagonal', target: p })
-  } else if (d) direction(d)
+  if (diag) stepDiagonal(diag)
+  else if (d) direction(d)
   else if (e.code === 'Space') pick('wait')
   else if (e.key === '?' || e.code === 'Slash') inspect()
   else if (ACTION_KEYS[e.code]) pick(ACTION_KEYS[e.code]!)
   else if (e.code === 'Escape') {
-    // Cancels "What is that?", what is picked, or the flag; with nothing to cancel, opens the menu.
-    if (!inspecting && !selected && !destination) return showMenu()
-    if (inspecting) inspecting = false
-    else {
-      selected = null
-      destination = null
-    }
-    afterChange()
+    // Cancels "What is that?", what is picked or the flag; with nothing to cancel, opens the menu.
+    if (!cancel()) showMenu()
   } else if (e.code === 'Enter') {
     // Presses the banner's button.
     controls.pressBanner()
@@ -776,6 +847,196 @@ function tileUnder(e: PointerEvent): Vec | null {
   return inBounds(state, p) ? p : null
 }
 
+/** How fast the right stick moves the view at full push, in tiles a second (the controller, ui/gamepad.ts). */
+const LOOK_TILES_PER_SECOND = 9
+
+/** The controller is what the player is using now (body.pad); a mouse or key takes it back. */
+let padActive = false
+/** The demo changes scene at most this often from the pad: a held stick repeats faster than a scene can be made. */
+const PAD_SCENE_MS = 400
+let lastPadScene = -Infinity
+function padScene(delta: number): void {
+  if (!demo || performance.now() - lastPadScene < PAD_SCENE_MS) return
+  lastPadScene = performance.now()
+  skipTo(adjacentScene(demo.index, delta))
+}
+
+/** "What is that?": the aim on a tile to explain (`aim`), with what pressing again does. */
+function aimAt(tiles: Vec[], target: Vec | null): void {
+  aim = { button: 'inspect', tiles, target, label: 'Find out what this is' }
+  if (target) revealTile(target.x, target.y, state.width, state.height)
+}
+
+/**
+ * The controller's cursor (decisions.md 2026-11-01, "tile first"): one actionable tile, shown with
+ * the dashed square while every actionable tile is blue, and what each face button would do there.
+ */
+let cursor: { tile: Vec; tiles: Vec[]; actions: TileAction[] } | null = null
+/** The kind of pad in use, for the names in the cursor's label. */
+let padKind: PadFamily = 'generic'
+
+function setCursor(tile: Vec): void {
+  cursor = { tile, tiles: actionableTiles(state), actions: tileActions(state, tile) }
+  revealTile(tile.x, tile.y, state.width, state.height)
+}
+
+/** The board changed under the cursor: it stays on a tile that is still actionable, else moves to the nearest that is, else goes. */
+function refreshCursor(): void {
+  if (!cursor) return
+  const tiles = actionableTiles(state)
+  const tile = state.status === 'playing' ? keepCursor(tiles, cursor.tile) : null
+  if (tile) setCursor(tile)
+  else cursor = null
+}
+
+/** A d-pad press: the cursor appears on the nearest actionable tile that way, or moves to the next one. */
+function cursorMove(dx: Step, dy: Step): void {
+  const tiles = actionableTiles(state)
+  setCursor(cursor ? moveCursor(tiles, cursor.tile, dx, dy) : firstCursor(tiles, state.player, dx, dy))
+}
+
+/**
+ * A face button with the cursor showing: that action, at once, on the cursor's tile (the action
+ * bar's button picked and the tile tapped, so a choice dialog for a bomb asks, and Freeze works
+ * as it does there). One that is not possible there flashes its button. One that would cost a
+ * life is played (the button was marked): only a tap with nothing picked refuses.
+ */
+function padDo(button: BarButton): void {
+  const c = cursor
+  const action = c?.actions.find((a) => a.button === button)
+  if (!c || !action?.possible) return controls.flashPad(button)
+  cursor = null
+  if (button === 'wait') return pick('wait', false)
+  selected = button
+  afterChange()
+  tapTile(c.tile)
+}
+
+/**
+ * L1: puts away "What is that?", or else the cursor, or else what is left picked (after a
+ * cancelled choice dialog) and the flag, as Esc does.
+ */
+function padBack(): void {
+  if (inspecting) inspecting = false
+  else if (cursor) cursor = null
+  else if (selected || destination) {
+    selected = null
+    destination = null
+  }
+  aim = null
+  afterChange()
+}
+
+/** "What is that?" is on: aim at the nearest tile with something to explain. */
+function aimInspect(): void {
+  const tiles = explainableTiles(state)
+  const first = firstTarget(tiles, state.player)
+  if (first) aimAt(tiles, first)
+}
+
+/** Whether the controller's aim or cursor is on screen: the square and the label go together. */
+function aimShown(): boolean {
+  return !!(aim ?? cursor) && padActive && !demo && !dialogOpen() && state.status === 'playing'
+}
+
+let lastBarKey = ''
+let lastNote = ''
+const pad = createPadInput({
+  active() {
+    padActive = true
+    document.body.classList.add('pad')
+  },
+  error: (e) => reportError('gamepad', e),
+  // The kind of pad, for the pictures on the action bar, in dialogs and the banner, and in the cursor's label.
+  family(f) {
+    padKind = f
+    controls.setPadFamily(f)
+    padDialog.setFamily(f)
+  },
+  toast: (title, text) => showToast(handIcon, title, text),
+  dir(dx, dy) {
+    if (demo) return dy === 0 && dx !== 0 ? padScene(dx) : undefined
+    if (dialogOpen()) return padDialog.move(dx, dy)
+    if (state.status !== 'playing') return
+    if (inspecting) {
+      // "What is that?": the d-pad jumps between the tiles it can explain, nothing wraps round. With
+      // no aim yet (it was turned on by key or mouse, or the aim was put away), the first press makes it.
+      if (aim?.target) aimAt(aim.tiles, jumpTarget(aim.tiles, aim.target, dx, dy))
+      else aimInspect()
+      return
+    }
+    cursorMove(dx, dy)
+  },
+  press(button, face) {
+    if (demo) {
+      // A button that acts ends it, as a tap or Esc does.
+      if (button !== 'info' && button !== 'reset') endDemo()
+      return
+    }
+    if (button === 'menu') {
+      if (!dialogOpen()) showMenu()
+      else if (document.getElementById('dialog-title')?.textContent === MENU_TITLE) closeDialog()
+      return
+    }
+    if (dialogOpen()) {
+      // In a dialog: face buttons press its buttons, LB / RB switch tabs.
+      if (face !== undefined) padDialog.face(face)
+      else if (button === 'cancel') padDialog.tab(-1)
+      else if (button === 'info') padDialog.tab(1)
+      return
+    }
+    // The end-of-level or end-of-run banner: its buttons sit on the face buttons.
+    if (face !== undefined && padDialog.banner(face)) return
+    if (state.status !== 'playing') return
+    if (button === 'reset') resetPan()
+    else if (button === 'cancel') padBack()
+    else if (button === 'info') {
+      // RB or View: "What is that?" on, and aimed; pressed again on a target, it explains that tile.
+      cursor = null
+      if (aim?.button === 'inspect' && aim.target) tapTile(aim.target)
+      else if (inspecting) aimInspect()
+      else {
+        inspect()
+        if (inspecting) aimInspect()
+      }
+    } else if (face !== undefined) {
+      // Cross explains the aimed tile while "What is that?" is on; the other face buttons do nothing then.
+      if (inspecting) {
+        if (face === 0 && aim?.target) tapTile(aim.target)
+      } else if (!cursor) setCursor({ ...state.player })
+      else padDo(FACE_TO_BUTTON[face])
+    }
+  },
+  look(dx, dy) {
+    if (demo) return
+    if (dialogOpen()) return padDialog.scroll(dy, timeDelta)
+    panView(dx * LOOK_TILES_PER_SECOND * timeDelta, -dy * LOOK_TILES_PER_SECOND * timeDelta)
+  },
+  lookEnd: () => endPan(),
+})
+/** The one-line note over the action bar: what confirming the controller's target does (style.css `#pad-label`). */
+const padLabel = document.createElement('div')
+padLabel.id = 'pad-label'
+padLabel.hidden = true
+padLabel.setAttribute('role', 'status')
+document.body.append(padLabel)
+// A dialog opening puts the cursor away, so closing the menu does not bring the old one back.
+document.getElementById('dialog')!.addEventListener('dialogopen', () => {
+  cursor = null
+})
+/** The mouse, touch or keyboard has the board again: the marker goes, with the cursor and the aim, so Esc and clicks are not swallowed by them. */
+function padOff(): void {
+  padActive = false
+  aim = null
+  cursor = null
+  document.body.classList.remove('pad')
+}
+for (const type of ['pointerdown', 'keydown'] as const) addEventListener(type, padOff, true)
+// A real mouse move does too, but not a hair's jitter (or the move the browser fakes under a still mouse).
+addEventListener('pointermove', (e) => padActive && Math.hypot(e.movementX, e.movementY) >= 3 && padOff(), true)
+// LittleJS would also read the pad (its own d-pad-as-keys); this game does it itself.
+setGamepadsEnable(false)
+
 if (!resumeSaved()) startRun(Number(params.get('seed')) || randomSeed(), testing ? urlOptions() : preferredOptions())
 
 /**
@@ -1529,6 +1790,7 @@ await engineInit(
     mainCanvas.addEventListener('contextmenu', (e) => e.preventDefault())
   },
   timed(guarded('update', () => {
+    pad.poll(performance.now())
     demoTick()
     // In the demo the board and the action bar under it fit above the caption panel, with room
     // between them and it.
@@ -1548,6 +1810,40 @@ await engineInit(
     document.body.style.setProperty('--tv-left', `${(innerWidth - tv) / 2}px`)
     placeDemoMarks()
     if (demo) vacuum()
+    // The label for the controller's target, over the action bar, while the pad is in use.
+    const shownCursor = aimShown() ? cursor : null
+    const lines = shownCursor ? cursorLines(shownCursor.actions) : []
+    // Empty (so the box is hidden) when there are no lines to show.
+    const note = shownCursor ? (lines.length ? JSON.stringify([padKind, lines]) : '') : aimShown() ? aim!.label : ''
+    // The action bar's buttons show what the cursor's tile offers: greyed, or marked when it costs a life.
+    const barKey = shownCursor ? `${shownCursor.tile.x},${shownCursor.tile.y}:${shownCursor.actions.map((a) => (a.possible ? (a.deadly ? 'd' : 'o') : 'x')).join('')}` : ''
+    if (barKey !== lastBarKey) {
+      lastBarKey = barKey
+      controls.setPadCursor(shownCursor ? Object.fromEntries(shownCursor.actions.map((a) => [a.button, a.possible ? (a.deadly ? 'danger' : 'ok') : 'off'])) : null)
+    }
+    if (lastNote !== note) {
+      lastNote = note
+      // The cursor's lines each start with the button's own picture; the inspect aim's is plain text.
+      if (shownCursor) {
+        padLabel.replaceChildren(
+          ...lines.map((l) => {
+            const row = document.createElement('div')
+            row.className = 'pad-line'
+            const img = document.createElement('img')
+            img.className = 'pad-glyph'
+            img.alt = ''
+            img.draggable = false
+            img.src = spriteUrl(glyphFor(padKind, l.button === 'charged' ? 'charge' : l.button))
+            const text = document.createElement('span')
+            text.textContent = l.label
+            row.append(img, text)
+            return row
+          }),
+        )
+      } else padLabel.textContent = note
+      padLabel.hidden = !note
+    }
+    if (note) padLabel.style.bottom = `${controls.insets().bottom + 8}px`
     snapCamera = false
     pruneAnimation(anim, time)
     autoStep()
@@ -1557,7 +1853,9 @@ await engineInit(
     // A dialog's clip first: it uses the canvas for a moment, then clears it (ui/clip.ts).
     drawClips()
     drawBoard({
-      state, danger, selected, hover, destination, path, armed, ...anim, deadly: deadlyTargets, refused,
+      state, danger, selected, hover: aimShown() && !cursor && aim?.target ? aim.target : hover, target: aimShown() ? (cursor?.tile ?? aim!.target ?? state.player) : null, destination, path, armed, ...anim, deadly: deadlyTargets, refused,
+      // The controller's cursor: every actionable tile blue, and the result of the first action there, faded.
+      lit: aimShown() && cursor ? cursor.tiles : undefined, deadlyCursor: aimShown() && !!cursor && cursorDeadly(cursor.actions), ghost: aimShown() && cursor ? ghostAction(cursor.actions, cursor.tile, state.player) : null,
       // A deadly next move shows as a blocked one: the first tile red.
       route: destination && !deadlyAutoMove ? autoRoute(state, destination, safeStep) : null,
       pathBlocked: (!!path?.[0] && !safeStep(path[0])) || deadlyAutoMove,
diff --git a/src/render/board.ts b/src/render/board.ts
index 0ed05a4..454c029 100644
--- a/src/render/board.ts
+++ b/src/render/board.ts
@@ -46,6 +46,12 @@ export interface View {
   state: GameState
   danger: Danger
   selected: ActionId | null
+  /** Tiles lit blue with nothing picked: the controller's cursor shows every actionable tile. */
+  lit?: Vec[]
+  /** The cursor's tile is red: everything possible there would cost a life. */
+  deadlyCursor?: boolean
+  /** An action and tile to preview as if hovered (the faded bomb, blast or player), with nothing picked: the controller's cursor. */
+  ghost?: { id: ActionId; target: Vec } | null
   /** The tile under the pointer or finger, for the commit preview. */
   hover: Vec | null
   flames: Flame[]
@@ -58,6 +64,8 @@ export interface View {
   route?: Vec[] | null
   /** The next auto-move step is unsafe, so Go and the flag won't take it this turn. */
   pathBlocked: boolean
+  /** The controller's current target: a dashed square, drawn last, over everything. */
+  target?: Vec | null
   /** A tile a long press has armed for a bomb. */
   armed: Vec | null
   /** The picked button's tiles that would cost a life this turn (sim/danger.ts `isDeadly`): red. */
@@ -244,6 +252,12 @@ export function drawBoard(view: View): void {
       drawCross(toWorld(s, t), C.deadlyCross)
     } else drawRect(toWorld(s, t), vec2(0.94), on ? C.targetHover : C.target)
   }
+  for (const t of view.lit ?? []) drawRect(toWorld(s, t), vec2(0.94), C.target)
+  // The controller's cursor on a tile where everything possible costs a life: red with a cross, as a deadly target is.
+  if (view.deadlyCursor && view.target) {
+    drawRect(toWorld(s, view.target), vec2(0.94), C.deadlyHover)
+    drawCross(toWorld(s, view.target), C.deadlyCross)
+  }
   // A tap refused because it would cost a life: the tile flashes red, then fades.
   const flash = view.refused ? 1 - (time - view.refused.at) / REFUSED_SECONDS : 0
   if (view.refused && flash > 0) {
@@ -263,9 +277,11 @@ export function drawBoard(view: View): void {
   // The commit preview: the picked action played on a copy, then whatever it changed drawn faded.
   const after = view.armed
     ? previewOf(s, 'bomb', view.armed)
-    : selected && hovered
-      ? previewOf(s, resolvePick(s, selected, hovered) ?? selected, hovered)
-      : null
+    : view.ghost
+      ? previewOf(s, view.ghost.id, view.ghost.target)
+      : selected && hovered
+        ? previewOf(s, resolvePick(s, selected, hovered) ?? selected, hovered)
+        : null
   const changedBombs = after ? after.bombs.filter((b) => !s.bombs.some((o) => eq(o.pos, b.pos) && o.timer === b.timer)) : []
   for (const b of changedBombs) {
     for (const t of blastTiles(after!, b)) drawRect(toWorld(s, t), vec2(0.7), C.preview)
@@ -351,6 +367,8 @@ export function drawBoard(view: View): void {
   drawPlayer(s, view.playerMove, view.playerPose, view.facingLeft)
   // Hidden in a bush: leaves over the player too, see-through, so they still know where they are.
   if (s.bushes.includes(idx(s, s.player)) && at.distance(toWorld(s, s.player)) < 0.01) drawTile(toWorld(s, s.player).add(vec2(0, 0.04)), vec2(1.02), sprite(bushSprite(idx(s, s.player))), C.white.scale(1, 0.55))
+  // The controller's target, over everything, breathing a little so it is easy to find.
+  if (view.target) drawTile(toWorld(s, view.target), vec2(1 + 0.04 * Math.sin(time * 6)), sprite('crosshair041'))
 }
 
 /** Where the player is on screen right now: sliding between tiles while walking, otherwise on their tile. */
diff --git a/src/render/camera.ts b/src/render/camera.ts
index a473166..edcca05 100644
--- a/src/render/camera.ts
+++ b/src/render/camera.ts
@@ -1,5 +1,6 @@
 import { mainCanvasSize, setCameraPos, setCameraScale, vec2 } from 'littlejsengine'
 import type { GameState } from '../sim/types'
+import { revealShift } from './reveal'
 
 /** CSS pixels covered at the top (HUD) and bottom (action bar) of the canvas. */
 export interface Insets {
@@ -35,6 +36,22 @@ let pan = { x: 0, y: 0 }
 let panning = false
 /** The view was put somewhere by a drag, and stays there; otherwise it follows the player. */
 let held = false
+/** Where the view is heading (its centre, world units), for `revealTile`: `center` lags behind it while easing. */
+let aim = { x: 0, y: 0 }
+
+/**
+ * The controller's target is on tile (x, y) of a board `height` rows tall: if it has come within a
+ * tile of the view's edge, move the view just far enough to keep it on screen (eased, like a
+ * finished drag).
+ */
+export function revealTile(x: number, y: number, width: number, height: number): void {
+  const shift = revealShift(aim, { x: x + 0.5, y: height - y - 0.5 }, VIEW_COLUMNS, VIEW_ROWS, 1, { w: width, h: height })
+  // A view the board won't let move (already at its edge) is left alone, and not marked as held.
+  if (shift.x === 0 && shift.y === 0) return
+  panView(shift.x, shift.y)
+  endPan()
+  aim = { x: aim.x + shift.x, y: aim.y + shift.y }
+}
 
 /** A drag on the board moves the view by this much (world units; x right, y up). */
 export function panView(dx: number, dy: number): void {
@@ -95,6 +112,7 @@ export function updateCamera(s: GameState, insets: Insets, snap = false, whole =
     y: follow(base.y + (whole ? 0 : pan.y), s.height, rows),
   }
   if (!whole) pan = { x: target.x - base.x, y: target.y - base.y }
+  aim = target
   center = !center || snap || panning ? target : { x: center.x + (target.x - center.x) * 0.2, y: center.y + (target.y - center.y) * 0.2 }
   rect = { left: center.x - columns / 2, right: center.x + columns / 2, bottom: center.y - rows / 2, top: center.y + rows / 2 }
 
diff --git a/src/render/reveal.ts b/src/render/reveal.ts
new file mode 100644
index 0000000..dac7aec
--- /dev/null
+++ b/src/render/reveal.ts
@@ -0,0 +1,33 @@
+/**
+ * How far to move a view so a tile is not within `margin` tiles of its edge (the controller's target).
+ * Pure, so it can be tested without the engine. `aim` is the view's centre and `tile` the tile's
+ * centre, both in world units; `cols` and `rows` are how many tiles the view shows. Zero on an
+ * axis where the tile is already clear of the edge.
+ */
+export function revealShift(
+  aim: { x: number; y: number },
+  tile: { x: number; y: number },
+  cols: number,
+  rows: number,
+  margin = 1,
+  board?: { w: number; h: number },
+): { x: number; y: number } {
+  const axis = (c: number, a: number, size: number): number => {
+    const d = c - a
+    // The gap between the tile's near edge and the view's edge is half the view minus the
+    // tile's distance from the centre minus half a tile; push it back out to `margin`.
+    const over = Math.abs(d) + 0.5 + margin - size / 2
+    return over > 0 ? Math.sign(d) * over : 0
+  }
+  // With `board` (world units, the view stays inside it; a board smaller than the view is centred
+  // and never moves), only the part of the shift the view can really make is returned.
+  const fit = (shift: number, a: number, view: number, size: number | undefined): number => {
+    if (shift === 0 || size === undefined) return shift
+    if (size <= view) return 0
+    return Math.min(size - view / 2, Math.max(view / 2, a + shift)) - a
+  }
+  return {
+    x: fit(axis(tile.x, aim.x, cols), aim.x, cols, board?.w),
+    y: fit(axis(tile.y, aim.y, rows), aim.y, rows, board?.h),
+  }
+}
diff --git a/src/render/sprites.ts b/src/render/sprites.ts
index be14292..d8ef0b2 100644
--- a/src/render/sprites.ts
+++ b/src/render/sprites.ts
@@ -9,15 +9,15 @@ import type { ChargeId, EnemyKind, GemColor, PowerUpKind } from '../sim/types'
  * `assets/kenney-splat/`: white splats from Kenney's Splat Pack (CC0; approved by the user,
  * 2026-10-24), scaled to 128, tinted when drawn (the worn floor, oil).
  */
-const URLS = import.meta.glob<string>(['../../assets/kenney/*.png', '../../assets/composed/*.png', '../../assets/kenney-splat/*.png'], {
+const URLS = import.meta.glob<string>(['../../assets/kenney/*.png', '../../assets/composed/*.png', '../../assets/kenney-splat/*.png', '../../assets/kenney-crosshair/crosshair041.png', '../../assets/kenney-input-prompts/*.png'], {
   eager: true,
   query: '?url',
   import: 'default',
 })
 
 export function spriteUrl(name: string): string {
-  const url = URLS[`../../assets/kenney/${name}.png`] ?? URLS[`../../assets/composed/${name}.png`] ?? URLS[`../../assets/kenney-splat/${name}.png`]
-  if (!url) throw new Error(`Missing sprite: ${name}.png in assets/kenney or assets/composed`)
+  const url = URLS[`../../assets/kenney/${name}.png`] ?? URLS[`../../assets/composed/${name}.png`] ?? URLS[`../../assets/kenney-splat/${name}.png`] ?? URLS[`../../assets/kenney-crosshair/${name}.png`] ?? URLS[`../../assets/kenney-input-prompts/${name}.png`]
+  if (!url) throw new Error(`Missing sprite: ${name}.png in the assets folders the game loads from`)
   return url
 }
 
@@ -135,6 +135,8 @@ const BOARD = [
   'grass_d',
   'canopy_a',
   'canopy_b',
+  // The controller's target square (Kenney Crosshair Pack, assets/kenney-crosshair).
+  'crosshair041',
 ] as const
 
 export type BoardSprite = (typeof BOARD)[number]
@@ -196,7 +198,7 @@ export function wallSprite(level: number, width: number, height: number, p: { x:
 
 /**
  * The icon for each charge, on the board (as a drop), in the top bar while it lasts, and as a
- * bomb's badge (user's choices, 2026-10-23): Pierce is the Wall Pass icon, Power the Fire+ icon;
+ * bomb's badge (user's choices, 2026-10-23): Pierce is the Crate Pass icon, Power the Fire+ icon;
  * Nuke a bomb with a skull badge; Shield the
  * figure in an empty ring; Freeze a locked padlock; Decoy three pawns; ICBM a bomb with a target;
  * Acid a bomb with a flask (tools/compose-icons.sh).
@@ -254,7 +256,7 @@ export const GEM_SPRITES: Record<GemColor, BoardSprite> = {
  * (blast), with a timer (fuse), with an arrow right (kick) or up-right (throw), with ! (Detonator)
  * or a shield (Bomb Pass), with a forward arrow (RPG) or a warning sign (Proximity alert); the Move
  * figure with fast-forward (Run), an outbox (Teleport) or a two-way arrow (Slide); a crate with ✕
- * (Wall Pass); flame with a shield (Flame Pass). The rest by meaning.
+ * (Crate Pass); flame with a shield (Flame Pass). The rest by meaning.
  */
 export const POWER_UP_ICONS: Record<PowerUpKind, BoardSprite> = {
   bombPlus: 'powerup_bomb_plus',
diff --git a/src/sim/demo.ts b/src/sim/demo.ts
index e9689cb..89fcf63 100644
--- a/src/sim/demo.ts
+++ b/src/sim/demo.ts
@@ -349,7 +349,7 @@ export const SCENES: Record<SceneKind, SceneDef> = {
     shows: (t) => t.action.id === 'icbm' && kills(t).length > 0,
   },
   wallPass: {
-    caption: 'Wall Pass: walk through crates.',
+    caption: 'Crate Pass: walk through crates.',
     enemies: ['blob'],
     prepare: (s) => (s.powers.wallPass = true),
     shows: (t) => t.events.some((e) => e.type === 'moved' && t.prev.cells[idx(t.prev, e.to)] === 'block'),
diff --git a/src/sim/state.ts b/src/sim/state.ts
index 42e0f27..95529d4 100644
--- a/src/sim/state.ts
+++ b/src/sim/state.ts
@@ -102,7 +102,7 @@ export function carryOf(s: GameState): Carry {
 /**
  * `p` if it is open floor (no wall or crate), else the nearest
  * open floor to it, by steps over the board (through anything but walls); a fallen charge can't
- * lie inside a crate (with Wall Pass the player can die in one), nor an immortal player in rubble.
+ * lie inside a crate (with Crate Pass the player can die in one), nor an immortal player in rubble.
  */
 export function nearestFloor(s: GameState, p: Vec): Vec {
   const seen = new Set([idx(s, p)])
@@ -183,7 +183,7 @@ export function isBurning(s: GameState, p: Vec): boolean {
   return inBounds(s, p) && s.burning.includes(idx(s, p))
 }
 
-/** Where the player's body can go, fire aside: floor, or a soft block with Wall Pass; a bomb only with Bomb Pass. */
+/** Where the player's body can go, fire aside: floor, or a soft block with Crate Pass; a bomb only with Bomb Pass. */
 export function playerCanPass(s: GameState, p: Vec): boolean {
   const cell = cellAt(s, p)
   return (cell === 'floor' || (cell === 'block' && s.powers.wallPass)) && (!bombAt(s, p) || s.powers.bombPass)
diff --git a/src/style.css b/src/style.css
index 94022e8..2bcf7af 100644
--- a/src/style.css
+++ b/src/style.css
@@ -241,6 +241,29 @@ img {
   }
 }
 
+/* The controller's target: what confirming does (main.ts `padLabel`), above the action bar; bottom is set from the bar's height. */
+#pad-label {
+  white-space: pre-line;
+  position: fixed;
+  left: 50%;
+  bottom: 96px;
+  z-index: 3;
+  max-width: calc(100vw - 32px);
+  transform: translateX(-50%);
+  padding: 8px 16px;
+  border: 2px solid var(--ink);
+  border-radius: 16px;
+  background: #ece3cc;
+  color: var(--ink);
+  font-weight: bold;
+  text-align: center;
+  pointer-events: none;
+}
+
+#pad-label[hidden] {
+  display: none;
+}
+
 #actions {
   position: fixed;
   bottom: 0;
@@ -357,6 +380,49 @@ img {
   color: #e2fff2;
 }
 
+/* The controller's picture of the button, beside the icon (shown while the pad is in use, body.pad,
+   set and cleared in main.ts; the key hint goes then). The pair sits on one row and shrinks
+   together on a narrow bar: the icon is 36px, the picture 32px (about 90% of it), each able to
+   shrink to fit the button. The button keeps its height (min-height 56px). */
+#actions .pad-glyph {
+  display: none;
+  pointer-events: none;
+  /* Nintendo's buttons are white: a hairline keeps them from vanishing on the light button. */
+  filter: drop-shadow(0 0 1px rgb(0 0 0 / 0.55));
+}
+
+body.pad #actions > button {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 4px;
+}
+
+body.pad #actions .icon,
+body.pad #actions .slot {
+  flex: 0 1 36px;
+  min-width: 0;
+  height: auto;
+  aspect-ratio: 1;
+}
+
+body.pad #actions .slot {
+  flex-basis: 30px;
+}
+
+body.pad #actions .pad-glyph {
+  display: block;
+  flex: 0 1 32px;
+  width: 32px;
+  min-width: 0;
+  height: auto;
+  aspect-ratio: 1;
+}
+
+body.pad #actions kbd {
+  display: none;
+}
+
 #banner {
   position: fixed;
   inset: 0;
@@ -1800,3 +1866,104 @@ canvas.clip[hidden] {
 #demo .timeline {
   margin-top: 10px;
 }
+
+/* The controller in dialogs and the banner (ui/padDialog.ts): the pictures of the face buttons on
+   the buttons they press, and the selector over the element the d-pad is on. Only while the pad
+   is in use (body.pad). */
+#dialog .pad-glyph,
+#banner .pad-glyph {
+  display: none;
+  width: 22px;
+  height: 22px;
+  flex: none;
+  pointer-events: none;
+}
+
+body.pad #dialog .pad-glyph,
+body.pad #banner .pad-glyph {
+  display: inline-block;
+  vertical-align: middle;
+}
+
+#banner .pad-glyph {
+  margin-left: 8px;
+}
+
+/* A shop item's Buy shows its Cross picture only on the selected item. */
+.shop-item .buy .pad-glyph {
+  display: none !important;
+}
+
+body.pad .shop-item[aria-pressed='true'] .buy .pad-glyph {
+  display: inline-block !important;
+}
+
+/* The X (when no footer button is the Circle one) carries its picture beside it. */
+body.pad #dialog-close .pad-glyph {
+  display: block;
+  position: absolute;
+  top: -6px;
+  right: -6px;
+  width: 24px;
+  height: 24px;
+}
+
+/* Not over the demo's scene: it has no pad to speak of. */
+#dialog.in-demo .pad-glyph {
+  display: none !important;
+}
+
+body.pad #dialog .pad-sel {
+  outline: 4px solid #ffc83d;
+  outline-offset: 2px;
+  border-radius: 10px;
+}
+
+/* The controller's cursor (main.ts, padTiles.ts): the action bar shows what the cursor's tile
+   offers. Not possible there: greyed. Costs a life: red outline and a warning mark. A press on a
+   greyed one flashes it. */
+body.pad #actions > button[data-pad='off'] {
+  opacity: 0.35;
+  filter: grayscale(1);
+}
+
+body.pad #actions > button[data-pad='danger'] {
+  position: relative;
+  outline: 3px solid #e5484d;
+  outline-offset: -3px;
+}
+
+body.pad #actions > button[data-pad='danger']::after {
+  content: '\26A0';
+  position: absolute;
+  top: -1px;
+  right: 3px;
+  color: #e5484d;
+  font-size: 12px;
+  line-height: 1;
+}
+
+#actions > button.pad-flash {
+  animation: pad-flash 0.35s;
+}
+
+@keyframes pad-flash {
+  0%, 100% { transform: none; }
+  25% { transform: translateX(-4px); }
+  75% { transform: translateX(4px); }
+}
+
+/* The cursor's label: one row per button, its picture first (the same size rule as the action bar). */
+#pad-label .pad-line {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  text-align: left;
+}
+
+#pad-label .pad-glyph {
+  flex: none;
+  width: 28px;
+  height: 28px;
+  filter: drop-shadow(0 0 1px rgb(0 0 0 / 0.55));
+}
diff --git a/src/ui/controls.ts b/src/ui/controls.ts
index d24bb1b..5f40b82 100644
--- a/src/ui/controls.ts
+++ b/src/ui/controls.ts
@@ -3,6 +3,8 @@ import { CLEAR_BONUS, PAR_BONUS, START_LIVES } from '../sim/state'
 import { CHARGE_IDS, type ActionId, type GameState, type PowerUpKind, type Powers } from '../sim/types'
 import { CHARGE_ICONS, MONO_ICONS, POWER_UP_ICONS, spriteUrl } from '../render/sprites'
 import { CHARGE_TEXT, chargeLeft, deathText, type HudItem } from './info'
+import { glyphFor, type GlyphButton } from './padGlyph'
+import type { PadFamily } from './padMap'
 import { openScores } from './scores'
 import questionIcon from '../../assets/kenney-icons/question.png'
 
@@ -54,7 +56,7 @@ const PASSES: [keyof Powers, PowerUpKind, string][] = [
   ['throw', 'throw', 'Throw'],
   ['diagonal', 'diagonal', 'Diagonal'],
   ['detonate', 'detonator', 'Detonator'],
-  ['wallPass', 'wallPass', 'Wall Pass'],
+  ['wallPass', 'wallPass', 'Crate Pass'],
   ['bombPass', 'bombPass', 'Bomb Pass'],
   ['rpg', 'rpg', 'RPG'],
   ['proximity', 'proximity', 'Proximity alert'],
@@ -106,6 +108,10 @@ function number(n: number, label: string): string {
 
 export function createControls(handlers: Handlers) {
   const bar = document.getElementById('actions')!
+  // The controller's picture for each button, shown only while the pad is in use (style.css `.pad-glyph`).
+  let family: PadFamily = 'generic'
+  const glyphButton = (def: ButtonDef): GlyphButton => (def.id === 'charged' ? 'charge' : (def.id as GlyphButton))
+  const glyph = (def: ButtonDef): string => `<img class="pad-glyph" src="${spriteUrl(glyphFor(family, glyphButton(def)))}" alt="" draggable="false">`
   const buttons = new Map<ButtonDef, HTMLButtonElement>()
   for (const def of BUTTONS) {
     const b = document.createElement('button')
@@ -113,7 +119,7 @@ export function createControls(handlers: Handlers) {
     b.dataset.action = def.id
     b.title = def.label
     b.setAttribute('aria-label', def.label)
-    b.innerHTML = `${icon(def.icon)}<kbd>${def.key}</kbd>`
+    b.innerHTML = `${icon(def.icon)}<kbd>${def.key}</kbd>${glyph(def)}`
     b.addEventListener('click', () => {
       const id = def.group.find((a) => canPick(current!, a)) ?? def.id
       handlers.pick(id, !!currentSelected && def.group.includes(currentSelected))
@@ -184,8 +190,8 @@ export function createControls(handlers: Handlers) {
             b.dataset.kind = shown
             const name = kind ? CHARGE_TEXT[kind].name : 'Charge'
             b.innerHTML = kind
-              ? `${icon(CHARGE_ICONS[kind])}<span class="count">${s.charge!.left}</span><kbd>${def.key}</kbd>`
-              : `<span class="slot" aria-hidden="true"></span><kbd>${def.key}</kbd>`
+              ? `${icon(CHARGE_ICONS[kind])}<span class="count">${s.charge!.left}</span><kbd>${def.key}</kbd>${glyph(def)}`
+              : `<span class="slot" aria-hidden="true"></span><kbd>${def.key}</kbd>${glyph(def)}`
             b.title = kind ? name : 'Charge: none carried'
             b.setAttribute('aria-label', kind ? `${name} charge, ${s.charge!.left} left` : 'Charge: none carried')
           }
@@ -280,6 +286,28 @@ export function createControls(handlers: Handlers) {
     setBannerNote(text: string): void {
       bannerNote = text
     },
+    /** The kind of pad in use, for the pictures on the buttons (padMap.ts `padFamily`). */
+    setPadFamily(next: PadFamily): void {
+      family = next
+      for (const [def, b] of buttons) b.querySelector<HTMLImageElement>('.pad-glyph')?.setAttribute('src', spriteUrl(glyphFor(family, glyphButton(def))))
+    },
+    /** The controller's cursor tile: what each bar button does there, as 'ok', 'off' (not possible) or 'danger' (costs a life); null when no cursor shows. */
+    setPadCursor(states: Partial<Record<ActionId, 'ok' | 'off' | 'danger'>> | null): void {
+      for (const [def, b] of buttons) {
+        const v = states?.[def.id]
+        if (v) b.dataset.pad = v
+        else delete b.dataset.pad
+      }
+    },
+    /** A button the cursor's tile cannot use was pressed: it flashes. */
+    flashPad(id: ActionId): void {
+      const b = bar.querySelector<HTMLButtonElement>(`button[data-action="${id}"]`)
+      if (!b) return
+      b.classList.remove('pad-flash')
+      void b.offsetWidth
+      b.classList.add('pad-flash')
+      setTimeout(() => b.classList.remove('pad-flash'), 350)
+    },
     /** True if the banner is showing; Enter presses its button. */
     pressBanner(): boolean {
       if (banner.hidden) return false
diff --git a/src/ui/dialog.ts b/src/ui/dialog.ts
index 5e55e74..52f64f4 100644
--- a/src/ui/dialog.ts
+++ b/src/ui/dialog.ts
@@ -63,6 +63,11 @@ export function closeDialog(then?: () => void): void {
   closing = { timer: window.setTimeout(finishClosing, CLOSE_MS), then }
 }
 
+/** Closes the dialog if it can be dismissed: what Esc does, for the controller's Circle. */
+export function dismissDialog(): void {
+  if (!required) closeDialog()
+}
+
 function finishClosing(): void {
   if (!closing) return
   const { timer, then } = closing
@@ -124,8 +129,13 @@ export function openDialog(content: DialogContent): void {
   // The body is one element for every dialog, so it kept the last one's scroll: each opens at the
   // top (user report, 2026-10-24). After showing, as a closed dialog has no layout to scroll.
   body.scrollTop = 0
+  // For the controller (padDialog.ts), which starts its selector afresh on a new dialog.
+  dialog.dispatchEvent(new Event('dialogopen'))
 }
 
+/** The menu's title: the controller's Start button closes a dialog only if it is this one. */
+export const MENU_TITLE = 'Menu'
+
 export function dialogOpen(): boolean {
   return dialog.open
 }
diff --git a/src/ui/gamepad.ts b/src/ui/gamepad.ts
new file mode 100644
index 0000000..f97fe64
--- /dev/null
+++ b/src/ui/gamepad.ts
@@ -0,0 +1,115 @@
+import { mapPad, newPadMemory, padFamily, type PadButton, type PadFamily, type PadMemory, type PadSnapshot } from './padMap'
+
+/**
+ * Reads the first connected gamepad once a frame and hands what the player did to main.ts
+ * (controller support, stage 2). The Gamepad API is read through `getPads`, so a test can feed it
+ * plain objects; every rule about what a button means lives in padMap.ts.
+ */
+
+/** The part of a `Gamepad` this reads. */
+export interface PadLike {
+  index: number
+  id: string
+  connected: boolean
+  mapping: string
+  buttons: ReadonlyArray<{ pressed: boolean }>
+  axes: ReadonlyArray<number>
+}
+
+/** What the game does with the pad: main.ts supplies these. */
+export interface PadTargets {
+  /** The d-pad, one step (a held one repeats); the left stick too when it is enabled (padMap.ts). */
+  dir(dx: -1 | 0 | 1, dy: -1 | 0 | 1): void
+  /** `face`: the raw face-button index (0 Cross, 1 Circle, 2 Square, 3 Triangle) when it is one. */
+  press(button: PadButton, face?: 0 | 1 | 2 | 3): void
+  /** The right stick, while pushed: -1..1, y positive down. Called every poll. */
+  look(dx: number, dy: number): void
+  lookEnd(): void
+  /** Any pad input at all (the pad is now what the player is using). */
+  active(): void
+  toast(title: string, text: string): void
+  /** A standard pad connected: which kind, for the pictures on the buttons. */
+  family?(family: PadFamily): void
+  /** Reading the pad threw: polling stops (reported here, once). */
+  error?(error: unknown): void
+}
+
+const FAMILY_NAMES = { xbox: 'Xbox', playstation: 'PlayStation', nintendo: 'Nintendo', generic: 'Generic' } as const
+
+/** The first connected pad with the standard layout; failing that, the first connected one (only to say it is not recognised). */
+function choose(pads: ArrayLike<PadLike | null>): PadLike | null {
+  let other: PadLike | null = null
+  for (let i = 0; i < pads.length; i++) {
+    const p = pads[i]
+    if (!p?.connected) continue
+    if (p.mapping === 'standard') return p
+    other ??= p
+  }
+  return other
+}
+
+export function createPadInput(
+  targets: PadTargets,
+  getPads: () => ArrayLike<PadLike | null> = () => navigator.getGamepads?.() ?? [],
+  hasFocus: () => boolean = () => document.hasFocus(),
+) {
+  let current: { index: number; id: string } | null = null
+  let memory: PadMemory = newPadMemory()
+  let failed = false
+
+  /** A fresh memory (a pad that came, left or was swapped; the window lost focus), ending a look that was under way. */
+  function forget(): void {
+    if (memory.looking) targets.lookEnd()
+    memory = newPadMemory()
+  }
+
+  function read(nowMs: number): void {
+    // A pad pressed while another window has focus is not for this game. Memory restarts, so a
+    // button held across the switch is not a press when the window comes back.
+    if (!hasFocus()) return forget()
+    const pad = choose(getPads())
+    if (!pad) {
+      // Unplugged: forget it, so the next one (or this one again) is announced and starts fresh.
+      if (current) forget()
+      current = null
+      return
+    }
+    if (!current || current.index !== pad.index || current.id !== pad.id) {
+      current = { index: pad.index, id: pad.id }
+      forget()
+      if (pad.mapping === 'standard') {
+        targets.active()
+        targets.family?.(padFamily(pad.id))
+        targets.toast('Controller connected', FAMILY_NAMES[padFamily(pad.id)])
+      } else {
+        targets.toast('Controller not recognised', '(non-standard layout)')
+      }
+    }
+    if (pad.mapping !== 'standard') return
+    const snap: PadSnapshot = {
+      buttons: pad.buttons.map((b) => b.pressed),
+      axes: [...pad.axes],
+      standard: true,
+    }
+    for (const intent of mapPad(snap, memory, nowMs)) {
+      targets.active()
+      if (intent.kind === 'dir') targets.dir(intent.dx, intent.dy)
+      else if (intent.kind === 'press') targets.press(intent.button, intent.face)
+      else if (intent.kind === 'look') targets.look(intent.dx, intent.dy)
+      else targets.lookEnd()
+    }
+  }
+
+  return {
+    poll(nowMs: number): void {
+      if (failed) return
+      try {
+        read(nowMs)
+      } catch (error) {
+        // Never into the game's frame: the pad is given up on, and the keyboard and touch go on.
+        failed = true
+        targets.error?.(error)
+      }
+    },
+  }
+}
diff --git a/src/ui/info.ts b/src/ui/info.ts
index 177b183..bb51dbe 100644
--- a/src/ui/info.ts
+++ b/src/ui/info.ts
@@ -41,7 +41,7 @@ export const POWER_UP_TEXT: Record<PowerUpKind, { name: string; text: string }>
   fusePlus: { name: 'Fuse +1', text: 'Your bombs start one higher: one more turn to get away.' },
   run: { name: 'Run', text: `Move can also run: 2 tiles in a straight line in one turn. Both tiles must be empty. With Teleport, it teleports ${TELEPORT_RANGE + 1} steps instead of ${TELEPORT_RANGE}.` },
   detonator: { name: 'Detonator', text: 'Your bombs pulse: tap one and it goes off this turn.' },
-  wallPass: { name: 'Wall Pass', text: 'You can walk through crates.' },
+  wallPass: { name: 'Crate Pass', text: 'You can walk through crates.' },
   bombPass: { name: 'Bomb Pass', text: 'You can walk over bombs.' },
   star: { name: 'Star', text: `Nothing can hurt you for ${STAR_TURNS} turns.` },
   coinBag: { name: 'Coin bag', text: `${COIN_BAG} coins.` },
diff --git a/src/ui/padDialog.ts b/src/ui/padDialog.ts
new file mode 100644
index 0000000..13756c5
--- /dev/null
+++ b/src/ui/padDialog.ts
@@ -0,0 +1,240 @@
+import { dismissDialog } from './dialog'
+import { faceGlyph } from './padGlyph'
+import { assignFaces, dismissIndex, faceAction, startIndex, padMode, restoreIndex, scrollDelta, tabStep, type Face, type FooterButton } from './padDialogMap'
+import type { PadFamily } from './padMap'
+import { nextFocus, type Step } from './spatial'
+import { spriteUrl } from '../render/sprites'
+
+/**
+ * The controller in dialogs and the banner (controller support, stage 3): DOM glue around the
+ * rules in padDialogMap.ts (those are tested; this reads and clicks the page, which node tests
+ * cannot). The footer's buttons get face buttons and their pictures; where that does not fit, a
+ * selector (outline, only under body.pad) moves over everything with the d-pad. It watches the
+ * dialog, so a body rebuilt in place (the shop after a purchase, an almanac tab) is picked up
+ * again with the selector kept at the same place.
+ */
+const dialog = document.getElementById('dialog') as HTMLDialogElement
+const panel = dialog.querySelector<HTMLElement>('.panel')!
+const body = document.getElementById('dialog-body')!
+const footer = document.getElementById('dialog-buttons')!
+const closeButton = document.getElementById('dialog-close')!
+const banner = document.getElementById('banner')!
+
+let family: PadFamily = 'generic'
+let sel: HTMLElement | null = null
+let selIndex = 0
+let faces: (Face | null)[] | null = null
+let mode: 'faces' | 'selector' = 'faces'
+/** The footer button Circle presses (-1: none, the dialog just closes). */
+let dismiss = -1
+
+const SELECTABLE = 'button, [role="button"], a[href="#"], input[type="checkbox"], input[type="radio"]'
+
+const isCard = (el: HTMLElement): boolean => el.classList.contains('offer-choice')
+
+function glyph(face: Face): HTMLImageElement {
+  const img = document.createElement('img')
+  img.className = 'pad-glyph'
+  img.alt = ''
+  img.draggable = false
+  img.dataset.face = String(face)
+  img.src = spriteUrl(faceGlyph(family, face))
+  return img
+}
+
+/** What the selector can be on in the body: cards, buttons, links and switches (a checkbox by its label). A text field is skipped on purpose. */
+function bodyItems(): HTMLElement[] {
+  const out: HTMLElement[] = []
+  for (const raw of body.querySelectorAll<HTMLElement>(SELECTABLE)) {
+    if ((raw as HTMLButtonElement).disabled || raw.closest('[hidden]') || raw.classList.contains('sold')) continue
+    // Inside a card (the shop's Buy): Cross reaches it through the card.
+    const card = raw.closest<HTMLElement>('.offer-choice')
+    if (card && card !== raw) continue
+    const el = raw instanceof HTMLInputElement ? (raw.closest<HTMLElement>('label') ?? raw) : raw
+    if (!out.includes(el)) out.push(el)
+  }
+  return out
+}
+
+function footerButtons(): HTMLButtonElement[] {
+  return [...footer.querySelectorAll<HTMLButtonElement>('button')]
+}
+
+/** What the selector moves over now. */
+function list(): HTMLElement[] {
+  return mode === 'faces' ? bodyItems() : [...bodyItems(), ...footerButtons().filter((b) => !b.disabled)]
+}
+
+function activate(el: HTMLElement): void {
+  const input = el instanceof HTMLLabelElement ? el.querySelector('input') : null
+  ;(input ?? el).click()
+}
+
+/** Scrolls the body just enough to show `el`, clear of the sticky tabs, clip or coin line at its top. */
+function reveal(el: HTMLElement): void {
+  if (el.closest('.almanac-tabs, .offer-clip, .shop-coins')) return
+  let sticky = 0
+  for (const c of body.children) if (getComputedStyle(c).position === 'sticky') sticky += (c as HTMLElement).offsetHeight
+  const box = body.getBoundingClientRect()
+  const r = el.getBoundingClientRect()
+  const top = box.top + sticky + 4
+  if (r.top < top) body.scrollBy(0, r.top - top)
+  else if (r.bottom > box.bottom - 4) body.scrollBy(0, r.bottom - box.bottom + 4)
+}
+
+function setSel(el: HTMLElement | null, items: HTMLElement[], scroll = false): void {
+  for (const old of dialog.querySelectorAll('.pad-sel')) old.classList.remove('pad-sel')
+  sel = el
+  selIndex = el ? Math.max(0, items.indexOf(el)) : selIndex
+  el?.classList.add('pad-sel')
+  // Only when the player moved it: a rebuilt dialog may be about to scroll itself back (Back from an entry).
+  if (scroll && el) reveal(el)
+}
+
+/** Reads the dialog afresh: the face buttons and their pictures, and the selector kept (or put on the first element). */
+function refresh(fresh = false): void {
+  observer.disconnect()
+  try {
+    const buttons = footerButtons()
+    const info: FooterButton[] = buttons.map((b) => ({
+      label: b.textContent?.trim() ?? '',
+      kind: b.classList.contains('primary') ? 'primary' : b.classList.contains('danger') ? 'danger' : 'plain',
+    }))
+    const dismissable = !panel.classList.contains('required')
+    const hasBuy = body.querySelector('.shop-item .buy') !== null
+    faces = assignFaces(info, dismissable, hasBuy)
+    dismiss = dismissIndex(info, dismissable)
+    const plainControls = bodyItems().filter((el) => !isCard(el)).length
+    mode = padMode(faces, plainControls)
+    for (const img of dialog.querySelectorAll('.pad-glyph')) img.remove()
+    // Pictures: in faces mode on every footer button; in selector mode only on the one Circle presses.
+    // A shop item's Buy shows Cross (CSS shows it on the selected card only).
+    buttons.forEach((b, i) => {
+      const face = faces?.[i]
+      if (mode === 'faces' && face != null) b.append(glyph(face))
+      else if (mode === 'selector' && i === dismiss) b.append(glyph(1))
+    })
+    for (const buy of body.querySelectorAll('.shop-item .buy')) buy.append(glyph(0))
+    // The X has the Circle picture when no footer button has it.
+    if (dismissable && dismiss < 0) closeButton.append(glyph(1))
+    const items = list()
+    let el: HTMLElement | null
+    if (!fresh && sel?.isConnected && items.includes(sel)) el = sel
+    else if (fresh) {
+      const primary = mode === 'selector' ? buttons.find((b) => b.classList.contains('primary') && !b.disabled) : undefined
+      el = items[startIndex(items.map((x) => ({ primary: x === primary, pressed: x.getAttribute('aria-pressed') === 'true' })))] ?? null
+    }
+    // Rebuilt in place: the card the dialog has selected (the shop moves on when one sells), else the same place.
+    else el = items.find((x) => isCard(x) && x.getAttribute('aria-pressed') === 'true') ?? items[restoreIndex(items.length, selIndex)] ?? null
+    setSel(el, items)
+  } finally {
+    observe()
+    observer.takeRecords()
+  }
+}
+
+const observer = new MutationObserver(() => refresh())
+function observe(): void {
+  observer.observe(body, { childList: true, subtree: true })
+  observer.observe(footer, { childList: true, subtree: true, characterData: true })
+}
+observe()
+// A card chosen with the mouse or keyboard moves the outline to it, so the pad and the click agree.
+body.addEventListener('click', (e) => {
+  const card = (e.target as Element).closest<HTMLElement>('.offer-choice')
+  if (card && card !== sel && !card.classList.contains('sold')) setSel(card, list())
+})
+dialog.addEventListener('dialogopen', () => refresh(true))
+
+/** The banner's visible buttons, in order: Cross, then Triangle, then Square (nothing dismisses it). */
+function bannerButtons(): HTMLButtonElement[] {
+  return ['banner-button', 'banner-button-2', 'banner-scores']
+    .map((id) => document.getElementById(id) as HTMLButtonElement)
+    .filter((b) => !b.hidden)
+}
+
+function bannerFaces(buttons: HTMLButtonElement[]): (Face | null)[] {
+  return assignFaces(buttons.map((b, i) => ({ label: b.textContent?.trim() ?? '', kind: i === 0 ? 'primary' : 'plain' })), false) ?? []
+}
+
+let bannerBusy = false
+function refreshBanner(): void {
+  if (bannerBusy || banner.hidden) return
+  bannerBusy = true
+  bannerObserver.disconnect()
+  try {
+    for (const img of banner.querySelectorAll('.pad-glyph')) img.remove()
+    const buttons = bannerButtons()
+    const f = bannerFaces(buttons)
+    buttons.forEach((b, i) => (f[i] != null ? b.append(glyph(f[i]!)) : undefined))
+  } finally {
+    bannerObserver.observe(banner, { childList: true, subtree: true, attributes: true, attributeFilter: ['hidden'] })
+    bannerObserver.takeRecords()
+    bannerBusy = false
+  }
+}
+const bannerObserver = new MutationObserver(refreshBanner)
+bannerObserver.observe(banner, { childList: true, subtree: true, attributes: true, attributeFilter: ['hidden'] })
+
+/** True while a dialog is closing (it ignores the pad until it has gone). */
+const closing = (): boolean => dialog.classList.contains('closing')
+
+export const padDialog = {
+  /** The pictures follow the kind of pad. */
+  setFamily(next: PadFamily): void {
+    family = next
+    for (const img of document.querySelectorAll<HTMLImageElement>('#dialog .pad-glyph, #banner .pad-glyph')) img.src = spriteUrl(faceGlyph(family, Number(img.dataset.face) as Face))
+  },
+  /** The d-pad: the selector moves; on a card, that selects it (as a click does). */
+  move(dx: Step, dy: Step): void {
+    if (closing()) return
+    const items = list()
+    const from = sel ? items.indexOf(sel) : -1
+    const to = nextFocus(items.map((el) => {
+      const r = el.getBoundingClientRect()
+      return { x: r.x, y: r.y, w: r.width, h: r.height }
+    }), from, dx, dy)
+    const el = items[to]
+    if (!el || el === sel) return
+    setSel(el, items, true)
+    if (isCard(el)) el.click()
+  },
+  /** A face button in a dialog. */
+  face(face: Face): void {
+    if (closing()) return
+    const dismissable = !panel.classList.contains('required')
+    // The shop's selected card is the one marked pressed, however it got selected (pad, mouse or key).
+    const pressed = body.querySelector<HTMLElement>('.shop-item[aria-pressed="true"]')
+    const action = faceAction(face, faces, mode, dismissable, !!pressed?.querySelector('.buy'), dismiss)
+    if (!action) return
+    if (action.kind === 'close') dismissDialog()
+    else if (action.kind === 'footer') footerButtons()[action.index]?.click()
+    else if (action.kind === 'buy') pressed?.querySelector<HTMLElement>('.buy')?.click()
+    else if (sel?.isConnected) activate(sel)
+  },
+  /** LB / RB (delta -1 / 1): the previous or next tab, where the dialog has tabs. */
+  tab(delta: -1 | 1): void {
+    if (closing()) return
+    const tabs = [...body.querySelectorAll<HTMLElement>('[data-tab]')]
+    if (tabs.length === 0) return
+    const now = tabs.findIndex((t) => t.getAttribute('aria-pressed') === 'true')
+    const next = tabStep(tabs.length, now, delta)
+    if (next === now) return
+    tabs[next]!.click()
+    // The tabs were rebuilt by the click: the selector goes to the new tab's button.
+    setSel(body.querySelector<HTMLElement>(`[data-tab="${tabs[next]!.dataset.tab}"]`), list(), true)
+  },
+  /** The right stick scrolls the body. */
+  scroll(dy: number, seconds: number): void {
+    if (!closing()) body.scrollBy(0, scrollDelta(dy, seconds))
+  },
+  /** A face button while the banner shows: true if it pressed one of the banner's buttons. */
+  banner(face: Face): boolean {
+    if (banner.hidden) return false
+    const buttons = bannerButtons()
+    const index = bannerFaces(buttons).indexOf(face)
+    if (index < 0) return false
+    buttons[index]!.click()
+    return true
+  },
+}
diff --git a/src/ui/padDialogMap.ts b/src/ui/padDialogMap.ts
new file mode 100644
index 0000000..e867aa1
--- /dev/null
+++ b/src/ui/padDialogMap.ts
@@ -0,0 +1,96 @@
+/**
+ * What the controller's buttons do in a dialog or the banner (controller support, stage 3). Pure:
+ * padDialog.ts reads the DOM and hands in plain data, so every rule here is unit-tested.
+ *
+ * In a dialog the footer's buttons sit on face buttons: the primary one on Cross, the others, left
+ * to right, on Triangle then Square, and the one that dismisses (Close, Cancel, Back, Got it ...)
+ * on Circle. Where that does not fit (more than three, or controls in the body to move between)
+ * a d-pad selector moves over everything instead, and Cross presses what it is on.
+ */
+
+/** The raw face-button index: 0 Cross/A, 1 Circle/B, 2 Square/X, 3 Triangle/Y. */
+export type Face = 0 | 1 | 2 | 3
+
+export interface FooterButton {
+  label: string
+  kind: 'primary' | 'danger' | 'plain'
+}
+
+/** Labels of buttons that just leave the dialog (or go back a step): Circle presses them. */
+const DISMISS_LABELS = ['Close', 'Cancel', 'Back', 'Got it', 'Keep playing', 'Resume']
+
+/** Index of the button Circle presses, or -1 (Circle then closes the dialog itself). Only a dialog that can be dismissed has one. */
+export function dismissIndex(buttons: readonly FooterButton[], dismissable: boolean): number {
+  if (!dismissable) return -1
+  return buttons.findIndex((b) => DISMISS_LABELS.includes(b.label))
+}
+
+/**
+ * The face button of each footer button (null: none), or null altogether if they do not fit and
+ * the selector must be used. `crossTaken`: Cross belongs to something in the body (the shop's Buy).
+ */
+export function assignFaces(buttons: readonly FooterButton[], dismissable: boolean, crossTaken = false): (Face | null)[] | null {
+  const out: (Face | null)[] = buttons.map(() => null)
+  const dismiss = dismissIndex(buttons, dismissable)
+  if (dismiss >= 0) out[dismiss] = 1
+  const rest = buttons.map((_, i) => i).filter((i) => i !== dismiss)
+  const primary = crossTaken ? -1 : rest.find((i) => buttons[i]!.kind === 'primary') ?? -1
+  if (primary >= 0) out[primary] = 0
+  const spare: Face[] = [3, 2]
+  for (const i of rest) {
+    if (i === primary) continue
+    const face = spare.shift()
+    if (face === undefined) return null
+    out[i] = face
+  }
+  return out
+}
+
+/** Face buttons on the footer, or a selector over everything: the latter when they do not fit or the body has controls to move between. */
+export function padMode(faces: (Face | null)[] | null, bodyControls: number): 'faces' | 'selector' {
+  return faces && bodyControls === 0 ? 'faces' : 'selector'
+}
+
+export type FaceAction = { kind: 'footer'; index: number } | { kind: 'buy' } | { kind: 'focused' } | { kind: 'close' } | null
+
+/**
+ * What face button `face` does. `faces`: the footer's assignment (null in selector mode);
+ * `focusedBuy`: the selector is on a shop card with a Buy button.
+ */
+export function faceAction(face: Face, faces: (Face | null)[] | null, mode: 'faces' | 'selector', dismissable: boolean, focusedBuy: boolean, dismiss = -1): FaceAction {
+  if (mode === 'selector') {
+    if (face === 0) return { kind: 'focused' }
+    if (face !== 1 || !dismissable) return null
+    // The footer's own dismissing button if it has one (Back runs its action), else the dialog just closes.
+    return dismiss >= 0 ? { kind: 'footer', index: dismiss } : { kind: 'close' }
+  }
+  if (face === 0 && focusedBuy) return { kind: 'buy' }
+  const index = faces?.indexOf(face) ?? -1
+  if (index >= 0) return { kind: 'footer', index }
+  return face === 1 && dismissable ? { kind: 'close' } : null
+}
+
+/** Where the selector starts in a fresh dialog in selector mode: the footer's first primary button (Resume, Start), else the item marked pressed (a tab), else the first. Returns an index into `items`. */
+export function startIndex(items: readonly { primary: boolean; pressed: boolean }[]): number {
+  const at = items.findIndex((i) => i.primary)
+  if (at >= 0) return at
+  return Math.max(0, items.findIndex((i) => i.pressed))
+}
+
+/** After the list was rebuilt: where the selector goes (the same place if it can, else the last; -1 for an empty list). */
+export function restoreIndex(count: number, previous: number): number {
+  if (count <= 0) return -1
+  return Math.max(0, Math.min(previous, count - 1))
+}
+
+/** The next or previous tab (delta -1 or 1), staying on the first or last. */
+export function tabStep(count: number, current: number, delta: -1 | 1): number {
+  return Math.max(0, Math.min(count - 1, (current < 0 ? 0 : current) + delta))
+}
+
+/** The right stick scrolls a dialog at this many pixels a second at full push. */
+export const SCROLL_PX_PER_SECOND = 900
+
+export function scrollDelta(dy: number, seconds: number): number {
+  return dy * SCROLL_PX_PER_SECOND * seconds
+}
diff --git a/src/ui/padGlyph.ts b/src/ui/padGlyph.ts
new file mode 100644
index 0000000..c55c6d4
--- /dev/null
+++ b/src/ui/padGlyph.ts
@@ -0,0 +1,34 @@
+import type { PadFamily } from './padMap'
+
+/**
+ * Which controller picture goes on an action-bar button (controller support; Kenney Input
+ * Prompts, assets/kenney-input-prompts). The buttons are the board mapping of the face buttons
+ * (padMap.ts): Move/Go on Cross or A, Bomb on Square or X, Charge on Triangle or Y, Wait on Circle
+ * or B, by position on the pad (bottom, left, top, right). A Nintendo pad has its own letters in
+ * those places; a pad that is none of the three has the same layout (W3C standard mapping), so it
+ * gets the Xbox letters.
+ */
+export type GlyphButton = 'move' | 'bomb' | 'charge' | 'wait'
+
+const NAMES = {
+  playstation: { move: 'cross', bomb: 'square', charge: 'triangle', wait: 'circle' },
+  xbox: { move: 'a', bomb: 'x', charge: 'y', wait: 'b' },
+  // Nintendo's letters sit in other places: the bottom button is B, right A, left Y, top X. The
+  // buttons are named by position (bottom Move, left Bomb, top Charge, right Wait), so this is
+  // the letter at each of those places.
+  nintendo: { move: 'b', bomb: 'y', charge: 'x', wait: 'a' },
+} as const
+
+/** The sprite's name (the file without `.png`) for `button` on a pad of `family`. */
+export function glyphFor(family: PadFamily, button: GlyphButton): string {
+  if (family === 'playstation') return `playstation_button_color_${NAMES.playstation[button]}`
+  if (family === 'nintendo') return `switch_button_${NAMES.nintendo[button]}`
+  return `xbox_button_color_${NAMES.xbox[button]}`
+}
+
+/** The picture for a raw face button (0 Cross/A, 1 Circle/B, 2 Square/X, 3 Triangle/Y), as dialogs use them. */
+const FACE_BUTTONS: readonly GlyphButton[] = ['move', 'wait', 'bomb', 'charge']
+
+export function faceGlyph(family: PadFamily, face: 0 | 1 | 2 | 3): string {
+  return glyphFor(family, FACE_BUTTONS[face]!)
+}
diff --git a/src/ui/padMap.ts b/src/ui/padMap.ts
new file mode 100644
index 0000000..71e3cd2
--- /dev/null
+++ b/src/ui/padMap.ts
@@ -0,0 +1,192 @@
+import type { Step } from './spatial'
+
+/**
+ * Turns a gamepad's raw state into what the game does with it (controller support, stage 1). Pure:
+ * the caller reads `navigator.getGamepads()` once a frame, copies it into a `PadSnapshot`, and
+ * acts on the intents that come back. Keeping the Gamepad API, the DOM and LittleJS out of here
+ * is what lets every rule below be unit-tested with plain numbers and a fake clock.
+ */
+
+/** One frame of a pad. Indexes are the W3C "standard" mapping, which browsers give for most pads. */
+export interface PadSnapshot {
+  /**
+   * Pressed state per button: 0=A/Cross, 1=B/Circle, 2=X/Square, 3=Y/Triangle, 4=LB/L1, 5=RB/R1,
+   * 6=LT, 7=RT, 8=View/Back, 9=Start, 10=L3, 11=R3, 12=d-pad up, 13=down, 14=left, 15=right.
+   */
+  buttons: boolean[]
+  /** 0,1 = left stick x,y; 2,3 = right stick x,y. Each -1..1, y positive = down. */
+  axes: number[]
+  /** `gamepad.mapping === 'standard'`. Not consulted here; the caller decides whether to trust a pad. */
+  standard: boolean
+}
+
+/**
+ * What a button means on the board. The four face buttons are Move/Go, Wait, Bomb and Charge (and
+ * the intent also carries `face`, their raw index, for dialogs, which map them differently). LB is
+ * `cancel` (backs out of the cursor or "What is that?"; the previous tab in a dialog); RB and View
+ * are `info` ("What is that?"; the next tab in a dialog).
+ */
+export type PadButton = 'move' | 'wait' | 'bomb' | 'charge' | 'cancel' | 'info' | 'menu' | 'reset'
+
+export type { Step }
+
+export type PadIntent =
+  | { kind: 'dir'; dx: Step; dy: Step }
+  /** `face`: 0 Cross/A, 1 Circle/B, 2 Square/X, 3 Triangle/Y, for the buttons that are face buttons. */
+  | { kind: 'press'; button: PadButton; face?: 0 | 1 | 2 | 3 }
+  | { kind: 'look'; dx: number; dy: number }
+  | { kind: 'lookEnd' }
+
+/**
+ * What `mapPad` must remember between frames. It is MUTATED IN PLACE by `mapPad` (one pad, one
+ * memory; make a fresh one with `newPadMemory()` when a pad connects or is swapped).
+ */
+export interface PadMemory {
+  /** Button indexes that were pressed last frame, so a press fires on the rising edge only. */
+  pressed: Set<number>
+  /** The direction being held ('dx,dy'), or '' when none; a change of direction counts as a new press. */
+  heldDir: string
+  /** When (ms) the held direction fires again. */
+  nextRepeat: number
+  /** Left stick: the sector (0..7, see SECTORS) it is counted as pushed into, or -1 when at rest. */
+  stickSector: number
+  /** Whether the right stick was outside its deadzone last frame, to send `lookEnd` once on return. */
+  looking: boolean
+  /**
+   * False until the first `mapPad` call. That call only records what is already down (buttons and
+   * direction) and emits no `press` or `dir`: in Chrome a pad is invisible until a button is
+   * pressed, and that press, which woke it, must not also act in the game. A direction already
+   * held then does not repeat until it is released.
+   */
+  primed: boolean
+}
+
+export function newPadMemory(): PadMemory {
+  return { pressed: new Set(), heldDir: '', nextRepeat: 0, stickSector: -1, looking: false, primed: false }
+}
+
+/** Held direction: fires at once, again after this long, then every REPEAT_MS while still held. */
+export const REPEAT_DELAY_MS = 300
+export const REPEAT_MS = 110
+/** Left stick counts as pushed once its distance from centre reaches ON, and stays pushed until it falls to OFF (stops flicker at the edge). */
+export const STICK_ON = 0.5
+export const STICK_OFF = 0.35
+/**
+ * While the stick is held, it changes sector only once it is more than this many degrees from the
+ * current sector's centre. A sector's edge is 22.5 degrees out, so this is 7.5 degrees of slack:
+ * rolling from right to down, or sitting on a boundary, does not flicker between two directions.
+ */
+export const SECTOR_HOLD_DEG = 30
+/** Right stick deadzone, radial: a stick resting slightly off centre must not pan the view. */
+export const LOOK_DEADZONE = 0.2
+
+// Cross Move/Go, Circle Wait, Square Bomb, Triangle Charge; LB backs out; RB and View ask "What is that?".
+const BUTTONS: Record<number, PadButton> = { 0: 'move', 1: 'wait', 2: 'bomb', 3: 'charge', 4: 'cancel', 5: 'info', 8: 'info', 9: 'menu', 11: 'reset' }
+/**
+ * The left stick does nothing for now (Pavel's first pad test, 2026-11-01: it was easy to nudge by
+ * accident). Its code stays, off behind this; `mapPad`'s `leftStick` argument lets a test turn it on.
+ */
+export const LEFT_STICK_ENABLED = false
+const DPAD_UP = 12
+const DPAD_DOWN = 13
+const DPAD_LEFT = 14
+const DPAD_RIGHT = 15
+
+/** The 8 stick directions, clockwise from right (y is down): sector k is centred at k x 45 degrees. */
+const SECTORS: readonly [Step, Step][] = [[1, 0], [1, 1], [0, 1], [-1, 1], [-1, 0], [-1, -1], [0, -1], [1, -1]]
+
+/** Angular distance in degrees between sector `k`'s centre and `angle` (degrees), 0..180. */
+function sectorOffset(k: number, angle: number): number {
+  const d = Math.abs(angle - k * 45) % 360
+  return d > 180 ? 360 - d : d
+}
+
+/**
+ * The left stick's sector, or -1 for at rest. The stick is judged as a vector, not per axis (per
+ * axis, a diagonal push read as a straight one whenever one axis lagged). Distance from centre
+ * has on/off hysteresis; the direction is the nearest of 8 sectors of 45 degrees each, with
+ * SECTOR_HOLD_DEG of slack once held.
+ */
+function stickSector(x: number, y: number, was: number): number {
+  const m = Math.hypot(x, y)
+  if (was === -1 ? m < STICK_ON : m <= STICK_OFF) return -1
+  const angle = (Math.atan2(y, x) * 180) / Math.PI
+  if (was !== -1 && sectorOffset(was, angle) <= SECTOR_HOLD_DEG) return was
+  return (Math.round(angle / 45) + 8) % 8
+}
+
+/** Whether direction `key` ('dx,dy') is a part of `of`: a diagonal's axis, so the diagonal was let go of, not a new direction pressed. */
+function isPartOf(key: string, of: string): boolean {
+  if (key === of || of === '') return false
+  const [dx, dy] = key.split(',').map(Number) as [number, number]
+  const [ox, oy] = of.split(',').map(Number) as [number, number]
+  return (dx === 0 || dx === ox) && (dy === 0 || dy === oy)
+}
+
+export function mapPad(snap: PadSnapshot, mem: PadMemory, nowMs: number, leftStick: boolean = LEFT_STICK_ENABLED): PadIntent[] {
+  const out: PadIntent[] = []
+  const down = (i: number): boolean => snap.buttons[i] === true
+
+  // Buttons: only the frame a button goes down counts, so holding one does not repeat it.
+  for (const [i, button] of Object.entries(BUTTONS)) {
+    if (mem.primed && down(Number(i)) && !mem.pressed.has(Number(i))) out.push(Number(i) < 4 ? { kind: 'press', button, face: Number(i) as 0 | 1 | 2 | 3 } : { kind: 'press', button })
+  }
+  mem.pressed = new Set(snap.buttons.flatMap((b, i) => (b ? [i] : [])))
+
+  // Direction: the d-pad wins over the left stick. The d-pad can give diagonals (up + right);
+  // opposite buttons cancel per axis. The caller drops diagonals the player cannot move on.
+  const num = (i: number): number => {
+    const v = snap.axes[i]
+    return v !== undefined && Number.isFinite(v) ? v : 0
+  }
+  mem.stickSector = leftStick ? stickSector(num(0), num(1), mem.stickSector) : -1
+  let dx = ((down(DPAD_RIGHT) ? 1 : 0) - (down(DPAD_LEFT) ? 1 : 0)) as Step
+  let dy = ((down(DPAD_DOWN) ? 1 : 0) - (down(DPAD_UP) ? 1 : 0)) as Step
+  // Only the d-pad's buttons lift one after the other; a stick rolling between sectors is a real change.
+  const fromDpad = dx !== 0 || dy !== 0
+  if (dx === 0 && dy === 0 && mem.stickSector !== -1) [dx, dy] = SECTORS[mem.stickSector] ?? [0, 0]
+  const key = dx === 0 && dy === 0 ? '' : `${dx},${dy}`
+  if (key === '') {
+    mem.heldDir = ''
+  } else if (fromDpad && isPartOf(key, mem.heldDir)) {
+    // A diagonal letting go: the buttons never lift in the same frame, and the one left must not read as a new step.
+    mem.heldDir = key
+    mem.nextRepeat = Infinity
+  } else if (key !== mem.heldDir) {
+    mem.heldDir = key
+    // Woken by this frame: remember it, but never repeat a direction nobody pressed in the game.
+    mem.nextRepeat = mem.primed ? nowMs + REPEAT_DELAY_MS : Infinity
+    if (mem.primed) out.push({ kind: 'dir', dx, dy })
+  } else if (nowMs >= mem.nextRepeat) {
+    mem.nextRepeat = nowMs + REPEAT_MS
+    out.push({ kind: 'dir', dx, dy })
+  }
+
+  // Right stick: continuous. Past the deadzone the distance is rescaled so the edge of the
+  // deadzone reads 0 and a full push reads 1 (no jump when it first leaves the deadzone).
+  const rx = num(2)
+  const ry = num(3)
+  const m = Math.hypot(rx, ry)
+  if (m > LOOK_DEADZONE) {
+    const k = (Math.min(m, 1) - LOOK_DEADZONE) / (1 - LOOK_DEADZONE) / m
+    const clamp = (v: number): number => Math.max(-1, Math.min(1, v))
+    out.push({ kind: 'look', dx: clamp(rx * k), dy: clamp(ry * k) })
+    mem.looking = true
+  } else if (mem.looking) {
+    mem.looking = false
+    out.push({ kind: 'lookEnd' })
+  }
+  mem.primed = true
+  return out
+}
+
+/** Which button glyphs to show: from `Gamepad.id`, which has the vendor and product ids in it. */
+export type PadFamily = 'xbox' | 'playstation' | 'nintendo' | 'generic'
+
+export function padFamily(id: string): PadFamily {
+  const s = id.toLowerCase()
+  if (s.includes('nintendo') || s.includes('switch') || s.includes('pro controller') || s.includes('057e')) return 'nintendo'
+  if (s.includes('xbox') || s.includes('xinput')) return 'xbox'
+  if (s.includes('dualsense') || s.includes('dualshock') || s.includes('playstation') || s.includes('054c')) return 'playstation'
+  return 'generic'
+}
diff --git a/src/ui/padTarget.ts b/src/ui/padTarget.ts
new file mode 100644
index 0000000..952aa9a
--- /dev/null
+++ b/src/ui/padTarget.ts
@@ -0,0 +1,59 @@
+import { cellAt } from '../sim/state'
+import type { GameState } from '../sim/types'
+import { tileInfo } from './info'
+import { nextFocus, type Step } from './spatial'
+
+/**
+ * The controller's current target among the tiles a picked action can use (the blue ones). Pure:
+ * main.ts hands in the tiles and the player's tile.
+ */
+interface Tile {
+  x: number
+  y: number
+}
+
+/** The tile nearest the player (straight-line distance; a tie goes to the smaller y, then x), or null for none. */
+export function firstTarget(tiles: readonly Tile[], player: Tile): Tile | null {
+  let best: Tile | null = null
+  let bestKey: [number, number, number] = [Infinity, 0, 0]
+  for (const t of tiles) {
+    const key: [number, number, number] = [Math.hypot(t.x - player.x, t.y - player.y), t.y, t.x]
+    if (key[0] < bestKey[0] || (key[0] === bestKey[0] && (key[1] < bestKey[1] || (key[1] === bestKey[1] && key[2] < bestKey[2])))) {
+      best = t
+      bestKey = key
+    }
+  }
+  return best
+}
+
+/**
+ * The target a direction jumps to from `current`: the best tile that way (spatial.ts `nextFocus`,
+ * board coordinates, y growing down as the screen's does), or `current` if none lies that way.
+ * Nothing wraps round.
+ */
+export function jumpTarget(tiles: readonly Tile[], current: Tile, dx: Step, dy: Step): Tile {
+  const at = tiles.findIndex((t) => t.x === current.x && t.y === current.y)
+  if (at < 0) return firstTarget(tiles, current) ?? current
+  const rects = tiles.map((t) => ({ x: t.x, y: t.y, w: 1, h: 1 }))
+  return tiles[nextFocus(rects, at, dx, dy)] ?? current
+}
+
+/**
+ * Every tile "What is that?" has something to say about, for the controller's inspect aim: what
+ * `tileInfo` explains (enemies, bombs, crates, metal, rubble, barrels, hazards, bushes, oil, gems,
+ * the exit, drops, a decoy, flame, a falling ceiling, a cracked pillar), nearest the player first.
+ * Plain pillars and the outer wall are left out: there are dozens, and the nearest would always be
+ * one of them (a tap or click still explains them). The player's own tile has nothing to explain.
+ */
+export function explainableTiles(s: GameState): Tile[] {
+  const tiles: Tile[] = []
+  for (let y = 0; y < s.height; y++) {
+    for (let x = 0; x < s.width; x++) {
+      const p = { x, y }
+      const i = y * s.width + x
+      if (cellAt(s, p) === 'wall' && !s.cracked.includes(i) && !s.collapsing.some((c) => c.cells.includes(i))) continue
+      if (tileInfo(s, p)) tiles.push(p)
+    }
+  }
+  return tiles.sort((a, b) => Math.hypot(a.x - s.player.x, a.y - s.player.y) - Math.hypot(b.x - s.player.x, b.y - s.player.y) || a.y - b.y || a.x - b.x)
+}
diff --git a/src/ui/padTiles.ts b/src/ui/padTiles.ts
new file mode 100644
index 0000000..48a1686
--- /dev/null
+++ b/src/ui/padTiles.ts
@@ -0,0 +1,121 @@
+import { isAvailable } from '../sim/actions'
+import { isDeadly } from '../sim/danger'
+import { canPick, pickChoices, pickTargets, resolvePick } from '../sim/picks'
+import { eq } from '../sim/state'
+import type { ActionId, GameState, Vec } from '../sim/types'
+import { targetLabel } from './targetLabel'
+import { nextFocus, type Step } from './spatial'
+
+/**
+ * The controller's "tile first" board play (decisions.md 2026-11-01, supersedes the aim, step and
+ * cycle models): a cursor sits on one actionable tile, and the four face buttons do what the
+ * action bar's buttons would do there. Pure: main.ts hands in the state.
+ */
+/** The action bar's buttons, in the order the label lists them. Cross, Square, Triangle, Circle. */
+export type BarButton = 'move' | 'bomb' | 'charged' | 'wait'
+export const BAR_BUTTONS: readonly BarButton[] = ['move', 'bomb', 'charged', 'wait']
+
+/** The raw face button (0 Cross/A, 1 Circle/B, 2 Square/X, 3 Triangle/Y) to the bar button it does. */
+export const FACE_TO_BUTTON: Record<0 | 1 | 2 | 3, BarButton> = { 0: 'move', 1: 'wait', 2: 'bomb', 3: 'charged' }
+
+/** What one bar button offers on the cursor's tile. */
+export interface TileAction {
+  button: BarButton
+  /** Whether it can be done on this tile (a greyed button when not). */
+  possible: boolean
+  /** The action it would be (a kick, a run ...); null when not possible. */
+  id: ActionId | null
+  /** Doing it would cost a life this turn: marked, but still played. */
+  deadly: boolean
+  /** One line, as targetLabel says it (including "(costs a life!)"); empty when not possible. */
+  label: string
+}
+
+/**
+ * Every tile a bar button could be used on now (Move, Bomb, Charge: `pickTargets` of each, so
+ * kicks, runs, throws and teleports are in), and the player's own tile (Wait). The player's tile
+ * comes first; each tile once.
+ */
+export function actionableTiles(s: GameState): Vec[] {
+  const tiles: Vec[] = [{ ...s.player }]
+  for (const button of ['move', 'bomb', 'charged'] as const) {
+    if (!canPick(s, button)) continue
+    for (const t of pickTargets(s, button)) if (!tiles.some((o) => eq(o, t))) tiles.push(t)
+  }
+  return tiles
+}
+
+/**
+ * What each of the four bar buttons does on `tile`. Wait only on the player's own tile; the
+ * others where `resolvePick` finds an action (or a choice of two, for a bomb). A button is
+ * `deadly` by the same test the board uses for its red tiles, `isDeadly` of the action there (for
+ * Wait, of waiting).
+ */
+export function tileActions(s: GameState, tile: Vec): TileAction[] {
+  return BAR_BUTTONS.map((button): TileAction => {
+    const none: TileAction = { button, possible: false, id: null, deadly: false, label: '' }
+    if (button === 'wait') {
+      if (!eq(tile, s.player) || !isAvailable(s, 'wait')) return none
+      return { button, possible: true, id: 'wait', deadly: isDeadly(s, { id: 'wait' }), label: targetLabel(s, 'wait', null) }
+    }
+    const id = resolvePick(s, button, tile)
+    if (!id) return none
+    const options = pickChoices(s, button, tile)
+    return { button, possible: true, id, deadly: isDeadly(s, { id, target: tile }), label: targetLabel(s, button, tile) || (options.length ? 'Choose' : '') }
+  })
+}
+
+/**
+ * Where the cursor first appears for a d-pad press with none showing: the nearest actionable tile
+ * in that direction from the player (`nextFocus`, board coordinates), or the player's own tile if
+ * there is none that way.
+ */
+export function firstCursor(tiles: readonly Vec[], player: Vec, dx: Step, dy: Step): Vec {
+  const others = tiles.filter((t) => !eq(t, player))
+  const rects = [player, ...others].map((t) => ({ x: t.x, y: t.y, w: 1, h: 1 }))
+  const at = nextFocus(rects, 0, dx, dy)
+  return at <= 0 ? player : others[at - 1]!
+}
+
+/** The cursor moved by the d-pad: the best actionable tile that way, or where it is if none. */
+export function moveCursor(tiles: readonly Vec[], current: Vec, dx: Step, dy: Step): Vec {
+  const at = tiles.findIndex((t) => eq(t, current))
+  if (at < 0) return current
+  return tiles[nextFocus(tiles.map((t) => ({ x: t.x, y: t.y, w: 1, h: 1 })), at, dx, dy)] ?? current
+}
+
+/**
+ * After the board changed: the cursor stays if its tile is still actionable, else goes to the
+ * nearest tile that is, else (no tiles) nowhere.
+ */
+export function keepCursor(tiles: readonly Vec[], current: Vec): Vec | null {
+  if (tiles.some((t) => eq(t, current))) return current
+  let best: Vec | null = null
+  let bestDist = Infinity
+  for (const t of tiles) {
+    const d = Math.hypot(t.x - current.x, t.y - current.y)
+    if (d < bestDist) {
+      best = t
+      bestDist = d
+    }
+  }
+  return best
+}
+
+/** The label's lines: each possible button and what it does (main.ts draws the button's picture before the text). */
+export function cursorLines(actions: readonly TileAction[]): { button: BarButton; label: string }[] {
+  return actions.filter((a) => a.possible).map((a) => ({ button: a.button, label: a.label }))
+}
+
+/** Whether the cursor's tile is drawn red: something is possible there and every possible action would cost a life. (If only some would, the tile stays blue and the red-marked buttons say which.) */
+export function cursorDeadly(actions: readonly TileAction[]): boolean {
+  const possible = actions.filter((a) => a.possible)
+  return possible.length > 0 && possible.every((a) => a.deadly)
+}
+
+/** The first action of Move, Bomb, Charge that is possible on a tile that is not the player's own: what the ghost previews. */
+export function ghostAction(actions: readonly TileAction[], tile: Vec, player: Vec): { id: ActionId; target: Vec } | null {
+  if (eq(tile, player)) return null
+  const first = actions.find((a) => a.button !== 'wait' && a.possible && a.id)
+  return first ? { id: first.id!, target: tile } : null
+}
diff --git a/src/ui/spatial.ts b/src/ui/spatial.ts
new file mode 100644
index 0000000..dd6113a
--- /dev/null
+++ b/src/ui/spatial.ts
@@ -0,0 +1,48 @@
+/**
+ * Moving focus between on-screen controls with a d-pad or stick (controller support, stage 1):
+ * the neighbour in the pressed direction, judged by where the rectangles are. Pure, so the caller
+ * measures the elements (`getBoundingClientRect`) and this only does the geometry.
+ */
+/** One step along an axis: back, none, forward. */
+export type Step = -1 | 0 | 1
+
+export interface FocusRect {
+  x: number
+  y: number
+  w: number
+  h: number
+}
+
+/**
+ * The index of the best rectangle in direction (dx, dy) from `rects[current]`, or `current` if
+ * none lies that way. A candidate counts when its centre is strictly past the current centre on
+ * every axis the direction uses. Score = distance along the direction + 2 x distance sideways
+ * (lower wins; a tie goes to the lower index), so a button straight along the row beats a nearer
+ * one a row off. An empty list gives -1; a `current` that is -1 or out of range gives 0; (0, 0)
+ * gives `current`. For a diagonal, both axes count as "along" and nothing is sideways.
+ */
+export function nextFocus(rects: FocusRect[], current: number, dx: Step, dy: Step): number {
+  if (rects.length === 0) return -1
+  const cur = rects[current]
+  if (cur === undefined) return 0
+  const cx = cur.x + cur.w / 2
+  const cy = cur.y + cur.h / 2
+  if (dx === 0 && dy === 0) return current
+  let best = current
+  let bestScore = Infinity
+  rects.forEach((r, i) => {
+    if (i === current) return
+    const ox = r.x + r.w / 2 - cx
+    const oy = r.y + r.h / 2 - cy
+    if (dx !== 0 && ox * dx <= 0) return
+    if (dy !== 0 && oy * dy <= 0) return
+    const along = (dx !== 0 ? Math.abs(ox) : 0) + (dy !== 0 ? Math.abs(oy) : 0)
+    const side = (dx === 0 ? Math.abs(ox) : 0) + (dy === 0 ? Math.abs(oy) : 0)
+    const score = along + 2 * side
+    if (score < bestScore) {
+      bestScore = score
+      best = i
+    }
+  })
+  return best
+}
diff --git a/src/ui/targetLabel.ts b/src/ui/targetLabel.ts
new file mode 100644
index 0000000..0d413bb
--- /dev/null
+++ b/src/ui/targetLabel.ts
@@ -0,0 +1,51 @@
+import { isDeadly } from '../sim/danger'
+import { pickChoices, resolvePick } from '../sim/picks'
+import { enemyAt } from '../sim/enemies'
+import { bombAt, oilSlide, playerCanPass } from '../sim/state'
+import type { ActionId, GameState, Vec } from '../sim/types'
+import { CHARGE_TEXT } from './info'
+
+/**
+ * The controller's one-line label (user request, 2026-11-01): what confirming the current target
+ * will do, in the player's words. `picked` is the action button (Move, Bomb, Charge, Wait); the
+ * tile decides which of its actions it is (picks.ts `resolvePick`), as it does for a tap. `target`
+ * is null for Wait, which has no tile. An action that would cost a life this turn says so.
+ */
+export function targetLabel(s: GameState, picked: ActionId, target: Vec | null): string {
+  if (!target) return picked === 'wait' ? withDanger('Wait a turn', isDeadly(s, { id: 'wait' })) : 'Nothing to do here'
+  // A bomb two of the button's actions could deal with: the choice dialog asks which.
+  if (pickChoices(s, picked, target).length) return 'Choose what to do with the bomb on this tile'
+  const id = resolvePick(s, picked, target)
+  if (!id) return 'Nothing to do here'
+  return withDanger(labelOf(s, id, target), isDeadly(s, { id, target }))
+}
+
+function withDanger(text: string, deadly: boolean): string {
+  return deadly ? `${text} (costs a life!)` : text
+}
+
+/** A step or run onto oil goes on sliding along the spill (turn.ts), so it is more than a move there. */
+function slidesOnOil(s: GameState, target: Vec): boolean {
+  const d = { x: Math.sign(target.x - s.player.x), y: Math.sign(target.y - s.player.y) }
+  return oilSlide(s, target, d, (q) => playerCanPass(s, q) && !enemyAt(s, q)).length > 0
+}
+
+function labelOf(s: GameState, id: ActionId, target: Vec): string {
+  switch (id) {
+    case 'move': return slidesOnOil(s, target) ? 'Slide to the end of the oil' : bombAt(s, target) ? 'Walk onto the bomb on this tile' : 'Move to this tile'
+    case 'run': return slidesOnOil(s, target) ? 'Slide to the end of the oil' : 'Run to this tile'
+    case 'diagonal': return 'Step diagonally to this tile'
+    case 'slide': return 'Slide to this tile'
+    case 'kick': return 'Kick the bomb on this tile'
+    case 'bomb': return 'Place a bomb on this tile'
+    case 'throw': return 'Throw a bomb to this tile'
+    case 'rpg': return 'Fire an RPG bomb to this tile'
+    case 'detonate': return 'Set off the bomb on this tile'
+    case 'wait': return 'Wait a turn'
+    case 'icbm': return 'Launch an ICBM to this tile'
+    case 'freeze': return 'Freeze every enemy'
+    case 'decoy': return 'Put a decoy on this tile'
+    case 'teleport': return 'Teleport to this tile'
+    case 'charged': return `Place a ${s.charge ? CHARGE_TEXT[s.charge.kind].name : 'charged'} bomb on this tile`
+  }
+}
diff --git a/test/gamepad.test.ts b/test/gamepad.test.ts
new file mode 100644
index 0000000..ec9edaa
--- /dev/null
+++ b/test/gamepad.test.ts
@@ -0,0 +1,212 @@
+import { describe, expect, it } from 'vitest'
+import { createPadInput, type PadLike, type PadTargets } from '../src/ui/gamepad'
+
+function pad(over: Partial<PadLike> = {}, pressed: number[] = []): PadLike {
+  return {
+    index: 0,
+    id: 'Wireless Controller (STANDARD GAMEPAD Vendor: 054c Product: 0ce6)',
+    connected: true,
+    mapping: 'standard',
+    buttons: Array.from({ length: 17 }, (_, i) => ({ pressed: pressed.includes(i) })),
+    axes: [0, 0, 0, 0],
+    ...over,
+  }
+}
+
+function setup() {
+  const calls: string[] = []
+  const targets: PadTargets = {
+    dir: (dx, dy) => calls.push(`dir ${dx},${dy}`),
+    press: (b) => calls.push(`press ${b}`),
+    look: (dx, dy) => calls.push(`look ${dx.toFixed(2)},${dy.toFixed(2)}`),
+    lookEnd: () => calls.push('lookEnd'),
+    active: () => calls.push('active'),
+    toast: (title, text) => calls.push(`toast ${title} | ${text}`),
+  }
+  let pads: (PadLike | null)[] = []
+  let focused = true
+  const input = createPadInput(targets, () => pads, () => focused)
+  return { calls, input, set: (...p: (PadLike | null)[]) => (pads = p), focus: (on: boolean) => (focused = on), targets }
+}
+
+describe('the pad input', () => {
+  it('says nothing and calls nothing with no pad', () => {
+    const t = setup()
+    t.set(null)
+    t.input.poll(0)
+    expect(t.calls).toEqual([])
+  })
+
+  it('announces a standard pad once, with its family', () => {
+    const t = setup()
+    t.set(pad())
+    t.input.poll(0)
+    t.input.poll(16)
+    expect(t.calls.filter((c) => c.startsWith('toast'))).toEqual(['toast Controller connected | PlayStation'])
+  })
+
+  it('routes a press, a direction and the right stick to the targets', () => {
+    const t = setup()
+    t.set(pad())
+    t.input.poll(0)
+    t.calls.length = 0
+    t.set(pad({}, [0, 15]))
+    t.input.poll(16)
+    expect(t.calls).toContain('press move')
+    expect(t.calls).toContain('dir 1,0')
+    t.calls.length = 0
+    t.set(pad({ axes: [0, 0, 1, 0] }))
+    t.input.poll(32)
+    expect(t.calls).toContain('look 1.00,0.00')
+    t.set(pad())
+    t.input.poll(48)
+    expect(t.calls).toContain('lookEnd')
+  })
+
+  it('marks the pad active only when something was pressed', () => {
+    const t = setup()
+    t.set(pad())
+    t.input.poll(0)
+    t.calls.length = 0
+    t.input.poll(16)
+    expect(t.calls).toEqual([])
+    t.set(pad({}, [1]))
+    t.input.poll(32)
+    expect(t.calls[0]).toBe('active')
+  })
+
+  it('refuses a non-standard pad with one toast and never calls the targets', () => {
+    const t = setup()
+    const odd = pad({ mapping: '', id: 'Mystery' }, [0, 15])
+    t.set(odd)
+    t.input.poll(0)
+    t.input.poll(16)
+    t.input.poll(32)
+    expect(t.calls).toEqual(['toast Controller not recognised | (non-standard layout)'])
+  })
+
+  it('starts a swapped pad fresh: a button the old one held is not a new press', () => {
+    const t = setup()
+    t.set(pad({}, [0]))
+    t.input.poll(0)
+    t.input.poll(16)
+    t.calls.length = 0
+    // A different pad appears with the same button down: it is only woken, not pressed.
+    t.set(pad({ index: 1, id: 'Xbox Wireless Controller (STANDARD GAMEPAD)' }, [0]))
+    t.input.poll(32)
+    expect(t.calls).toEqual(['active', 'toast Controller connected | Xbox'])
+    t.input.poll(48)
+    expect(t.calls.filter((c) => c.startsWith('press'))).toEqual([])
+  })
+
+  it('forgets a pad that is unplugged, and announces it again when it returns', () => {
+    const t = setup()
+    t.set(pad())
+    t.input.poll(0)
+    t.set(null)
+    t.input.poll(16)
+    t.set(pad())
+    t.input.poll(32)
+    expect(t.calls.filter((c) => c.startsWith('toast'))).toHaveLength(2)
+  })
+
+  it('uses the first connected pad, skipping empty and disconnected slots', () => {
+    const t = setup()
+    t.set(null, pad({ index: 1, connected: false }), pad({ index: 2 }))
+    t.input.poll(0)
+    expect(t.calls).toContain('toast Controller connected | PlayStation')
+  })
+
+  it('does nothing while the window is not focused, and a button held across is not a press', () => {
+    const t = setup()
+    t.set(pad())
+    t.input.poll(0)
+    t.calls.length = 0
+    t.focus(false)
+    t.set(pad({}, [0]))
+    t.input.poll(16)
+    expect(t.calls).toEqual([])
+    t.focus(true)
+    t.input.poll(32)
+    t.input.poll(48)
+    expect(t.calls.filter((c) => c.startsWith('press'))).toEqual([])
+  })
+
+  it('ends a look when the pad is unplugged, swapped or the window loses focus', () => {
+    for (const how of ['unplug', 'swap', 'blur'] as const) {
+      const t = setup()
+      t.set(pad())
+      t.input.poll(0)
+      t.set(pad({ axes: [0, 0, 1, 0] }))
+      t.input.poll(16)
+      t.calls.length = 0
+      if (how === 'unplug') t.set(null)
+      else if (how === 'swap') t.set(pad({ index: 1, id: 'Other (STANDARD GAMEPAD)' }))
+      else t.focus(false)
+      t.input.poll(32)
+      expect(t.calls).toContain('lookEnd')
+    }
+  })
+
+  it('prefers a standard pad over an earlier non-standard one', () => {
+    const t = setup()
+    t.set(pad({ index: 0, mapping: '', id: 'Mystery' }), pad({ index: 1 }))
+    t.input.poll(0)
+    expect(t.calls).toEqual(['active', 'toast Controller connected | PlayStation'])
+  })
+
+  it('falls back to a non-standard pad only to say it is not recognised', () => {
+    const t = setup()
+    t.set(null, pad({ mapping: '' }, [0]))
+    t.input.poll(0)
+    expect(t.calls).toEqual(['toast Controller not recognised | (non-standard layout)'])
+  })
+
+  it('stops polling, once, when reading the pads throws', () => {
+    const errors: unknown[] = []
+    let reads = 0
+    const input = createPadInput(
+      { dir() {}, press() {}, look() {}, lookEnd() {}, active() {}, toast() {}, error: (e) => errors.push(e) },
+      () => {
+        reads++
+        throw new Error('boom')
+      },
+      () => true,
+    )
+    expect(() => input.poll(0)).not.toThrow()
+    input.poll(16)
+    input.poll(32)
+    expect(errors).toHaveLength(1)
+    expect(reads).toBe(1)
+  })
+})
+
+describe('face buttons', () => {
+  it('carry their board meaning, and the raw index for dialogs', () => {
+    const t = setup()
+    const faces: { name: string; face?: number }[] = []
+    const input = createPadInput(
+      { ...t.targets, press: (name, face) => faces.push({ name, face }) },
+      () => [pad({}, [])],
+      () => true,
+    )
+    input.poll(0)
+    const pads = [pad({}, [0]), pad({}, [3])]
+    let i = 0
+    const input2 = createPadInput({ ...t.targets, press: (name, face) => faces.push({ name, face }) }, () => [i === 0 ? pad() : pads[i - 1]!], () => true)
+    input2.poll(0)
+    for (i = 1; i <= 2; i++) input2.poll(i * 16)
+    expect(faces).toEqual([{ name: 'move', face: 0 }, { name: 'charge', face: 3 }])
+  })
+})
+
+describe('the pad family', () => {
+  it('is passed on when a standard pad connects', () => {
+    const families: string[] = []
+    const t = setup()
+    const input = createPadInput({ ...t.targets, family: (f) => families.push(f) }, () => [pad()], () => true)
+    input.poll(0)
+    input.poll(16)
+    expect(families).toEqual(['playstation'])
+  })
+})
diff --git a/test/hazards.test.ts b/test/hazards.test.ts
index afb05be..b04a414 100644
--- a/test/hazards.test.ts
+++ b/test/hazards.test.ts
@@ -24,7 +24,7 @@ describe('metal crates (PLAN.md §19.3)', () => {
     expect(r.state.cells[idx(s, v(5, 1))]).toBe('metal')
   })
 
-  it('can’t be walked through, even with Wall Pass', () => {
+  it('can’t be walked through, even with Crate Pass', () => {
     const s = board(`
       #####
       #PM.#
diff --git a/test/padDialog.test.ts b/test/padDialog.test.ts
new file mode 100644
index 0000000..e6cdf0f
--- /dev/null
+++ b/test/padDialog.test.ts
@@ -0,0 +1,88 @@
+import { describe, expect, it } from 'vitest'
+import { assignFaces, dismissIndex, faceAction, padMode, restoreIndex, scrollDelta, startIndex, tabStep, type FooterButton } from '../src/ui/padDialogMap'
+
+const b = (label: string, kind: FooterButton['kind'] = 'plain'): FooterButton => ({ label, kind })
+
+describe('footer buttons on face buttons', () => {
+  it('primary on Cross, others left to right on Triangle then Square', () => {
+    expect(assignFaces([b('Keep', 'plain'), b('Take', 'primary'), b('Use', 'primary')], false)).toEqual([3, 0, 2])
+  })
+  it('the dismissing button takes Circle and leaves the others their faces', () => {
+    expect(assignFaces([b('Kick it', 'primary'), b('Walk', 'primary'), b('Cancel')], true)).toEqual([0, 3, 1])
+    expect(assignFaces([b('Walk there', 'primary'), b('Got it')], true)).toEqual([0, 1])
+  })
+  it('a required dialog has no Circle, even for a button called Cancel', () => {
+    expect(assignFaces([b('Cancel')], false)).toEqual([3])
+  })
+  it('a dialog with no primary leaves Cross unused', () => {
+    expect(assignFaces([b('Back')], true)).toEqual([1])
+    expect(assignFaces([b('A'), b('B')], false)).toEqual([3, 2])
+  })
+  it('crossTaken keeps Cross free for the body (the shop)', () => {
+    expect(assignFaces([b('Restock'), b('Next level', 'primary')], false, true)).toEqual([3, 2])
+  })
+  it('too many buttons: null, so the selector is used', () => {
+    expect(assignFaces([b('Resume', 'primary'), b('New', 'danger'), b('Almanac'), b('Scores'), b('Demo'), b('Bug')], true)).toBeNull()
+    expect(assignFaces([b('a', 'primary'), b('b'), b('c'), b('d')], false)).toBeNull()
+  })
+  it('dismissIndex: only when dismissable, by label', () => {
+    expect(dismissIndex([b('Start'), b('Keep playing')], true)).toBe(1)
+    expect(dismissIndex([b('Start'), b('Keep playing')], false)).toBe(-1)
+    expect(dismissIndex([b('Start')], true)).toBe(-1)
+  })
+})
+
+describe('mode and actions', () => {
+  it('controls in the body force the selector', () => {
+    expect(padMode([0, 1], 0)).toBe('faces')
+    expect(padMode([0, 1], 2)).toBe('selector')
+    expect(padMode(null, 0)).toBe('selector')
+  })
+  it('faces mode: a face presses its footer button; Circle closes when nothing is on it', () => {
+    const faces = assignFaces([b('Start', 'primary'), b('Other')], false)
+    expect(faceAction(0, faces, 'faces', false, false)).toEqual({ kind: 'footer', index: 0 })
+    expect(faceAction(3, faces, 'faces', false, false)).toEqual({ kind: 'footer', index: 1 })
+    expect(faceAction(2, faces, 'faces', false, false)).toBeNull()
+    expect(faceAction(1, faces, 'faces', false, false)).toBeNull()
+    expect(faceAction(1, faces, 'faces', true, false)).toEqual({ kind: 'close' })
+  })
+  it('Cross buys on a card with a Buy button', () => {
+    expect(faceAction(0, [3, 2], 'faces', false, true)).toEqual({ kind: 'buy' })
+  })
+  it('selector mode: Cross presses the focused element, Circle closes, the rest nothing', () => {
+    expect(faceAction(0, null, 'selector', false, false)).toEqual({ kind: 'focused' })
+    expect(faceAction(1, null, 'selector', true, false)).toEqual({ kind: 'close' })
+    expect(faceAction(1, null, 'selector', false, false)).toBeNull()
+    // With a dismissing footer button (Back with an action), Circle presses that button.
+    expect(faceAction(1, [1], 'selector', true, false, 0)).toEqual({ kind: 'footer', index: 0 })
+    expect(faceAction(1, null, 'selector', false, false, 0)).toBeNull()
+    expect(faceAction(3, null, 'selector', true, false)).toBeNull()
+  })
+})
+
+describe('where the selector starts', () => {
+  it('the primary footer button, else the pressed item, else the first', () => {
+    expect(startIndex([{ primary: false, pressed: false }, { primary: false, pressed: true }, { primary: true, pressed: false }])).toBe(2)
+    expect(startIndex([{ primary: false, pressed: false }, { primary: false, pressed: true }])).toBe(1)
+    expect(startIndex([{ primary: false, pressed: false }])).toBe(0)
+    expect(startIndex([])).toBe(0)
+  })
+})
+
+describe('helpers', () => {
+  it('restoreIndex keeps the place, clamps, and handles empty', () => {
+    expect(restoreIndex(5, 2)).toBe(2)
+    expect(restoreIndex(2, 4)).toBe(1)
+    expect(restoreIndex(3, -1)).toBe(0)
+    expect(restoreIndex(0, 1)).toBe(-1)
+  })
+  it('tabStep stops at the ends', () => {
+    expect(tabStep(4, 0, -1)).toBe(0)
+    expect(tabStep(4, 3, 1)).toBe(3)
+    expect(tabStep(4, 1, 1)).toBe(2)
+  })
+  it('scrollDelta is proportional to push and time', () => {
+    expect(scrollDelta(0.5, 0.1)).toBeCloseTo(scrollDelta(1, 0.05))
+    expect(scrollDelta(-1, 0.1)).toBeLessThan(0)
+  })
+})
diff --git a/test/padGlyph.test.ts b/test/padGlyph.test.ts
new file mode 100644
index 0000000..a46ee79
--- /dev/null
+++ b/test/padGlyph.test.ts
@@ -0,0 +1,43 @@
+import { existsSync } from 'node:fs'
+import { describe, expect, it } from 'vitest'
+import { glyphFor, type GlyphButton } from '../src/ui/padGlyph'
+
+const BUTTONS: GlyphButton[] = ['move', 'bomb', 'charge', 'wait']
+
+describe('controller glyphs on the action bar', () => {
+  it('every family and button resolves to a file in assets/kenney-input-prompts', () => {
+    for (const family of ['playstation', 'xbox', 'nintendo', 'generic'] as const) {
+      for (const button of BUTTONS) expect(existsSync(`assets/kenney-input-prompts/${glyphFor(family, button)}.png`), `${family} ${button}`).toBe(true)
+    }
+  })
+  it('PlayStation: Move Cross, Bomb Square, Charge Triangle, Wait Circle', () => {
+    expect(BUTTONS.map((b) => glyphFor('playstation', b))).toEqual([
+      'playstation_button_color_cross', 'playstation_button_color_square', 'playstation_button_color_triangle', 'playstation_button_color_circle',
+    ])
+  })
+  it('Xbox: Move A, Bomb X, Charge Y, Wait B; any other pad gets the same', () => {
+    expect(BUTTONS.map((b) => glyphFor('xbox', b))).toEqual(['xbox_button_color_a', 'xbox_button_color_x', 'xbox_button_color_y', 'xbox_button_color_b'])
+    expect(BUTTONS.map((b) => glyphFor('generic', b))).toEqual(BUTTONS.map((b) => glyphFor('xbox', b)))
+  })
+})
+
+describe('Nintendo glyphs are by position, not by letter', () => {
+  it('bottom (Move) is B, left (Bomb) Y, top (Charge) X, right (Wait) A', () => {
+    expect(glyphFor('nintendo', 'move')).toBe('switch_button_b')
+    expect(glyphFor('nintendo', 'bomb')).toBe('switch_button_y')
+    expect(glyphFor('nintendo', 'charge')).toBe('switch_button_x')
+    expect(glyphFor('nintendo', 'wait')).toBe('switch_button_a')
+  })
+})
+
+describe('controller glyphs in dialogs', () => {
+  it('each raw face button has a file, per family', async () => {
+    const { faceGlyph } = await import('../src/ui/padGlyph')
+    for (const family of ['playstation', 'xbox', 'nintendo', 'generic'] as const) {
+      for (const face of [0, 1, 2, 3] as const) expect(existsSync(`assets/kenney-input-prompts/${faceGlyph(family, face)}.png`), `${family} ${face}`).toBe(true)
+    }
+    expect(faceGlyph('playstation', 0)).toBe('playstation_button_color_cross')
+    expect(faceGlyph('xbox', 1)).toBe('xbox_button_color_b')
+    expect(faceGlyph('playstation', 3)).toBe('playstation_button_color_triangle')
+  })
+})
diff --git a/test/padMap.test.ts b/test/padMap.test.ts
new file mode 100644
index 0000000..727233d
--- /dev/null
+++ b/test/padMap.test.ts
@@ -0,0 +1,338 @@
+import { describe, expect, it } from 'vitest'
+import { LEFT_STICK_ENABLED, mapPad as mapPadWith, newPadMemory, padFamily, type PadIntent, type PadMemory, type PadSnapshot } from '../src/ui/padMap'
+
+// Tests of the left stick's code run with it on; the default (off) has its own test below.
+const mapPad = (s: PadSnapshot, m: PadMemory, t: number): PadIntent[] => mapPadWith(s, m, t, true)
+
+function snap(pressed: number[] = [], axes: number[] = [0, 0, 0, 0]): PadSnapshot {
+  const buttons = Array.from({ length: 17 }, (_, i) => pressed.includes(i))
+  return { buttons, axes, standard: true }
+}
+// A memory that has already seen its first (waking) frame, as mapPad expects in play.
+function ready(): PadMemory {
+  const mem = newPadMemory()
+  mapPad(snap(), mem, -1000)
+  return mem
+}
+const UP = 12
+const DOWN = 13
+const LEFT = 14
+const RIGHT = 15
+
+describe('buttons', () => {
+  const cases: [number, string][] = [[0, 'move'], [1, 'wait'], [2, 'bomb'], [3, 'charge'], [4, 'cancel'], [5, 'info'], [8, 'info'], [9, 'menu'], [11, 'reset']]
+  for (const [index, button] of cases) {
+    it(`button ${index} is ${button}`, () => {
+      expect(mapPad(snap([index]), ready(), 0)).toEqual([index < 4 ? { kind: 'press', button, face: index } : { kind: 'press', button }])
+    })
+  }
+  it('the triggers and L3 do nothing', () => {
+    for (const i of [6, 7, 10]) expect(mapPad(snap([i]), ready(), 0)).toEqual([])
+  })
+  it('fires on the rising edge only', () => {
+    const mem = ready()
+    expect(mapPad(snap([0]), mem, 0)).toHaveLength(1)
+    expect(mapPad(snap([0]), mem, 16)).toEqual([])
+    expect(mapPad(snap([0]), mem, 5000)).toEqual([])
+    expect(mapPad(snap([]), mem, 5016)).toEqual([])
+    expect(mapPad(snap([0]), mem, 5032)).toEqual([{ kind: 'press', button: 'move', face: 0 }])
+  })
+  it('a second button pressed while the first is held fires alone', () => {
+    const mem = ready()
+    mapPad(snap([0]), mem, 0)
+    expect(mapPad(snap([0, 2]), mem, 16)).toEqual([{ kind: 'press', button: 'bomb', face: 2 }])
+  })
+  it('copes with a short buttons array', () => {
+    expect(mapPad({ buttons: [], axes: [], standard: true }, ready(), 0)).toEqual([])
+  })
+})
+
+describe('d-pad direction and repeat', () => {
+  const dirs = (xs: PadIntent[]): PadIntent[] => xs.filter((i) => i.kind === 'dir')
+  it('maps each d-pad button to a direction', () => {
+    expect(mapPad(snap([UP]), ready(), 0)).toEqual([{ kind: 'dir', dx: 0, dy: -1 }])
+    expect(mapPad(snap([DOWN]), ready(), 0)).toEqual([{ kind: 'dir', dx: 0, dy: 1 }])
+    expect(mapPad(snap([LEFT]), ready(), 0)).toEqual([{ kind: 'dir', dx: -1, dy: 0 }])
+    expect(mapPad(snap([RIGHT]), ready(), 0)).toEqual([{ kind: 'dir', dx: 1, dy: 0 }])
+  })
+  it('fires at 0, waits 300, then repeats every 110', () => {
+    const mem = ready()
+    const fired: number[] = []
+    for (const t of [0, 16, 299, 300, 316, 409, 410, 519, 520, 630]) {
+      if (dirs(mapPad(snap([RIGHT]), mem, t)).length > 0) fired.push(t)
+    }
+    expect(fired).toEqual([0, 300, 410, 520, 630])
+  })
+  it('releasing resets, so the next press fires at once', () => {
+    const mem = ready()
+    mapPad(snap([RIGHT]), mem, 0)
+    mapPad(snap([]), mem, 100)
+    expect(dirs(mapPad(snap([RIGHT]), mem, 150))).toHaveLength(1)
+    expect(dirs(mapPad(snap([RIGHT]), mem, 300))).toHaveLength(0)
+    expect(dirs(mapPad(snap([RIGHT]), mem, 450))).toHaveLength(1)
+  })
+  it('changing direction while held fires at once and restarts the delay', () => {
+    const mem = ready()
+    mapPad(snap([RIGHT]), mem, 0)
+    expect(mapPad(snap([DOWN]), mem, 100)).toEqual([{ kind: 'dir', dx: 0, dy: 1 }])
+    expect(dirs(mapPad(snap([DOWN]), mem, 399))).toHaveLength(0)
+    expect(dirs(mapPad(snap([DOWN]), mem, 400))).toHaveLength(1)
+  })
+  it('gives diagonals; opposite buttons cancel per axis', () => {
+    expect(mapPad(snap([UP, RIGHT]), ready(), 0)).toEqual([{ kind: 'dir', dx: 1, dy: -1 }])
+    expect(mapPad(snap([DOWN, LEFT]), ready(), 0)).toEqual([{ kind: 'dir', dx: -1, dy: 1 }])
+    expect(mapPad(snap([LEFT, RIGHT]), ready(), 0)).toEqual([])
+    expect(mapPad(snap([UP, DOWN]), ready(), 0)).toEqual([])
+    expect(mapPad(snap([UP, LEFT, RIGHT]), ready(), 0)).toEqual([{ kind: 'dir', dx: 0, dy: -1 }])
+  })
+  it('repeats on ~16 ms frames: first repeat within a frame of 300 ms, then about every 110', () => {
+    const mem = ready()
+    const fired: number[] = []
+    for (let t = 0; t <= 800; t += 16) if (dirs(mapPad(snap([RIGHT]), mem, t)).length > 0) fired.push(t)
+    expect(fired[0]).toBe(0)
+    expect(fired[1]).toBeGreaterThanOrEqual(300)
+    expect(fired[1]).toBeLessThan(316)
+    for (let i = 2; i < fired.length; i++) {
+      const gap = (fired[i] as number) - (fired[i - 1] as number)
+      expect(gap).toBeGreaterThanOrEqual(110)
+      expect(gap).toBeLessThan(110 + 16)
+    }
+    expect(fired.length).toBeGreaterThanOrEqual(5)
+  })
+  it('going from the d-pad to the stick in the same direction does not restart; a different one does', () => {
+    const mem = ready()
+    mapPad(snap([RIGHT]), mem, 0)
+    expect(mapPad(snap([], [1, 0, 0, 0]), mem, 100)).toEqual([]) // same direction, delay carries on
+    expect(mapPad(snap([], [0, 1, 0, 0]), mem, 150)).toEqual([{ kind: 'dir', dx: 0, dy: 1 }])
+    expect(dirs(mapPad(snap([], [0, 1, 0, 0]), mem, 449))).toHaveLength(0)
+    expect(dirs(mapPad(snap([], [0, 1, 0, 0]), mem, 450))).toHaveLength(1)
+  })
+  it('a short buttons array can still carry a press at index 15', () => {
+    const buttons = Array.from({ length: 16 }, (_, i) => i === 15)
+    expect(mapPad({ buttons, axes: [], standard: true }, ready(), 0)).toEqual([{ kind: 'dir', dx: 1, dy: 0 }])
+  })
+})
+
+describe('releasing a diagonal', () => {
+  const dirs = (xs: PadIntent[]): PadIntent[] => xs.filter((i) => i.kind === 'dir')
+  it('one button lifting a frame before the other gives no new step', () => {
+    const mem = ready()
+    expect(mapPad(snap([UP, RIGHT]), mem, 0)).toEqual([{ kind: 'dir', dx: 1, dy: -1 }])
+    expect(mapPad(snap([UP]), mem, 16)).toEqual([])
+    expect(mapPad(snap([]), mem, 32)).toEqual([])
+    // ... and a fresh press afterwards is a step again.
+    expect(mapPad(snap([UP]), mem, 48)).toEqual([{ kind: 'dir', dx: 0, dy: -1 }])
+  })
+  it('the axis left does not repeat either, however long it is held', () => {
+    const mem = ready()
+    mapPad(snap([DOWN, LEFT]), mem, 0)
+    mapPad(snap([LEFT]), mem, 16)
+    expect(dirs(mapPad(snap([LEFT]), mem, 2000))).toHaveLength(0)
+  })
+  it('building a diagonal still fires: the first button, then the second', () => {
+    const mem = ready()
+    expect(mapPad(snap([UP]), mem, 0)).toEqual([{ kind: 'dir', dx: 0, dy: -1 }])
+    expect(mapPad(snap([UP, RIGHT]), mem, 16)).toEqual([{ kind: 'dir', dx: 1, dy: -1 }])
+  })
+  it('going to a different direction, not a part, fires', () => {
+    const mem = ready()
+    mapPad(snap([UP, RIGHT]), mem, 0)
+    expect(mapPad(snap([DOWN, RIGHT]), mem, 16)).toEqual([{ kind: 'dir', dx: 1, dy: 1 }])
+  })
+})
+
+describe('left stick off', () => {
+  it('is off by default and gives nothing, at any push', () => {
+    expect(LEFT_STICK_ENABLED).toBe(false)
+    for (const axes of [[1, 0, 0, 0], [0, -1, 0, 0], [0.7, 0.7, 0, 0]]) expect(mapPadWith(snap([], axes), ready(), 0)).toEqual([])
+  })
+  it('the d-pad still works', () => {
+    expect(mapPadWith(snap([UP], [1, 0, 0, 0]), ready(), 0)).toEqual([{ kind: 'dir', dx: 0, dy: -1 }])
+  })
+})
+
+describe('left stick', () => {
+  const stick = (x: number, y: number): PadSnapshot => snap([], [x, y, 0, 0])
+  it('is ignored below the 0.5 threshold', () => {
+    expect(mapPad(stick(0.49, 0), ready(), 0)).toEqual([])
+    expect(mapPad(stick(0, -0.49), ready(), 0)).toEqual([])
+  })
+  it('gives four straight directions (y positive is down)', () => {
+    expect(mapPad(stick(0.5, 0), ready(), 0)).toEqual([{ kind: 'dir', dx: 1, dy: 0 }])
+    expect(mapPad(stick(-1, 0), ready(), 0)).toEqual([{ kind: 'dir', dx: -1, dy: 0 }])
+    expect(mapPad(stick(0, 0.9), ready(), 0)).toEqual([{ kind: 'dir', dx: 0, dy: 1 }])
+    expect(mapPad(stick(0, -0.9), ready(), 0)).toEqual([{ kind: 'dir', dx: 0, dy: -1 }])
+  })
+  it('gives a diagonal when both axes are past the threshold', () => {
+    expect(mapPad(stick(0.7, 0.7), ready(), 0)).toEqual([{ kind: 'dir', dx: 1, dy: 1 }])
+    expect(mapPad(stick(-0.7, -0.6), ready(), 0)).toEqual([{ kind: 'dir', dx: -1, dy: -1 }])
+    // Sectors are 45 degrees: a push 18 degrees below right is still right; 30 degrees is the diagonal.
+    expect(mapPad(stick(0.9, 0.3), ready(), 0)).toEqual([{ kind: 'dir', dx: 1, dy: 0 }])
+    expect(mapPad(stick(0.8, 0.46), ready(), 0)).toEqual([{ kind: 'dir', dx: 1, dy: 1 }])
+    expect(mapPad(stick(0.3, 0.9), ready(), 0)).toEqual([{ kind: 'dir', dx: 0, dy: 1 }])
+  })
+  it('judges the vector, not each axis: both axes under 0.5 can still be pushed far enough', () => {
+    expect(mapPad(stick(0.45, 0.45), ready(), 0)).toEqual([{ kind: 'dir', dx: 1, dy: 1 }])
+  })
+  it('hysteresis: stays on until it falls to 0.35', () => {
+    const mem = ready()
+    expect(mapPad(stick(0.6, 0), mem, 0)).toHaveLength(1)
+    // 0.4 is under the on-threshold but over the off-threshold: still held, so no new fire.
+    expect(mapPad(stick(0.4, 0), mem, 50)).toEqual([])
+    expect(mem.heldDir).toBe('1,0')
+    expect(mapPad(stick(0.36, 0), mem, 60)).toEqual([])
+    expect(mem.heldDir).toBe('1,0')
+    mapPad(stick(0.35, 0), mem, 70)
+    expect(mem.heldDir).toBe('')
+    // Back at 0.4 it is not on again: it must pass 0.5 first.
+    expect(mapPad(stick(0.4, 0), mem, 80)).toEqual([])
+    expect(mapPad(stick(0.5, 0), mem, 90)).toHaveLength(1)
+  })
+  it('a held stick changes sector only past 30 degrees from the sector centre', () => {
+    const mem = ready()
+    const at = (deg: number): PadSnapshot => stick(0.8 * Math.cos((deg * Math.PI) / 180), 0.8 * Math.sin((deg * Math.PI) / 180))
+    expect(mapPad(at(0), mem, 0)).toEqual([{ kind: 'dir', dx: 1, dy: 0 }])
+    // 29 degrees would be the diagonal from rest, but while held right it stays right.
+    expect(mapPad(at(29), mem, 10)).toEqual([])
+    expect(mem.heldDir).toBe('1,0')
+    expect(mapPad(at(31), mem, 20)).toEqual([{ kind: 'dir', dx: 1, dy: 1 }])
+    // And back: the diagonal holds down to 15 degrees.
+    expect(mapPad(at(20), mem, 30)).toEqual([])
+    expect(mem.heldDir).toBe('1,1')
+  })
+  it('rolling right to down passes through the diagonal, one step each, no flicker', () => {
+    const mem = ready()
+    const steps: string[] = []
+    for (let deg = 0; deg <= 90; deg += 5) {
+      const r = (deg * Math.PI) / 180
+      for (const i of mapPad(stick(0.8 * Math.cos(r), 0.8 * Math.sin(r)), mem, deg)) if (i.kind === 'dir') steps.push(`${i.dx},${i.dy}`)
+    }
+    expect(steps).toEqual(['1,0', '1,1', '0,1'])
+  })
+  it('the opposite side switches sector at once (held right, pushed left)', () => {
+    const mem = ready()
+    mapPad(stick(0.6, 0), mem, 0)
+    expect(mapPad(stick(-0.4, 0), mem, 10)).toEqual([{ kind: 'dir', dx: -1, dy: 0 }])
+  })
+  it('a push to a diagonal and back fires once: the sector the stick is in when it first passes 0.5', () => {
+    // Chosen: the first fire is whatever sector it is in at the first frame past 0.5 (here the
+    // diagonal, 42.7 degrees), and easing back to rest through the band adds nothing.
+    const mem = ready()
+    const fired: PadIntent[] = []
+    const frames: [number, number][] = [[0.3, 0.28], [0.52, 0.48], [0.7, 0.7], [0.5, 0.3], [0.3, 0.1], [0, 0]]
+    frames.forEach(([x, y], i) => fired.push(...mapPad(stick(x, y), mem, i * 16)))
+    expect(fired).toEqual([{ kind: 'dir', dx: 1, dy: 1 }])
+    expect(mem.heldDir).toBe('')
+  })
+  it('non-finite stick values count as zero', () => {
+    expect(mapPad(snap([], [NaN, Infinity, 0, 0]), ready(), 0)).toEqual([])
+  })
+  it('works with only the x axis present', () => {
+    expect(mapPad({ buttons: [], axes: [1], standard: true }, ready(), 0)).toEqual([{ kind: 'dir', dx: 1, dy: 0 }])
+  })
+  it('repeats like the d-pad', () => {
+    const mem = ready()
+    const fired: number[] = []
+    for (const t of [0, 299, 300, 409, 410]) if (mapPad(stick(1, 0), mem, t).length > 0) fired.push(t)
+    expect(fired).toEqual([0, 300, 410])
+  })
+  it('the d-pad takes precedence over the stick', () => {
+    const mem = ready()
+    expect(mapPad(snap([UP], [1, 0, 0, 0]), mem, 0)).toEqual([{ kind: 'dir', dx: 0, dy: -1 }])
+    expect(mapPad(snap([], [1, 0, 0, 0]), mem, 20)).toEqual([{ kind: 'dir', dx: 1, dy: 0 }])
+  })
+  it('copes with missing axes', () => {
+    expect(mapPad({ buttons: [], axes: [], standard: true }, ready(), 0)).toEqual([])
+  })
+})
+
+describe('right stick', () => {
+  const look = (x: number, y: number): PadSnapshot => snap([], [0, 0, x, y])
+  it('does nothing inside the 0.2 deadzone, even before it ever looked', () => {
+    expect(mapPad(look(0.1, 0.1), ready(), 0)).toEqual([])
+    expect(mapPad(look(0.2, 0), ready(), 0)).toEqual([])
+  })
+  it('the deadzone is radial, not per axis', () => {
+    // Each axis is at or under 0.2, but together they are 0.28 out (past the deadzone); 0.14 each is 0.198 (inside).
+    expect(mapPad(look(0.2, 0.2), ready(), 0)).toHaveLength(1)
+    expect(mapPad(look(0.14, 0.14), ready(), 0)).toEqual([])
+  })
+  it('non-finite values count as zero', () => {
+    expect(mapPad(look(NaN, Infinity), ready(), 0)).toEqual([])
+    expect(mapPad(look(NaN, 1), ready(), 0)).toHaveLength(1)
+  })
+  it('scales from 0 at the deadzone edge to 1 at full tilt', () => {
+    const full = mapPad(look(1, 0), ready(), 0)
+    expect(full).toEqual([{ kind: 'look', dx: 1, dy: 0 }])
+    const half = mapPad(look(0.6, 0), ready(), 0)[0]
+    expect(half).toMatchObject({ kind: 'look', dy: 0 })
+    expect((half as { dx: number }).dx).toBeCloseTo(0.5)
+    const down = mapPad(look(0, -1), ready(), 0)[0] as { dx: number; dy: number }
+    expect(down.dx).toBeCloseTo(0)
+    expect(down.dy).toBeCloseTo(-1)
+  })
+  it('stays within -1..1 on a corner push', () => {
+    const l = mapPad(look(1, 1), ready(), 0)[0] as { dx: number; dy: number }
+    expect(Math.abs(l.dx)).toBeLessThanOrEqual(1)
+    expect(Math.abs(l.dy)).toBeLessThanOrEqual(1)
+    expect(l.dx).toBeCloseTo(l.dy)
+  })
+  it('emits look every frame, then one lookEnd on return', () => {
+    const mem: PadMemory = ready()
+    expect(mapPad(look(1, 0), mem, 0)).toHaveLength(1)
+    expect(mapPad(look(1, 0), mem, 16)).toHaveLength(1)
+    expect(mapPad(look(0, 0), mem, 32)).toEqual([{ kind: 'lookEnd' }])
+    expect(mapPad(look(0, 0), mem, 48)).toEqual([])
+  })
+})
+
+describe('the first frame (waking the pad)', () => {
+  it('records what is down but emits no press or dir', () => {
+    const mem = newPadMemory()
+    expect(mapPad(snap([0, RIGHT]), mem, 0)).toEqual([])
+    expect(mem.primed).toBe(true)
+    expect(mapPad(snap([0, RIGHT]), mem, 16)).toEqual([]) // still the same press
+  })
+  it('a direction held through the wake does not repeat, until released', () => {
+    const mem = newPadMemory()
+    mapPad(snap([RIGHT]), mem, 0)
+    expect(mapPad(snap([RIGHT]), mem, 1000)).toEqual([])
+    mapPad(snap([]), mem, 1016)
+    expect(mapPad(snap([RIGHT]), mem, 1032)).toEqual([{ kind: 'dir', dx: 1, dy: 0 }])
+  })
+  it('the next press after the wake fires', () => {
+    const mem = newPadMemory()
+    mapPad(snap([0]), mem, 0)
+    mapPad(snap([]), mem, 16)
+    expect(mapPad(snap([0]), mem, 32)).toEqual([{ kind: 'press', button: 'move', face: 0 }])
+  })
+})
+
+describe('padFamily: Nintendo', () => {
+  it('recognises Switch pads by name or vendor', () => {
+    expect(padFamily('Pro Controller (STANDARD GAMEPAD Vendor: 057e Product: 2009)')).toBe('nintendo')
+    expect(padFamily('Nintendo Switch Pro Controller')).toBe('nintendo')
+    expect(padFamily('057e-2006-Joy-Con (L)')).toBe('nintendo')
+    expect(padFamily('Switch Controller')).toBe('nintendo')
+  })
+})
+
+describe('padFamily', () => {
+  it('recognises Xbox and XInput pads', () => {
+    expect(padFamily('Xbox 360 Controller (STANDARD GAMEPAD Vendor: 045e Product: 028e)')).toBe('xbox')
+    expect(padFamily('Microsoft Controller (XInput STANDARD GAMEPAD)')).toBe('xbox')
+    expect(padFamily('XBOX One')).toBe('xbox')
+  })
+  it('recognises PlayStation pads', () => {
+    expect(padFamily('DualSense Wireless Controller (STANDARD GAMEPAD Vendor: 054c Product: 0ce6)')).toBe('playstation')
+    expect(padFamily('Wireless Controller (STANDARD GAMEPAD Vendor: 054c Product: 0ce6)')).toBe('playstation')
+    expect(padFamily('054c-09cc-Sony Controller')).toBe('playstation')
+    expect(padFamily('PLAYSTATION(R)3 Controller')).toBe('playstation')
+    expect(padFamily('DualShock 4')).toBe('playstation')
+  })
+  it('anything else is generic', () => {
+    expect(padFamily('8BitDo Pro 2 (Vendor: 2dc8 Product: 6003)')).toBe('generic')
+    expect(padFamily('Wireless Controller')).toBe('generic')
+    expect(padFamily('')).toBe('generic')
+  })
+})
diff --git a/test/padTarget.test.ts b/test/padTarget.test.ts
new file mode 100644
index 0000000..e4fb353
--- /dev/null
+++ b/test/padTarget.test.ts
@@ -0,0 +1,59 @@
+import { describe, expect, it } from 'vitest'
+import { board } from './helpers'
+import { explainableTiles, firstTarget, jumpTarget } from '../src/ui/padTarget'
+
+describe('firstTarget', () => {
+  it('is null for no tiles', () => {
+    expect(firstTarget([], { x: 5, y: 5 })).toBeNull()
+  })
+  it('is the nearest tile to the player', () => {
+    expect(firstTarget([{ x: 9, y: 5 }, { x: 5, y: 7 }, { x: 4, y: 5 }], { x: 5, y: 5 })).toEqual({ x: 4, y: 5 })
+  })
+  it('breaks a tie by the smaller y, then the smaller x', () => {
+    const around = [{ x: 6, y: 5 }, { x: 5, y: 6 }, { x: 4, y: 5 }, { x: 5, y: 4 }]
+    expect(firstTarget(around, { x: 5, y: 5 })).toEqual({ x: 5, y: 4 })
+    expect(firstTarget(around.slice(0, 3), { x: 5, y: 5 })).toEqual({ x: 4, y: 5 })
+  })
+})
+
+describe('jumpTarget', () => {
+  // The four neighbours of (5, 5); the board's y grows down, so up is the smaller y.
+  const tiles = [{ x: 5, y: 4 }, { x: 6, y: 5 }, { x: 5, y: 6 }, { x: 4, y: 5 }]
+  it('goes the way pressed', () => {
+    expect(jumpTarget(tiles, tiles[0]!, 0, 1)).toEqual({ x: 5, y: 6 })
+    expect(jumpTarget(tiles, tiles[1]!, -1, 0)).toEqual({ x: 4, y: 5 })
+    expect(jumpTarget(tiles, tiles[3]!, 1, -1)).toEqual({ x: 5, y: 4 })
+  })
+  it('stays put when nothing lies that way (nothing wraps)', () => {
+    expect(jumpTarget(tiles, tiles[0]!, 0, -1)).toEqual({ x: 5, y: 4 })
+    expect(jumpTarget(tiles, tiles[1]!, 1, 0)).toEqual({ x: 6, y: 5 })
+  })
+  it('prefers the tile straight along over a nearer one a row off', () => {
+    const row = [{ x: 0, y: 0 }, { x: 1, y: 2 }, { x: 3, y: 0 }]
+    expect(jumpTarget(row, row[0]!, 1, 0)).toEqual({ x: 3, y: 0 })
+  })
+  it('falls back to the nearest tile if the current one is not in the list', () => {
+    expect(jumpTarget(tiles, { x: 5, y: 5 }, 1, 0)).toEqual({ x: 5, y: 4 })
+  })
+})
+
+describe('explainableTiles', () => {
+  it('lists what "What is that?" explains, nearest the player first, without plain pillars or the outer wall', () => {
+    const s = board(`
+      #######
+      #P.+.B#
+      #.#...#
+      #.$..3#
+      #######`)
+    const tiles = explainableTiles(s)
+    // The crate, the blob, the coin and the bomb; no wall of any kind, no empty floor, and not the player.
+    const key = tiles.map((t) => `${t.x},${t.y}`)
+    expect(key).toContain('3,1')
+    expect(key).toContain('5,1')
+    expect(key).toContain('2,3')
+    expect(key).toContain('5,3')
+    expect(key).toHaveLength(4)
+    // Nearest first: the coin (2,3) is 2.2 away, the crate (3,1) is 2 away.
+    expect(key[0]).toBe('3,1')
+  })
+})
diff --git a/test/padTiles.test.ts b/test/padTiles.test.ts
new file mode 100644
index 0000000..41a5c7c
--- /dev/null
+++ b/test/padTiles.test.ts
@@ -0,0 +1,133 @@
+import { describe, expect, it } from 'vitest'
+import { actionableTiles, cursorDeadly, cursorLines, firstCursor, ghostAction, keepCursor, moveCursor, tileActions } from '../src/ui/padTiles'
+import { board, v } from './helpers'
+
+const room = () =>
+  board(`
+    #######
+    #.....#
+    #.....#
+    #..P..#
+    #.....#
+    #.....#
+    #######`)
+
+const by = (actions: ReturnType<typeof tileActions>, b: string) => actions.find((a) => a.button === b)!
+
+describe('actionableTiles', () => {
+  it('the player first, then every tile a button can use, each once', () => {
+    const tiles = actionableTiles(room())
+    expect(tiles[0]).toEqual(v(3, 3))
+    expect(tiles.some((t) => t.x === 4 && t.y === 3)).toBe(true)
+    expect(new Set(tiles.map((t) => `${t.x},${t.y}`)).size).toBe(tiles.length)
+  })
+  it('walls and far tiles are not in it', () => {
+    const tiles = actionableTiles(room())
+    expect(tiles.some((t) => t.x === 0)).toBe(false)
+    expect(tiles.some((t) => t.x === 1 && t.y === 1)).toBe(false)
+  })
+  it('with Run, tiles two away are in it too', () => {
+    const s = room()
+    s.powers.run = true
+    expect(actionableTiles(s).some((t) => t.x === 5 && t.y === 3)).toBe(true)
+  })
+})
+
+describe('tileActions', () => {
+  it('Wait only on the player\'s own tile', () => {
+    const s = room()
+    expect(by(tileActions(s, v(3, 3)), 'wait').possible).toBe(true)
+    expect(by(tileActions(s, v(4, 3)), 'wait').possible).toBe(false)
+  })
+  it('a floor tile next to the player: Move and Bomb, not Charge or Wait', () => {
+    const a = tileActions(room(), v(4, 3))
+    expect(by(a, 'move')).toMatchObject({ possible: true, id: 'move', deadly: false, label: 'Move to this tile' })
+    expect(by(a, 'bomb')).toMatchObject({ possible: true, id: 'bomb', label: 'Place a bomb on this tile' })
+    expect(by(a, 'charged').possible).toBe(false)
+    expect(by(a, 'charged').label).toBe('')
+  })
+  it('the own tile with only Wait possible', () => {
+    const a = tileActions(room(), v(3, 3))
+    expect(a.filter((x) => x.possible).map((x) => x.button)).toEqual(['wait'])
+    expect(by(a, 'wait').label).toBe('Wait a turn')
+  })
+  it('marks a button deadly and says so', () => {
+    const s = board(`
+      #######
+      #P.1..#
+      #######`)
+    const move = by(tileActions(s, v(2, 1)), 'move')
+    expect(move.deadly).toBe(true)
+    expect(move.possible).toBe(true)
+    expect(move.label).toContain('(costs a life!)')
+  })
+  it('Wait is deadly when staying is', () => {
+    const s = board(`
+      #######
+      #P1...#
+      #######`)
+    const wait = by(tileActions(s, v(1, 1)), 'wait')
+    expect(wait.possible).toBe(true)
+    expect(wait.deadly).toBe(true)
+    expect(wait.label).toContain('(costs a life!)')
+  })
+  it('a Freeze charge is possible on the own tile', () => {
+    const s = room()
+    s.charge = { kind: 'freeze', left: 1 }
+    const a = tileActions(s, v(3, 3))
+    expect(by(a, 'charged')).toMatchObject({ possible: true, id: 'freeze' })
+    expect(by(a, 'wait').possible).toBe(true)
+  })
+})
+
+describe('the cursor', () => {
+  const tiles = [v(3, 3), v(4, 3), v(2, 3), v(3, 2), v(3, 4), v(5, 3)]
+  it('first press: the nearest actionable tile that way, else the player\'s own', () => {
+    expect(firstCursor(tiles, v(3, 3), 1, 0)).toEqual(v(4, 3))
+    expect(firstCursor(tiles, v(3, 3), 0, -1)).toEqual(v(3, 2))
+    expect(firstCursor(tiles, v(3, 3), 1, 1)).toEqual(v(3, 3))
+    expect(firstCursor([v(3, 3)], v(3, 3), 1, 0)).toEqual(v(3, 3))
+  })
+  it('moves between actionable tiles, staying put at the edge', () => {
+    expect(moveCursor(tiles, v(4, 3), 1, 0)).toEqual(v(5, 3))
+    expect(moveCursor(tiles, v(5, 3), 1, 0)).toEqual(v(5, 3))
+    expect(moveCursor(tiles, v(4, 3), -1, 0)).toEqual(v(3, 3))
+  })
+  it('keepCursor: stays, else the nearest still actionable, else nothing', () => {
+    expect(keepCursor(tiles, v(4, 3))).toEqual(v(4, 3))
+    expect(keepCursor(tiles, v(6, 3))).toEqual(v(5, 3))
+    expect(keepCursor([], v(1, 1))).toBeNull()
+  })
+})
+
+describe('label and ghost', () => {
+  it('lists each possible button with what it does', () => {
+    expect(cursorLines(tileActions(room(), v(4, 3)))).toEqual([
+      { button: 'move', label: 'Move to this tile' },
+      { button: 'bomb', label: 'Place a bomb on this tile' },
+    ])
+    expect(cursorLines(tileActions(room(), v(3, 3)))).toEqual([{ button: 'wait', label: 'Wait a turn' }])
+  })
+  it('the tile is red only when everything possible there would cost a life', () => {
+    const s = board(`
+      #######
+      #P.1..#
+      #######`)
+    // Walking there is deadly; so is a bomb at fuse 1? Only what the sim says counts.
+    const a = tileActions(s, v(2, 1))
+    const all = a.filter((x) => x.possible)
+    expect(cursorDeadly(a)).toBe(all.every((x) => x.deadly))
+    expect(cursorDeadly(a.map((x) => ({ ...x, deadly: x.possible })))).toBe(true)
+    expect(cursorDeadly(a.map((x, i) => ({ ...x, deadly: x.possible && i === 0 })))).toBe(all.length === 1)
+    expect(cursorDeadly(a.map((x) => ({ ...x, deadly: false })))).toBe(false)
+  })
+  it('no possible action: not red', () => {
+    expect(cursorDeadly([])).toBe(false)
+    expect(cursorDeadly(tileActions(room(), v(1, 1)).map((x) => ({ ...x, deadly: true })))).toBe(false)
+  })
+  it('the ghost is the first of Move, Bomb, Charge; none on the own tile', () => {
+    const s = room()
+    expect(ghostAction(tileActions(s, v(4, 3)), v(4, 3), s.player)).toEqual({ id: 'move', target: v(4, 3) })
+    expect(ghostAction(tileActions(s, v(3, 3)), v(3, 3), s.player)).toBeNull()
+  })
+})
diff --git a/test/powerups.test.ts b/test/powerups.test.ts
index abf5d06..a04f4ca 100644
--- a/test/powerups.test.ts
+++ b/test/powerups.test.ts
@@ -241,7 +241,7 @@ describe('Detonate', () => {
 })
 
 describe('passes', () => {
-  it('Wall Pass walks through soft blocks', () => {
+  it('Crate Pass walks through soft blocks', () => {
     const s = board(`
       #####
       #P+.#
diff --git a/test/reveal.test.ts b/test/reveal.test.ts
new file mode 100644
index 0000000..aec04a3
--- /dev/null
+++ b/test/reveal.test.ts
@@ -0,0 +1,32 @@
+import { describe, expect, it } from 'vitest'
+import { revealShift } from '../src/render/reveal'
+
+// A 9 x 13 view centred on (10, 20): its edges are at x 5.5..14.5 and y 13.5..26.5.
+const aim = { x: 10, y: 20 }
+const shift = (x: number, y: number) => revealShift(aim, { x, y }, 9, 13)
+
+describe('revealShift', () => {
+  it('leaves a tile alone while it is more than a tile from the edge', () => {
+    expect(shift(10, 20)).toEqual({ x: 0, y: 0 })
+    expect(shift(13, 25)).toEqual({ x: 0, y: 0 })
+  })
+  it('moves the view once the tile is within a tile of the edge', () => {
+    expect(shift(14, 20)).toEqual({ x: 1, y: 0 })
+    expect(shift(6, 20)).toEqual({ x: -1, y: 0 })
+    expect(shift(10, 14)).toEqual({ x: 0, y: -1 })
+  })
+  it('moves both axes at a corner, and far tiles all the way', () => {
+    expect(shift(16, 28)).toEqual({ x: 3, y: 3 })
+  })
+})
+
+describe('revealShift on a board', () => {
+  it('gives only the shift the board allows', () => {
+    // The view's right edge is at 14.5; a board 15 wide lets it go 0.5 further, not 1.
+    expect(revealShift(aim, { x: 14, y: 20 }, 9, 13, 1, { w: 15, h: 40 })).toEqual({ x: 0.5, y: 0 })
+  })
+  it('is zero when the view is at the board edge, or the board is smaller than the view', () => {
+    expect(revealShift({ x: 10.5, y: 20 }, { x: 14, y: 20 }, 9, 13, 1, { w: 15, h: 40 })).toEqual({ x: 0, y: 0 })
+    expect(revealShift({ x: 4, y: 20 }, { x: 7, y: 20 }, 9, 13, 1, { w: 8, h: 40 })).toEqual({ x: 0, y: 0 })
+  })
+})
diff --git a/test/spatial.test.ts b/test/spatial.test.ts
new file mode 100644
index 0000000..d37e5c7
--- /dev/null
+++ b/test/spatial.test.ts
@@ -0,0 +1,68 @@
+import { describe, expect, it } from 'vitest'
+import { nextFocus, type FocusRect } from '../src/ui/spatial'
+
+const box = (x: number, y: number): FocusRect => ({ x, y, w: 10, h: 10 })
+// A 3 x 3 grid, indexes row by row:  0 1 2 / 3 4 5 / 6 7 8
+const grid = [0, 1, 2].flatMap((r) => [0, 1, 2].map((c) => box(c * 20, r * 20)))
+
+describe('nextFocus', () => {
+  it('moves one cell in each direction on a grid', () => {
+    expect(nextFocus(grid, 4, 0, -1)).toBe(1)
+    expect(nextFocus(grid, 4, 0, 1)).toBe(7)
+    expect(nextFocus(grid, 4, -1, 0)).toBe(3)
+    expect(nextFocus(grid, 4, 1, 0)).toBe(5)
+  })
+  it('stays put when nothing lies in that direction', () => {
+    expect(nextFocus(grid, 0, 0, -1)).toBe(0)
+    expect(nextFocus(grid, 0, -1, 0)).toBe(0)
+    expect(nextFocus(grid, 8, 0, 1)).toBe(8)
+    expect(nextFocus(grid, 8, 1, 0)).toBe(8)
+  })
+  it('weights sideways distance double, so a straight neighbour beats a nearer off-row one', () => {
+    // From 0 at (0,0) going right: A is far along the row, B is nearer but a row down.
+    const rects = [box(0, 0), box(60, 0), box(30, 20)]
+    expect(nextFocus(rects, 0, 1, 0)).toBe(1) // 60 vs 30 + 2*20 = 70
+    const near = [box(0, 0), box(60, 0), box(20, 10)]
+    expect(nextFocus(near, 0, 1, 0)).toBe(2) // 60 vs 20 + 2*10 = 40
+  })
+  it('an equal score goes to the lower index', () => {
+    const rects = [box(20, 20), box(40, 20), box(0, 20)]
+    expect(nextFocus(rects, 0, 1, 0)).toBe(1)
+    const tie = [box(20, 20), box(40, 20), box(40, 20)]
+    expect(nextFocus(tie, 0, 1, 0)).toBe(1)
+  })
+  it('does not count a centre level with the current one as in the direction', () => {
+    const rects = [box(0, 0), box(0, 20), box(20, 0)]
+    expect(nextFocus(rects, 0, 1, 0)).toBe(2)
+    expect(nextFocus(rects, 0, 0, 1)).toBe(1)
+    expect(nextFocus([box(0, 0), box(0, 20)], 0, 1, 0)).toBe(0)
+  })
+  it('uses centres, not corners, so rectangles of different sizes compare fairly', () => {
+    // Current: a wide bar, centre (50,5). A: a small box far left under it, centre (5,35).
+    // B: a tall box at the right, x 70..80, centre (75,35). Going down, by centres A costs
+    // 30 + 2*45 = 120 and B 30 + 2*25 = 80, so B wins. By left corners A (x 0) would be nearer
+    // than B (x 70) to the bar's left edge, and a corner-based measure would pick A.
+    const rects = [{ x: 0, y: 0, w: 100, h: 10 }, { x: 0, y: 30, w: 10, h: 10 }, { x: 70, y: 30, w: 10, h: 10 }]
+    expect(nextFocus(rects, 0, 0, 1)).toBe(2)
+  })
+  it('diagonals need both axes past the current centre', () => {
+    expect(nextFocus(grid, 4, 1, 1)).toBe(8)
+    expect(nextFocus(grid, 4, -1, -1)).toBe(0)
+    expect(nextFocus(grid, 4, 1, -1)).toBe(2)
+    expect(nextFocus(grid, 4, -1, 1)).toBe(6)
+    expect(nextFocus(grid, 2, 1, 1)).toBe(2)
+    expect(nextFocus(grid, 5, 1, 1)).toBe(5)
+  })
+  it('handles an empty list and a bad current index', () => {
+    expect(nextFocus([], 0, 1, 0)).toBe(-1)
+    expect(nextFocus([], -1, 1, 0)).toBe(-1)
+    expect(nextFocus(grid, -1, 1, 0)).toBe(0)
+    expect(nextFocus(grid, 99, 0, 1)).toBe(0)
+  })
+  it('a single rectangle stays focused', () => {
+    expect(nextFocus([box(0, 0)], 0, 1, 0)).toBe(0)
+  })
+  it('no direction means no move', () => {
+    expect(nextFocus(grid, 4, 0, 0)).toBe(4)
+  })
+})
diff --git a/test/targetLabel.test.ts b/test/targetLabel.test.ts
new file mode 100644
index 0000000..d31b976
--- /dev/null
+++ b/test/targetLabel.test.ts
@@ -0,0 +1,124 @@
+import { describe, expect, it } from 'vitest'
+import { targetLabel } from '../src/ui/targetLabel'
+import { board, v } from './helpers'
+
+// A roomy empty level, the player in the middle; enemies and bombs are added per case.
+const room = () =>
+  board(`
+    #######
+    #.....#
+    #.....#
+    #..P..#
+    #.....#
+    #.....#
+    #######`)
+
+describe('the controller target label', () => {
+  it('Move, Bomb and Wait', () => {
+    const s = room()
+    expect(targetLabel(s, 'move', v(4, 3))).toBe('Move to this tile')
+    expect(targetLabel(s, 'bomb', v(4, 3))).toBe('Place a bomb on this tile')
+    expect(targetLabel(s, 'wait', null)).toBe('Wait a turn')
+  })
+  it('a button with no tile and no Wait has nothing to do', () => {
+    expect(targetLabel(room(), 'move', null)).toBe('Nothing to do here')
+  })
+  it('Run, Diagonal and Slide are told apart by the tile', () => {
+    const s = room()
+    s.powers.run = true
+    s.powers.diagonal = true
+    expect(targetLabel(s, 'move', v(5, 3))).toBe('Run to this tile')
+    expect(targetLabel(s, 'move', v(4, 2))).toBe('Step diagonally to this tile')
+  })
+  it('Kick, with a bomb next to the player', () => {
+    const s = board(`
+      ######
+      #P3..#
+      ######`)
+    s.powers.kick = true
+    expect(targetLabel(s, 'move', v(2, 1))).toBe('Kick the bomb on this tile')
+  })
+  it('Kick and Bomb Pass together ask which', () => {
+    const s = board(`
+      ######
+      #P8..#
+      ######`)
+    s.powers.kick = true
+    s.powers.bombPass = true
+    expect(targetLabel(s, 'move', v(2, 1))).toBe('Choose what to do with the bomb on this tile')
+  })
+  it('Bomb Pass alone walks onto the bomb', () => {
+    const s = board(`
+      ######
+      #P8..#
+      ######`)
+    s.powers.bombPass = true
+    expect(targetLabel(s, 'move', v(2, 1))).toBe('Walk onto the bomb on this tile')
+  })
+  it('Throw, RPG and Detonator', () => {
+    const s = room()
+    s.powers.throw = true
+    s.powers.rpg = true
+    expect(targetLabel(s, 'bomb', v(5, 3))).toBe('Throw a bomb to this tile')
+    expect(targetLabel(s, 'bomb', v(5, 3))).not.toContain('RPG')
+    // The RPG lands at the end of a straight line, further than a throw reaches.
+    const lane = board(`
+      ########
+      #P.....#
+      ########`)
+    lane.powers.throw = true
+    lane.powers.rpg = true
+    expect(targetLabel(lane, 'bomb', v(3, 1))).toBe('Throw a bomb to this tile')
+    expect(targetLabel(lane, 'bomb', v(6, 1))).toBe('Fire an RPG bomb to this tile')
+    const d = board(`
+      ######
+      #P.7.#
+      ######`)
+    d.powers.detonate = true
+    expect(targetLabel(d, 'bomb', v(3, 1))).toBe('Set off the bomb on this tile')
+  })
+  it('each charge', () => {
+    const s = room()
+    s.charge = { kind: 'nuke', left: 1 }
+    expect(targetLabel(s, 'charged', v(4, 3))).toBe('Place a Nuke bomb on this tile')
+    s.charge = { kind: 'pierce', left: 1 }
+    expect(targetLabel(s, 'charged', v(4, 3))).toBe('Place a Pierce bomb on this tile')
+    s.charge = { kind: 'freeze', left: 1 }
+    expect(targetLabel(s, 'charged', v(3, 3))).toBe('Freeze every enemy')
+    s.charge = { kind: 'decoy', left: 1 }
+    expect(targetLabel(s, 'charged', v(4, 3))).toContain('Put a decoy on this tile')
+    s.charge = { kind: 'icbm', left: 1 }
+    expect(targetLabel(s, 'charged', v(1, 3))).toContain('Launch an ICBM to this tile')
+    s.charge = { kind: 'teleport', left: 1 }
+    expect(targetLabel(s, 'charged', v(5, 4))).toBe('Teleport to this tile')
+  })
+  it('says so when it would cost a life', () => {
+    // A bomb about to go off two tiles away: stepping next to it is deadly.
+    const s = board(`
+      #######
+      #P.1..#
+      #######`)
+    expect(targetLabel(s, 'move', v(2, 1))).toContain('(costs a life!)')
+  })
+  it('a tile that means nothing says so', () => {
+    expect(targetLabel(room(), 'move', v(1, 1))).toBe('Nothing to do here')
+  })
+})
+
+describe('the controller target label: oil', () => {
+  it('a step or a run onto oil slides on', () => {
+    const s = board(`
+      #######
+      #P~~~.#
+      #######`)
+    s.powers.run = true
+    expect(targetLabel(s, 'move', v(2, 1))).toBe('Slide to the end of the oil')
+    expect(targetLabel(s, 'move', v(3, 1))).toBe('Slide to the end of the oil')
+    // A lone oil tile has nowhere to slide on to.
+    const one = board(`
+      ######
+      #P~.##
+      ######`)
+    expect(targetLabel(one, 'move', v(2, 1))).toBe('Move to this tile')
+  })
+})
diff --git a/tools/compose-icons.sh b/tools/compose-icons.sh
index 18e85e4..254fd66 100644
--- a/tools/compose-icons.sh
+++ b/tools/compose-icons.sh
@@ -53,7 +53,7 @@ badge "$K/bomb.png" "$B/shield.png" powerup_bomb_pass
 badge "$K/fireball.png" "$B/shield.png" powerup_flame_pass
 
 # The charges (user request, 2026-10-23: they work on pickup now). Pierce and Power reuse the
-# Wall Pass and Fire+ icons above; the rest:
+# Crate Pass and Fire+ icons above; the rest:
 badge "$K/bomb.png" "$B/skull.png" charge_nuke
 ink "$I/locked.png" charge_freeze
 ink "$B/pawns.png" charge_decoy