fuzzyworld

explodorama / commit

Playtest fixes: flag clears off-path, menu with New run, info dialogs, Kenney UI packs

a4fab0fbe7a60d9d21fad71222735f53f6ee4c54 · Pavel Safronov · 2026-10-21 08:44:21 -0700 · parents b73dd42206d0

Claude Code usage: this commit ~64.6K out / ~25.2M cached · cost unknown (no per-model trailer) · session 1 tally 908 in / 539.9K out / 120.3M cached · usage by commit and session

Playtest fixes: flag clears off-path, menu with New run, info dialogs, Kenney UI packs

Moving off the auto-move path clears the flag. A menu (top right, or Esc)
shows level and seed and offers New run behind a confirmation. Tapping
anything that isn't an action explains it: enemies (with their next moves),
bombs, flame, walls, crates, the exit, the power-up, drops, and every top-bar
item; walkable things offer "Walk there". Dialogs use Kenney's UI Pack 2.0
and UI Pack: RPG Expansion (CC0), credited with the platformer pack.

Usage: 908 in / 539.9K out / 120.3M cached (session tally)
rtk:   1.7M saved, 25.6% over 2727 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
 PLAN.md                                            |  16 ++
 README.md                                          |  26 ++-
 assets/kenney-ui-rpg/license.txt                   |  14 ++
 assets/kenney-ui-rpg/panel_beigeLight.png          | Bin 0 -> 785 bytes
 assets/kenney-ui/Extra/icon_play_light.png         | Bin 0 -> 489 bytes
 assets/kenney-ui/Extra/icon_repeat_light.png       | Bin 0 -> 733 bytes
 .../Green/button_rectangle_depth_flat.png          | Bin 0 -> 785 bytes
 .../kenney-ui/Grey/button_rectangle_depth_flat.png | Bin 0 -> 786 bytes
 assets/kenney-ui/Grey/button_round_depth_flat.png  | Bin 0 -> 2470 bytes
 assets/kenney-ui/Grey/icon_cross.png               | Bin 0 -> 516 bytes
 assets/kenney-ui/Kenney Future.ttf                 | Bin 0 -> 34160 bytes
 assets/kenney-ui/License.txt                       |  22 +++
 .../kenney-ui/Red/button_rectangle_depth_flat.png  | Bin 0 -> 785 bytes
 decisions.md                                       |  17 ++
 docs/screenshots/auto-move.png                     | Bin 243724 -> 248711 bytes
 docs/screenshots/bomb-picked.png                   | Bin 243572 -> 248823 bytes
 docs/screenshots/charges.png                       | Bin 273479 -> 270548 bytes
 docs/screenshots/countdown.png                     | Bin 248462 -> 253449 bytes
 docs/screenshots/death.png                         | Bin 202732 -> 214407 bytes
 docs/screenshots/desktop.png                       | Bin 589981 -> 593323 bytes
 docs/screenshots/enemies.png                       | Bin 254864 -> 252484 bytes
 docs/screenshots/info-enemy.png                    | Bin 0 -> 192054 bytes
 docs/screenshots/info-hud.png                      | Bin 0 -> 182092 bytes
 docs/screenshots/lingering.png                     | Bin 252768 -> 257773 bytes
 docs/screenshots/long-press.png                    | Bin 246984 -> 252078 bytes
 docs/screenshots/menu.png                          | Bin 0 -> 186148 bytes
 docs/screenshots/new-run.png                       | Bin 0 -> 175001 bytes
 docs/screenshots/pierce-decoy.png                  | Bin 282258 -> 280079 bytes
 docs/screenshots/power-up.png                      | Bin 260047 -> 261789 bytes
 docs/screenshots/run.png                           | Bin 243745 -> 237155 bytes
 docs/screenshots/scrolled.png                      | Bin 257350 -> 261115 bytes
 docs/screenshots/start.png                         | Bin 242525 -> 247709 bytes
 index.html                                         |  12 +-
 src/main.ts                                        |  98 ++++++++--
 src/style.css                                      | 168 +++++++++++++++++
 src/ui/controls.ts                                 |  41 ++++-
 src/ui/dialog.ts                                   |  65 +++++++
 src/ui/info.ts                                     | 201 +++++++++++++++++++++
 tools/screenshots.mjs                              |  12 ++
 39 files changed, 663 insertions(+), 29 deletions(-)

diff --git a/PLAN.md b/PLAN.md
index 9543248..c4abd62 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -98,6 +98,14 @@ Starting values can be raised by Workshop tiers (§7).
 | **Place bomb** | On any **adjacent** free tile (orthogonal). Limited by bomb capacity. | Valid tiles. Hovering/pressing one shows the faded **blast cross** and the starting timer. |
 | **Wait** | Do nothing; the world advances one turn. | Nothing (or a subtle pulse on the player). |
 
+**Info and menu (user request, 2026-10-21; built):** tapping anything that isn't an action shows
+a dialog explaining it (`ui/info.ts`): enemies (their next moves in words), bombs, burning
+flame, walls and crates, the exit, the power-up, drops, and every top-bar item. Enemies, bombs and
+burning tiles always show info, even next to the player (so a tap never walks into an enemy).
+Walls and crates clear the auto-move flag if there is one, otherwise show info. Things the player
+can walk to get a **Walk there** button. A **menu** button (top right; Esc with nothing to
+cancel) shows level and seed, credits Kenney, and offers **New run** behind a confirmation.
+
 **Touch shortcuts (user request, 2026-10-20; built):**
 
 - **Tap the player** = Wait.
@@ -108,6 +116,8 @@ Starting values can be raised by Workshop tiers (§7).
   destination (flag + dotted path, next step highlighted). The Move button turns into **Go**;
   each press takes one step along the shortest walk (`sim/path.ts`, over free tiles, ignoring
   enemies), so it is still one turn per step and fully recorded as ordinary Move actions.
+  **Moving anywhere off the path clears the flag** ("changed my mind", user request, 2026-10-21):
+  any move, run, diagonal step or teleport that doesn't land on the path.
   **Tapping the flag also takes one step** (user request, 2026-10-20). Tapping another far tile
   moves the flag. Recomputed every turn; cleared on arrival, when unreachable, on Esc, by tapping
   a wall or crate, or on death / level change. Neither Go nor the flag takes a known-deadly step
@@ -420,6 +430,12 @@ plan of black Icongeek26 outline icons from Flaticon over a plain drawn board.
   sound is added (M7), copied into `assets/kenney/` like the sprites; ZzFX only for anything the
   pack has no sound for (e.g. the explosion).
 
+- **UI (2026-10-21):** dialogs and their buttons use Kenney's **UI Pack 2.0** (rectangle depth
+  buttons, green / red / grey; round grey button for close and the menu; play / repeat icons; the
+  **Kenney Future** font for titles) and the **UI Pack: RPG Expansion** parchment panel, as CSS
+  9-slice `border-image`s. Files in `assets/kenney-ui/` (pack colour folders kept) and
+  `assets/kenney-ui-rpg/`, each with its licence. Credited with the platformer pack.
+
 Sprite assignments (✅ = drawn now; the rest are copied and waiting for their feature):
 
 | Thing | Sprite(s) in `assets/kenney/` |
diff --git a/README.md b/README.md
index 9a33ed1..63d9837 100644
--- a/README.md
+++ b/README.md
@@ -31,11 +31,21 @@ where their arrows pointed, bombs count down and explode, and enemies show their
 **Move** button becomes **Go**: each press of Go — or each tap on the flag itself — takes **one
 step** along the way (one turn, as always — the world still moves between steps). The way is
 worked out again every turn. Tapping another far tile moves the flag there. Auto-move ends when
-you arrive, when the flag can no longer be reached, when you tap a wall or crate, or on **Esc**.
+you arrive, when you **move somewhere off the dotted way** (you changed your mind), when the flag
+can no longer be reached, when you tap a wall or crate, or on **Esc**.
 It **won't take a deadly step**: onto an enemy, onto a tile an enemy's arrow points at, or into
 flame this turn. The next step then shows red, and Go / the flag wait for you to deal with it
 (Go falls back to picking Move, so you can step aside yourself).
 
+**What is that?** Tap anything that isn't a move to see what it is and what it does: an
+**enemy** (with its next moves spelled out), a **bomb**, burning flame, a wall or crate, the exit,
+the power-up, a drop — and every item in the **top bar** (lives, level, enemies left, coins,
+turn / par, bombs, blast, fuse, passes, Star). For things you can walk to, the dialog has a
+**Walk there** button that sets the auto-move flag. Tapping an enemy never walks into it.
+
+**Menu:** the round button at the top right (or **Esc** with nothing picked). It shows the level
+and seed, and has **New run**, which asks first, since the current run can't be got back.
+
 **Press and hold** a tile next to you (about half a second, with a short buzz on phones that
 support it) to arm a bomb there: its blast shows, and letting go places it. Sliding off the
 tile first cancels it.
@@ -207,14 +217,18 @@ Design and decisions: [PLAN.md](PLAN.md), [decisions.md](decisions.md).
 
 ## Attribution
 
-Art: [New Platformer Pack](https://kenney.nl/assets/new-platformer-pack) 1.1 by
+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), all by
 [Kenney](https://www.kenney.nl), under [CC0](http://creativecommons.org/publicdomain/zero/1.0/)
 (public domain). Kenney doesn't require credit, but the pack was chosen deliberately and the game
-looks the way it does because of it, so it is credited here and in the game (on the banner shown
-after each death). Thank you, Kenney! Support his work at <https://www.kenney.nl/donate>.
+looks the way it does because of it, so it is credited here and in the game (in the menu, and on
+the banner shown after each death). Thank you, Kenney! Support his work at <https://www.kenney.nl/donate>.
 
-The sprites used are copied, unchanged and under their original file names, into
-[`assets/kenney/`](assets/kenney/) with the pack's [licence file](assets/kenney/License.txt). They
+The files used are copied, unchanged and under their original file names, into
+[`assets/kenney/`](assets/kenney/), [`assets/kenney-ui/`](assets/kenney-ui/) (keeping the pack's
+colour folders) and [`assets/kenney-ui-rpg/`](assets/kenney-ui-rpg/), each with its pack's
+licence file. They
 are not covered by this project's MIT licence. Sound effects, when the game gets them, will come
 from the same pack.
 
diff --git a/assets/kenney-ui-rpg/license.txt b/assets/kenney-ui-rpg/license.txt
new file mode 100644
index 0000000..68c10dd
--- /dev/null
+++ b/assets/kenney-ui-rpg/license.txt
@@ -0,0 +1,14 @@
+

+###############################################################################

+

+	UI pack: RPG extension by Kenney Vleugels (www.kenney.nl)

+

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

+

+			        License (CC0)

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

+

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

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

+

+###############################################################################
\ No newline at end of file
diff --git a/assets/kenney-ui-rpg/panel_beigeLight.png b/assets/kenney-ui-rpg/panel_beigeLight.png
new file mode 100644
index 0000000..8ccf82c
Binary files /dev/null and b/assets/kenney-ui-rpg/panel_beigeLight.png differ
diff --git a/assets/kenney-ui/Extra/icon_play_light.png b/assets/kenney-ui/Extra/icon_play_light.png
new file mode 100644
index 0000000..8236295
Binary files /dev/null and b/assets/kenney-ui/Extra/icon_play_light.png differ
diff --git a/assets/kenney-ui/Extra/icon_repeat_light.png b/assets/kenney-ui/Extra/icon_repeat_light.png
new file mode 100644
index 0000000..f5e0387
Binary files /dev/null and b/assets/kenney-ui/Extra/icon_repeat_light.png differ
diff --git a/assets/kenney-ui/Green/button_rectangle_depth_flat.png b/assets/kenney-ui/Green/button_rectangle_depth_flat.png
new file mode 100644
index 0000000..cb06457
Binary files /dev/null and b/assets/kenney-ui/Green/button_rectangle_depth_flat.png differ
diff --git a/assets/kenney-ui/Grey/button_rectangle_depth_flat.png b/assets/kenney-ui/Grey/button_rectangle_depth_flat.png
new file mode 100644
index 0000000..7ac4ba7
Binary files /dev/null and b/assets/kenney-ui/Grey/button_rectangle_depth_flat.png differ
diff --git a/assets/kenney-ui/Grey/button_round_depth_flat.png b/assets/kenney-ui/Grey/button_round_depth_flat.png
new file mode 100644
index 0000000..8ae447e
Binary files /dev/null and b/assets/kenney-ui/Grey/button_round_depth_flat.png differ
diff --git a/assets/kenney-ui/Grey/icon_cross.png b/assets/kenney-ui/Grey/icon_cross.png
new file mode 100644
index 0000000..eb80975
Binary files /dev/null and b/assets/kenney-ui/Grey/icon_cross.png differ
diff --git a/assets/kenney-ui/Kenney Future.ttf b/assets/kenney-ui/Kenney Future.ttf
new file mode 100644
index 0000000..17e2b6c
Binary files /dev/null and b/assets/kenney-ui/Kenney Future.ttf differ
diff --git a/assets/kenney-ui/License.txt b/assets/kenney-ui/License.txt
new file mode 100644
index 0000000..a8334da
--- /dev/null
+++ b/assets/kenney-ui/License.txt
@@ -0,0 +1,22 @@
+	

+

+	UI Pack (2.0)

+

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

+	Creation date: 12-06-2024

+

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

+

+	License: (Creative Commons Zero, CC0)

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

+

+	This content is free to use in personal, educational and commercial projects.


+	Support us by crediting Kenney or www.kenney.nl (this is not mandatory)

+

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

+

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

+	Patreon:  http://patreon.com/kenney/

+

+	Follow on Twitter for updates:

+	http://twitter.com/KenneyNL
\ No newline at end of file
diff --git a/assets/kenney-ui/Red/button_rectangle_depth_flat.png b/assets/kenney-ui/Red/button_rectangle_depth_flat.png
new file mode 100644
index 0000000..01883b0
Binary files /dev/null and b/assets/kenney-ui/Red/button_rectangle_depth_flat.png differ
diff --git a/decisions.md b/decisions.md
index bc4ef2d..f8b18e3 100644
--- a/decisions.md
+++ b/decisions.md
@@ -176,3 +176,20 @@ fix, two parts:
   the seed. The victory certificate is M8.
 - `SIM_VERSION` is now 4: older saved runs are not resumed (they would replay differently).
 
+### 2026-10-21 — Playtest feedback: flag, new run, info dialogs, Kenney UI packs
+
+- **The auto-move flag clears when the player moves off its path.** Any player move (move, run,
+  diagonal, teleport) that doesn't land on a tile of the current path means "changed my mind".
+  Bombs, waits and charges don't clear it.
+- **New run lives in a menu, behind a confirmation**, since a run can't be got back. The menu
+  button is in the top bar; Esc opens it when there is nothing to cancel.
+- **Tapping anything that isn't an action explains it.** One native `<dialog>` (focus trap, Esc
+  to close) for info, the menu and confirmations. Enemies, bombs and burning tiles always explain
+  rather than act, even next to the player: a tap on an enemy used to walk into it, which was
+  never what anyone meant. Walls and crates keep clearing the flag when there is one (that tap
+  did nothing else), otherwise explain. Things you can walk to offer "Walk there".
+- **UI art from Kenney's UI Pack 2.0 and UI Pack: RPG Expansion** (the user added both, CC0),
+  as CSS 9-slice `border-image`s so the panels and buttons stretch to any text. Credited in the
+  README and the menu like the platformer pack. The action bar keeps its CSS-drawn buttons for
+  now; they already follow the same look.
+
diff --git a/docs/screenshots/auto-move.png b/docs/screenshots/auto-move.png
index a61e0b0..4b18454 100644
Binary files a/docs/screenshots/auto-move.png and b/docs/screenshots/auto-move.png differ
diff --git a/docs/screenshots/bomb-picked.png b/docs/screenshots/bomb-picked.png
index 76cc335..8826509 100644
Binary files a/docs/screenshots/bomb-picked.png and b/docs/screenshots/bomb-picked.png differ
diff --git a/docs/screenshots/charges.png b/docs/screenshots/charges.png
index ba0bdc6..7a0c39f 100644
Binary files a/docs/screenshots/charges.png and b/docs/screenshots/charges.png differ
diff --git a/docs/screenshots/countdown.png b/docs/screenshots/countdown.png
index 0077429..72d3c92 100644
Binary files a/docs/screenshots/countdown.png and b/docs/screenshots/countdown.png differ
diff --git a/docs/screenshots/death.png b/docs/screenshots/death.png
index 44cc235..cffcc5d 100644
Binary files a/docs/screenshots/death.png and b/docs/screenshots/death.png differ
diff --git a/docs/screenshots/desktop.png b/docs/screenshots/desktop.png
index 3a81306..e85a36b 100644
Binary files a/docs/screenshots/desktop.png and b/docs/screenshots/desktop.png differ
diff --git a/docs/screenshots/enemies.png b/docs/screenshots/enemies.png
index a2587b4..3ebf454 100644
Binary files a/docs/screenshots/enemies.png and b/docs/screenshots/enemies.png differ
diff --git a/docs/screenshots/info-enemy.png b/docs/screenshots/info-enemy.png
new file mode 100644
index 0000000..7c758dd
Binary files /dev/null and b/docs/screenshots/info-enemy.png differ
diff --git a/docs/screenshots/info-hud.png b/docs/screenshots/info-hud.png
new file mode 100644
index 0000000..03168b3
Binary files /dev/null and b/docs/screenshots/info-hud.png differ
diff --git a/docs/screenshots/lingering.png b/docs/screenshots/lingering.png
index 4a9396d..6314908 100644
Binary files a/docs/screenshots/lingering.png and b/docs/screenshots/lingering.png differ
diff --git a/docs/screenshots/long-press.png b/docs/screenshots/long-press.png
index 3d9c8f0..963e501 100644
Binary files a/docs/screenshots/long-press.png and b/docs/screenshots/long-press.png differ
diff --git a/docs/screenshots/menu.png b/docs/screenshots/menu.png
new file mode 100644
index 0000000..a8706d0
Binary files /dev/null and b/docs/screenshots/menu.png differ
diff --git a/docs/screenshots/new-run.png b/docs/screenshots/new-run.png
new file mode 100644
index 0000000..9545bbe
Binary files /dev/null and b/docs/screenshots/new-run.png differ
diff --git a/docs/screenshots/pierce-decoy.png b/docs/screenshots/pierce-decoy.png
index 8b03aaf..ccf311b 100644
Binary files a/docs/screenshots/pierce-decoy.png and b/docs/screenshots/pierce-decoy.png differ
diff --git a/docs/screenshots/power-up.png b/docs/screenshots/power-up.png
index c3c4fcf..c5d51fe 100644
Binary files a/docs/screenshots/power-up.png and b/docs/screenshots/power-up.png differ
diff --git a/docs/screenshots/run.png b/docs/screenshots/run.png
index 51a6d45..81c9777 100644
Binary files a/docs/screenshots/run.png and b/docs/screenshots/run.png differ
diff --git a/docs/screenshots/scrolled.png b/docs/screenshots/scrolled.png
index 01a738b..ae2178a 100644
Binary files a/docs/screenshots/scrolled.png and b/docs/screenshots/scrolled.png differ
diff --git a/docs/screenshots/start.png b/docs/screenshots/start.png
index bbab4ec..4e47755 100644
Binary files a/docs/screenshots/start.png and b/docs/screenshots/start.png differ
diff --git a/index.html b/index.html
index 0ed27fa..c2d1a92 100644
--- a/index.html
+++ b/index.html
@@ -19,14 +19,24 @@
   <span id="hud-coins"></span>
   <span id="hud-turn"></span>
   <span id="hud-stats"></span>
+  <button id="menu-button" type="button" aria-label="Menu"><span></span><span></span><span></span></button>
 </header>
 <nav id="actions" aria-label="Actions"></nav>
 <div id="banner" hidden>
   <img id="banner-icon" alt="" />
   <p id="banner-text"></p>
   <button id="banner-button" type="button"></button>
-  <p class="credit">Art by <a href="https://www.kenney.nl" target="_blank" rel="noopener">Kenney</a> · New Platformer Pack (CC0)</p>
+  <p class="credit">Art by <a href="https://www.kenney.nl" target="_blank" rel="noopener">Kenney</a> · New Platformer Pack, UI Pack, UI Pack: RPG Expansion (CC0)</p>
 </div>
+<dialog id="dialog" aria-labelledby="dialog-title">
+  <div class="panel" tabindex="-1">
+    <button id="dialog-close" type="button" aria-label="Close"></button>
+    <img id="dialog-icon" alt="" />
+    <h2 id="dialog-title"></h2>
+    <div id="dialog-body"></div>
+    <div id="dialog-buttons"></div>
+  </div>
+</dialog>
 <script type="module" src="/src/main.ts"></script>
 </body>
 </html>
diff --git a/src/main.ts b/src/main.ts
index 9f80ba6..66fb217 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -18,10 +18,15 @@ import { enemyAt } from './sim/enemies'
 import { playerPath } from './sim/path'
 import { predictDanger, type Danger } from './sim/danger'
 import { newRecord, replay, SIM_VERSION, type RunRecord } from './sim/record'
-import { eq, idx, inBounds, newRun } from './sim/state'
+import { bombAt, cellAt, eq, idx, inBounds, isBurning, newRun } from './sim/state'
 import { step } from './sim/turn'
 import { DIRS, type Action, type ActionId, type Dir, type GameState, type Vec } from './sim/types'
 import { createControls } from './ui/controls'
+import { dialogOpen, openDialog, type DialogButton } from './ui/dialog'
+import { hudInfo, tileInfo, type Info } from './ui/info'
+import { spriteUrl } from './render/sprites'
+import playIcon from '../assets/kenney-ui/Extra/icon_play_light.png'
+import newRunIcon from '../assets/kenney-ui/Extra/icon_repeat_light.png'
 
 // `?seed=123&level=4` gives a reproducible board (screenshots, bug reports). Otherwise a random
 // seed — chosen here, never inside the sim.
@@ -48,6 +53,10 @@ let pressTile: Vec | null = null
 const LONG_PRESS_MS = 450
 
 function startRun(seed: number): void {
+  selected = null
+  destination = null
+  flames = []
+  snapCamera = true
   record = newRecord(seed, startLevel, startCharges)
   state = newRun(seed, startLevel, startCharges)
   save()
@@ -93,7 +102,11 @@ function randomSeed(): number {
   return Math.floor(Math.random() * 2 ** 31)
 }
 
+/** The actions that move the player. One that doesn't land on the auto-move path means "changed my mind". */
+const MOVES: ReadonlySet<Action['id']> = new Set(['move', 'run', 'diagonal', 'teleport'])
+
 function act(action: Action): void {
+  if (MOVES.has(action.id) && 'target' in action && !path?.some((t) => eq(t, action.target!))) destination = null
   const result = step(state, action)
   state = result.state
   record.actions.push(action)
@@ -119,10 +132,14 @@ function afterChange(): void {
 }
 
 /**
- * Two taps: pick an action, then a highlighted tile. With nothing picked: a tap next to the
- * player moves there, a tap on the player waits, a tap on a reachable tile further away sets it
- * as the auto-move destination, a tap on that flag takes one step toward it (like Go), and a tap
- * on a wall or crate clears it.
+ * Two taps: pick an action, then a highlighted tile. With nothing picked:
+ * - the player: Wait;
+ * - an enemy, a bomb, or flame still burning: what it is (info), never a move into it;
+ * - the auto-move flag: one step toward it (like Go), with auto-move's safety check;
+ * - a tile next to the player: move there;
+ * - a wall or crate: clears the flag if there is one, otherwise what it is;
+ * - the exit, the power-up, a drop or the decoy further away: what it is, with "Walk there";
+ * - any other reachable tile further away: set it as the auto-move destination.
  */
 function tapTile(p: Vec): void {
   if (state.status !== 'playing') return
@@ -133,19 +150,71 @@ function tapTile(p: Vec): void {
       afterChange()
     }
   } else if (eq(p, state.player)) pick('wait')
-  // The flag first: even when it is next to the player, tapping it is an auto-move step, with
-  // auto-move's safety check, not a plain move.
+  else if (enemyAt(state, p) || bombAt(state, p) || isBurning(state, p)) showTileInfo(p)
   else if (destination && eq(destination, p)) stepTowardDestination()
   else if (isValidTarget(state, 'move', p)) act({ id: 'move', target: p })
-  else if (playerPath(state, p)) {
-    destination = p
-    afterChange()
-  } else if (destination) {
+  else if (cellAt(state, p) !== 'floor') {
+    if (destination) {
+      destination = null
+      afterChange()
+    } else showTileInfo(p)
+  } else if (tileInfo(state, p)) showTileInfo(p)
+  else if (playerPath(state, p)) setDestination(p)
+  else if (destination) {
     destination = null
     afterChange()
   }
 }
 
+function setDestination(p: Vec): void {
+  destination = p
+  afterChange()
+}
+
+/** The info dialog for a tile; with "Walk there" when it is something to walk to. */
+function showTileInfo(p: Vec): void {
+  const info = tileInfo(state, p)
+  if (!info) return
+  const reachable = cellAt(state, p) === 'floor' && !isBurning(state, p) && !enemyAt(state, p) && playerPath(state, p)
+  showInfo(info, reachable ? [{ label: 'Walk there', kind: 'primary', onClick: () => setDestination(p) }] : [])
+}
+
+function showInfo(info: Info, extra: DialogButton[] = []): void {
+  openDialog({
+    icon: spriteUrl(info.icon),
+    title: info.title,
+    body: info.body.map((t) => `<p>${t}</p>`).join(''),
+    buttons: [...extra, { label: 'Got it', kind: 'plain' }],
+  })
+}
+
+/** The menu: resume, or abandon this run for a new one (confirmed first, since it can't be undone). */
+function showMenu(): void {
+  const where = state.status === 'playing' ? `Level ${state.level}, turn ${state.turn}. ` : ''
+  openDialog({
+    title: 'Menu',
+    body:
+      `<p>${where}Seed ${state.seed}. The game saves after every move, so you can close it and come back.</p>` +
+      `<p class="credit">Art by <a href="https://www.kenney.nl" target="_blank" rel="noopener">Kenney</a>: ` +
+      `New Platformer Pack, UI Pack and UI Pack: RPG Expansion (CC0). Thank you!</p>`,
+    buttons: [
+      { label: 'Resume', kind: 'primary', icon: playIcon },
+      { label: 'New run', kind: 'danger', icon: newRunIcon, onClick: confirmNewRun },
+    ],
+  })
+}
+
+function confirmNewRun(): void {
+  openDialog({
+    title: 'Start a new run?',
+    body: `<p>This run — level ${state.level}, ${state.coins} coins, ${state.lives} ${state.lives === 1 ? 'life' : 'lives'} — will be lost. This can’t be undone.</p>`,
+    buttons: [
+      { label: 'Start a new run', kind: 'danger', icon: newRunIcon, onClick: () => startRun(randomSeed()) },
+      { label: 'Keep playing', kind: 'plain' },
+    ],
+  })
+}
+
 /**
  * Whether auto-move may take this step: not onto an enemy, not onto the tile an enemy moves to
  * next, and not into flame this turn. Those would be deaths the player didn't choose.
@@ -190,6 +259,8 @@ const controls = createControls({
     if (state.status === 'dead' || state.status === 'cleared') act({ id: 'continue' })
     else if (state.status === 'gameOver' || state.status === 'won') startRun(randomSeed())
   },
+  info: (item) => showInfo(hudInfo(item, state)),
+  menu: showMenu,
 })
 
 const KEY_DIRS: Record<string, Dir> = {
@@ -208,7 +279,8 @@ const ACTION_KEYS: Record<string, ActionId> = {
 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 } }
 
 window.addEventListener('keydown', (e) => {
-  if (e.repeat) return
+  // The dialog has the keyboard while it is open (Esc closes it, Tab moves between its buttons).
+  if (e.repeat || dialogOpen()) return
   const d = KEY_DIRS[e.code]
   const diag = selected === 'diagonal' ? DIAGONAL_KEYS[e.code] : undefined
   if (diag) {
@@ -218,6 +290,8 @@ window.addEventListener('keydown', (e) => {
   else if (e.code === 'Space') pick('wait')
   else if (ACTION_KEYS[e.code]) pick(ACTION_KEYS[e.code]!)
   else if (e.code === 'Escape') {
+    // Cancels what is picked, or the flag; with nothing to cancel, opens the menu.
+    if (!selected && !destination) return showMenu()
     selected = null
     destination = null
     afterChange()
diff --git a/src/style.css b/src/style.css
index 2879045..61e9f97 100644
--- a/src/style.css
+++ b/src/style.css
@@ -278,3 +278,171 @@ img {
   font: inherit;
   font-weight: 800;
 }
+
+/* ---- Kenney UI pack: the font, the dialog, its buttons, and the menu button ---- */
+
+@font-face {
+  font-family: 'Kenney Future';
+  src: url('../assets/kenney-ui/Kenney Future.ttf') format('truetype');
+  font-display: swap;
+}
+
+/* The top bar's items are buttons that open their info. */
+#hud .hud-item {
+  display: flex;
+  align-items: center;
+  padding: 2px;
+  border: 0;
+  border-radius: 8px;
+  background: none;
+  color: inherit;
+  font: inherit;
+  cursor: pointer;
+}
+
+#hud .hud-item:active {
+  background: rgb(255 255 255 / 0.15);
+}
+
+#hud .hud-item + .hud-item {
+  margin-left: 4px;
+}
+
+#menu-button {
+  display: grid;
+  place-content: center;
+  gap: 3px;
+  width: 40px;
+  height: 40px;
+  margin-left: auto;
+  padding: 0 0 3px;
+  border: 0;
+  background: url('../assets/kenney-ui/Grey/button_round_depth_flat.png') center / contain no-repeat;
+  cursor: pointer;
+}
+
+#menu-button span {
+  width: 16px;
+  height: 3px;
+  border-radius: 2px;
+  background: var(--ink);
+}
+
+#dialog {
+  max-width: min(360px, calc(100vw - 32px));
+  overflow: visible;
+  padding: 0;
+  border: 0;
+  background: none;
+  color: var(--ink);
+}
+
+#dialog::backdrop {
+  background: rgb(53 53 65 / 0.6);
+}
+
+/* The RPG expansion's parchment panel, stretched by its edges (9-slice). */
+#dialog .panel {
+  position: relative;
+  display: grid;
+  justify-items: center;
+  gap: 10px;
+  padding: 14px 18px 18px;
+  border: 12px solid transparent;
+  border-image: url('../assets/kenney-ui-rpg/panel_beigeLight.png') 12 fill / 12px stretch;
+  text-align: center;
+}
+
+#dialog .panel:focus {
+  outline: none;
+}
+
+#dialog-close {
+  position: absolute;
+  top: -26px;
+  right: -26px;
+  width: 40px;
+  height: 40px;
+  padding: 0;
+  border: 0;
+  background:
+    url('../assets/kenney-ui/Grey/icon_cross.png') center 45% / 16px no-repeat,
+    url('../assets/kenney-ui/Grey/button_round_depth_flat.png') center / contain no-repeat;
+  cursor: pointer;
+}
+
+#dialog-icon {
+  width: 64px;
+  height: 64px;
+}
+
+#dialog-title {
+  margin: 0;
+  font: 22px/1.2 'Kenney Future', ui-rounded, system-ui, sans-serif;
+}
+
+#dialog-body {
+  font-size: 15px;
+  line-height: 1.4;
+}
+
+#dialog-body p {
+  margin: 0 0 8px;
+}
+
+#dialog-body .credit {
+  font-size: 12px;
+  color: var(--muted);
+}
+
+#dialog-body a {
+  color: inherit;
+}
+
+/* Keyboard focus in the dialog: a yellow ring, like the count badges. */
+#dialog button:focus-visible {
+  outline: 3px solid #ffc83d;
+  outline-offset: 2px;
+}
+
+#dialog-buttons {
+  display: grid;
+  gap: 8px;
+  width: 100%;
+}
+
+/* The UI pack's rectangle buttons, stretched by their edges; the darker strip at the bottom is
+   the button's depth, which it sinks into when pressed. */
+.ui-button {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 8px;
+  min-height: 52px;
+  padding: 0 12px;
+  border-style: solid;
+  border-width: 12px 12px 16px;
+  border-color: transparent;
+  border-image: url('../assets/kenney-ui/Green/button_rectangle_depth_flat.png') 24 24 32 fill / 12px 12px 16px stretch;
+  background: none;
+  color: #fff;
+  font: 16px 'Kenney Future', ui-rounded, system-ui, sans-serif;
+  cursor: pointer;
+}
+
+.ui-button.danger {
+  border-image-source: url('../assets/kenney-ui/Red/button_rectangle_depth_flat.png');
+}
+
+.ui-button.plain {
+  border-image-source: url('../assets/kenney-ui/Grey/button_rectangle_depth_flat.png');
+  color: var(--ink);
+}
+
+.ui-button:active {
+  transform: translateY(3px);
+}
+
+.ui-button img {
+  height: 20px;
+}
diff --git a/src/ui/controls.ts b/src/ui/controls.ts
index 1f1f3a8..53e3d8f 100644
--- a/src/ui/controls.ts
+++ b/src/ui/controls.ts
@@ -2,6 +2,7 @@ import { ACTIONS, ACTION_ORDER, chargeOf, isAvailable } from '../sim/actions'
 import { PAR_BONUS, START_LIVES } from '../sim/state'
 import type { ActionId, GameState, PowerUpKind, Powers } from '../sim/types'
 import { CHARGE_ICONS, POWER_UP_ICONS, spriteUrl } from '../render/sprites'
+import type { HudItem } from './info'
 
 /** The HTML around the board: HUD, action bar, and the banner shown between attempts and levels. */
 export interface Handlers {
@@ -9,6 +10,9 @@ export interface Handlers {
   pick(id: ActionId): void
   /** The banner's button: continue after losing a life or clearing a level, or a new run after game over or the ending. */
   banner(): void
+  /** A top-bar item was tapped: show what it means. */
+  info(item: HudItem): void
+  menu(): void
 }
 
 export const KEYS: Record<ActionId, string> = {
@@ -32,6 +36,11 @@ const PASSES: [keyof Powers, PowerUpKind, string][] = [
 /** What a death takes away (the NES rule; Skates are kept). */
 const LOST_ON_DEATH = PASSES.filter(([p]) => p !== 'run')
 
+/** A top-bar item: a button that opens its info (`data-info`, see ui/info.ts). */
+function item(info: HudItem, html: string): string {
+  return `<button type="button" class="hud-item" data-info="${info}">${html}</button>`
+}
+
 function icon(name: string, className = 'icon'): string {
   return `<img class="${className}" src="${spriteUrl(name)}" alt="" draggable="false">`
 }
@@ -63,6 +72,13 @@ export function createControls(handlers: Handlers) {
   const bannerButton = document.getElementById('banner-button') as HTMLButtonElement
   bannerButton.addEventListener('click', () => handlers.banner())
 
+  const hudEl = document.getElementById('hud')!
+  hudEl.addEventListener('click', (e) => {
+    const b = (e.target as Element).closest<HTMLElement>('[data-info]')
+    if (b) handlers.info(b.dataset.info as HudItem)
+  })
+  document.getElementById('menu-button')!.addEventListener('click', () => handlers.menu())
+
   const hud = {
     lives: document.getElementById('hud-lives')!,
     level: document.getElementById('hud-level')!,
@@ -97,21 +113,26 @@ export function createControls(handlers: Handlers) {
       const hearts = Array.from({ length: Math.max(s.lives, START_LIVES) }, (_, i) =>
         icon(i < s.lives ? 'hud_heart' : 'hud_heart_empty'),
       )
-      hud.lives.innerHTML = `<span class="sr-only">${s.lives} lives</span>${hearts.join('')}`
-      hud.level.innerHTML = icon('flag_green_a') + number(s.level, 'Level ')
-      hud.enemies.innerHTML = icon('slime_normal_rest') + number(s.enemies.length, 'Enemies left ')
-      hud.coins.innerHTML = icon('hud_coin') + number(s.coins, 'Coins ')
+      hud.lives.innerHTML = item('lives', `<span class="sr-only">${s.lives} lives</span>${hearts.join('')}`)
+      hud.level.innerHTML = item('level', icon('flag_green_a') + number(s.level, 'Level '))
+      hud.enemies.innerHTML = item('enemies', icon('slime_normal_rest') + number(s.enemies.length, 'Enemies left '))
+      hud.coins.innerHTML = item('coins', icon('hud_coin') + number(s.coins, 'Coins '))
       // Turns taken, and the par to beat for the bonus.
-      hud.turn.innerHTML =
-        `<small aria-hidden="true">Turn</small>` + number(s.turn, 'Turn ') + `<small class="par">/${s.par}</small>`
+      hud.turn.innerHTML = item(
+        'turn',
+        `<small aria-hidden="true">Turn</small>` + number(s.turn, 'Turn ') + `<small class="par">/${s.par}</small>`,
+      )
       // Bomb limit, blast size and fuse, then a small icon for each pass held and the Star's turns.
       const passes = PASSES.filter(([p]) => s.powers[p])
-        .map(([, kind, name]) => `<span class="sr-only">, ${name}</span>${icon(POWER_UP_ICONS[kind], 'icon small')}`)
+        .map(([p, kind, name]) => item(p, `<span class="sr-only">${name}</span>${icon(POWER_UP_ICONS[kind], 'icon small')}`))
         .join('')
-      const star = s.star > 0 ? icon('star', 'icon small') + number(s.star, ', Star for ') : ''
+      const star = s.star > 0 ? item('star', icon('star', 'icon small') + number(s.star, 'Star for ')) : ''
       hud.stats.innerHTML =
-        icon('bomb') + number(s.stats.capacity, 'Bombs ') + icon('fireball') + number(s.stats.fire, ', fire ') +
-        icon('torch_on_a') + number(s.stats.fuse, ', fuse ') + passes + star
+        item('bombs', icon('bomb') + number(s.stats.capacity, 'Bombs ')) +
+        item('fire', icon('fireball') + number(s.stats.fire, 'Blast ')) +
+        item('fuse', icon('torch_on_a') + number(s.stats.fuse, 'Fuse ')) +
+        passes +
+        star
 
       banner.hidden = s.status === 'playing'
       if (s.status === 'dead') {
diff --git a/src/ui/dialog.ts b/src/ui/dialog.ts
new file mode 100644
index 0000000..61527dd
--- /dev/null
+++ b/src/ui/dialog.ts
@@ -0,0 +1,65 @@
+/**
+ * The one modal dialog: info about something tapped, the menu, and confirmations. A native
+ * `<dialog>` shown with `showModal()`, so it blocks the board, traps focus and closes on Esc.
+ * Styled with Kenney's UI packs (style.css).
+ */
+
+export interface DialogButton {
+  label: string
+  /** primary: green; danger: red; plain: grey. */
+  kind: 'primary' | 'danger' | 'plain'
+  /** An image URL shown before the label. */
+  icon?: string
+  /** Runs after the dialog closes. */
+  onClick?: () => void
+}
+
+export interface DialogContent {
+  /** Image URL for the picture at the top. */
+  icon?: string
+  title: string
+  /** Trusted HTML built by the game itself (never from user input). */
+  body: string
+  buttons: DialogButton[]
+}
+
+const dialog = document.getElementById('dialog') as HTMLDialogElement
+const icon = document.getElementById('dialog-icon') as HTMLImageElement
+const title = document.getElementById('dialog-title')!
+const body = document.getElementById('dialog-body')!
+const buttons = document.getElementById('dialog-buttons')!
+const panel = dialog.querySelector<HTMLElement>('.panel')!
+
+document.getElementById('dialog-close')!.addEventListener('click', () => dialog.close())
+// A tap on the dimmed backdrop (the dialog element itself, outside its panel) closes it.
+dialog.addEventListener('click', (e) => {
+  if (e.target === dialog) dialog.close()
+})
+
+export function openDialog(content: DialogContent): void {
+  icon.hidden = !content.icon
+  if (content.icon) icon.src = content.icon
+  title.textContent = content.title
+  body.innerHTML = content.body
+  buttons.replaceChildren(
+    ...content.buttons.map((b) => {
+      const el = document.createElement('button')
+      el.type = 'button'
+      el.className = `ui-button ${b.kind}`
+      el.innerHTML = (b.icon ? `<img src="${b.icon}" alt="">` : '') + `<span>${b.label}</span>`
+      el.addEventListener('click', () => {
+        dialog.close()
+        b.onClick?.()
+      })
+      return el
+    }),
+  )
+  if (dialog.open) dialog.close()
+  dialog.showModal()
+  // Focus the panel, not the first button, so nothing looks pre-selected; Tab still reaches the buttons.
+  panel.focus()
+}
+
+export function dialogOpen(): boolean {
+  return dialog.open
+}
diff --git a/src/ui/info.ts b/src/ui/info.ts
new file mode 100644
index 0000000..c584a52
--- /dev/null
+++ b/src/ui/info.ts
@@ -0,0 +1,201 @@
+import { DECOY_TURNS, TELEPORT_RANGE } from '../sim/actions'
+import { LAST_LEVEL } from '../sim/levelgen'
+import { bombAt, cellAt, eq, exitOpen, isBurning, PAR_BONUS } from '../sim/state'
+import { COIN_BAG, EXIT_SPAWN, STAR_TURNS } from '../sim/turn'
+import type { ChargeId, Dir, EnemyKind, GameState, Powers, PowerUpKind, Vec } from '../sim/types'
+import { dirBetween } from '../sim/enemies'
+import { CHARGE_ICONS, coinSprite, ENEMY_SPRITES, POWER_UP_ICONS, spriteUrl } from '../render/sprites'
+
+/**
+ * What the game says about each thing on the board and in the top bar, for the info dialog
+ * (tap anything that isn't an action). Plain words, numbers from the current state.
+ */
+export interface Info {
+  /** Sprite name, from `assets/kenney/`. */
+  icon: string
+  title: string
+  /** Paragraphs of plain text. */
+  body: string[]
+}
+
+export const ENEMY_TEXT: Record<EnemyKind, { name: string; moves: string }> = {
+  blob: { name: 'Blob', moves: 'Wanders, usually carrying on the way it was going.' },
+  seeker: { name: 'Seeker', moves: 'Half the time it takes the shortest way toward you (or your decoy); otherwise it wanders like a Blob.' },
+  drifter: { name: 'Drifter', moves: 'Goes straight until something blocks it, then turns.' },
+}
+
+export const POWER_UP_TEXT: Record<PowerUpKind, { name: string; text: string }> = {
+  bombPlus: { name: 'Bomb +1', text: 'You can have one more bomb on the board at a time.' },
+  firePlus: { name: 'Fire +1', text: 'Your blasts reach one tile further in each direction.' },
+  fusePlus: { name: 'Fuse +1', text: 'Your bombs start one higher: one more turn to get away.' },
+  skates: { name: 'Skates', text: 'Gives you Run: 2 tiles in a straight line in one turn. Both tiles must be empty. Kept when you die.' },
+  detonator: { name: 'Detonator', text: 'Gives you Detonate: pick one of your bombs and it goes off this turn. Lost when you die.' },
+  wallPass: { name: 'Wall Pass', text: 'You can walk through crates. Lost when you die.' },
+  bombPass: { name: 'Bomb Pass', text: 'You can walk over bombs. Lost when you die.' },
+  flamePass: { name: 'Flame Pass', text: 'Flame can’t hurt you (enemies still can). Lost when you die.' },
+  star: { name: 'Star', text: `Nothing can hurt you for ${STAR_TURNS} turns.` },
+  coinBag: { name: 'Coin bag', text: `${COIN_BAG} coins.` },
+  heart: { name: 'Heart', text: 'One more life.' },
+}
+
+export const CHARGE_TEXT: Record<ChargeId, { name: string; text: string }> = {
+  kick: { name: 'Kick', text: 'Push a bomb next to you: it slides away until something blocks it, keeping its number.' },
+  throw: { name: 'Throw', text: 'Put a bomb exactly 2 tiles away in a straight line, over anything in between.' },
+  shield: { name: 'Shield', text: 'The next hit (flame or enemy) is absorbed, and anything else that hits you that turn.' },
+  freeze: { name: 'Freeze', text: 'Every enemy skips its next move.' },
+  fuse: { name: 'Fuse ±1', text: 'Add 1 to any bomb’s number (up to 9), or take 1 off (down to 1).' },
+  diagonal: { name: 'Diagonal', text: 'Step one tile diagonally, even between two blocked tiles.' },
+  teleport: { name: 'Teleport', text: `Jump to any empty tile up to ${TELEPORT_RANGE} steps away, over anything.` },
+  pierce: { name: 'Pierce', text: 'A bomb whose flame goes through crates, burning every one in reach.' },
+  power: { name: 'Power', text: 'A bomb whose flame reaches all the way to the walls (crates still stop it).' },
+  line: { name: 'Line', text: 'Put as many bombs as you’re allowed in a row, starting next to you.' },
+  decoy: { name: 'Decoy', text: `A copy of you that Seekers chase instead, for ${DECOY_TURNS} turns. Flame destroys it.` },
+}
+
+const ARROWS: Record<Dir, string> = { up: '↑', down: '↓', left: '←', right: '→' }
+const PUNISH = `Don’t blast it once it’s uncovered: flame on it lets out ${EXIT_SPAWN} more enemies.`
+
+/** The top bar's items. */
+export type HudItem = 'lives' | 'level' | 'enemies' | 'coins' | 'turn' | 'bombs' | 'fire' | 'fuse' | 'star' | keyof Powers
+
+const PASS_KIND: Record<keyof Powers, PowerUpKind> = {
+  run: 'skates', detonate: 'detonator', wallPass: 'wallPass', bombPass: 'bombPass', flamePass: 'flamePass',
+}
+
+export function hudInfo(item: HudItem, s: GameState): Info {
+  switch (item) {
+    case 'lives':
+      return {
+        icon: 'hud_heart',
+        title: `Lives: ${s.lives}`,
+        body: [
+          'Caught by flame or touched by an enemy, you lose a life and the level starts again, same layout and enemies.',
+          'With no lives left, the run is over.',
+        ],
+      }
+    case 'level':
+      return { icon: 'flag_green_a', title: `Level ${s.level} of ${LAST_LEVEL}`, body: [`Clear all ${LAST_LEVEL} levels to win the run. New enemy types join every few levels.`] }
+    case 'enemies':
+      return {
+        icon: 'slime_normal_rest',
+        title: `Enemies left: ${s.enemies.length}`,
+        body: ['The exit only opens when this reaches 0.', 'Tap an enemy to see what it does.'],
+      }
+    case 'coins':
+      return {
+        icon: 'hud_coin',
+        title: `Coins: ${s.coins}`,
+        body: ['From enemies, coin bags, and finishing a level under par. They will buy upgrades in the shop (coming soon).'],
+      }
+    case 'turn':
+      return {
+        icon: 'hud_character_0',
+        title: `Turn ${s.turn}, par ${s.par}`,
+        body: [`Turns taken on this level. Finish it in ${s.par} turns or fewer for a bonus of ${PAR_BONUS} coins.`],
+      }
+    case 'bombs':
+      return {
+        icon: 'bomb',
+        title: `Bombs: ${s.stats.capacity} at a time`,
+        body: ['How many of your bombs can be on the board at once. Bomb +1 (blue gem) adds one.'],
+      }
+    case 'fire':
+      return {
+        icon: 'fireball',
+        title: `Blast: ${s.stats.fire} ${s.stats.fire === 1 ? 'tile' : 'tiles'}`,
+        body: ['How far your blasts reach in each of the four directions. Fire +1 (red gem) adds one.'],
+      }
+    case 'fuse':
+      return {
+        icon: 'torch_on_a',
+        title: `Fuse: ${s.stats.fuse}`,
+        body: [`The number your new bombs start at: they explode after ${s.stats.fuse} more of your actions. Fuse +1 (yellow gem) adds one.`],
+      }
+    case 'star':
+      return { icon: 'star', title: `Star: ${s.star} turns left`, body: ['Nothing can hurt you until it runs out.'] }
+    default: {
+      const kind = PASS_KIND[item]
+      return { icon: POWER_UP_ICONS[kind], title: POWER_UP_TEXT[kind].name, body: [POWER_UP_TEXT[kind].text] }
+    }
+  }
+}
+
+/**
+ * What is on a tile, most important first, or null for plain empty floor (a tap there is
+ * auto-move, not info).
+ */
+export function tileInfo(s: GameState, p: Vec): Info | null {
+  const enemies = s.enemies.filter((e) => eq(e.pos, p))
+  const e = enemies[0]
+  if (e) {
+    const moves = e.plan.map((to, i) => ARROWS[dirBetween(i ? e.plan[i - 1]! : e.pos, to)]).join(' ')
+    const more = enemies.length > 1 ? ` (and ${enemies.length - 1} more on this tile)` : ''
+    return {
+      icon: ENEMY_SPRITES[e.kind].a,
+      title: ENEMY_TEXT[e.kind].name + more,
+      body: [
+        ENEMY_TEXT[e.kind].moves,
+        moves
+          ? `Its next moves: ${moves}. The arrows beside it show them, next move on top. It makes exactly those moves unless a bomb gets in its way.`
+          : 'It has nowhere to go right now, so it stays put (eyes closed).',
+        'Flame kills it, including flame still burning from last turn. Touching it costs you a life.',
+      ],
+    }
+  }
+  const bomb = bombAt(s, p)
+  if (bomb) {
+    const kind = bomb.special === 'pierce' ? 'Pierce bomb' : bomb.special === 'power' ? 'Power bomb' : 'Bomb'
+    const reach = bomb.special === 'power' ? 'to the walls' : `${bomb.fire} ${bomb.fire === 1 ? 'tile' : 'tiles'}`
+    return {
+      icon: 'bomb',
+      title: `${kind}: ${bomb.timer}`,
+      body: [
+        `Explodes at the end of your ${bomb.timer === 1 ? 'next action' : `${bomb.timer}th action from now`}.`,
+        `Its blast reaches ${reach} in each direction. Flame stops at walls${bomb.special === 'pierce' ? ' (it goes through crates)' : ' and on the first crate'}, and sets off any bomb it reaches.`,
+      ],
+    }
+  }
+  if (s.decoy && eq(s.decoy.pos, p)) {
+    return { icon: 'character_green_front', title: 'Decoy', body: [`Seekers chase it instead of you for ${s.decoy.turns} more ${s.decoy.turns === 1 ? 'turn' : 'turns'}. Flame destroys it.`] }
+  }
+  if (isBurning(s, p)) {
+    return {
+      icon: 'fireball',
+      title: 'Still burning',
+      body: ['Flame from last turn’s blast. It goes out at the end of this turn. Anything in it then dies, and you can’t step in.'],
+    }
+  }
+  const cell = cellAt(s, p)
+  if (cell === 'wall') return { icon: 'block_blue', title: 'Wall', body: ['Indestructible: nothing gets through, not even flame.'] }
+  if (cell === 'block') {
+    return {
+      icon: 'block_planks',
+      title: 'Crate',
+      body: ['Flame destroys it (and stops there). On every level one crate hides the exit and another hides a power-up.'],
+    }
+  }
+  if (s.powerUp && eq(s.powerUp.pos, p)) {
+    const t = POWER_UP_TEXT[s.powerUp.kind]
+    return { icon: POWER_UP_ICONS[s.powerUp.kind], title: t.name, body: [t.text, 'Walk onto it to take it. ' + PUNISH] }
+  }
+  if (eq(s.exit, p)) {
+    return exitOpen(s)
+      ? { icon: 'door_open', title: 'Exit: open', body: ['Step onto it to finish the level.', PUNISH] }
+      : {
+          icon: 'door_closed',
+          title: 'Exit: locked',
+          body: [`It opens when every enemy is gone (${s.enemies.length} left). Then step onto it to finish the level.`, PUNISH],
+        }
+  }
+  const drop = s.drops.find((d) => eq(d.pos, p))
+  if (drop) {
+    if (drop.kind === 'coins') return { icon: coinSprite(drop.amount), title: `${drop.amount} ${drop.amount === 1 ? 'coin' : 'coins'}`, body: ['Walk onto it to take it. A blast destroys it.'] }
+    const t = CHARGE_TEXT[drop.kind]
+    return { icon: CHARGE_ICONS[drop.kind], title: `${t.name} charge`, body: [t.text, 'Walk onto it to take one. Using it takes your turn. A blast destroys it.'] }
+  }
+  return null
+}
+
+export function infoIconUrl(info: Info): string {
+  return spriteUrl(info.icon)
+}
diff --git a/tools/screenshots.mjs b/tools/screenshots.mjs
index 60a99ce..1c51cc6 100644
--- a/tools/screenshots.mjs
+++ b/tools/screenshots.mjs
@@ -72,6 +72,18 @@ const SHOTS = [
     query: 'seed=55',
     act: press('KeyB', 'KeyS', 'KeyD', 'KeyD', 'Space', 'KeyA', 'KeyA', 'KeyS', 'KeyS', 'KeyR'),
   },
+  // Tapping an enemy, or anything in the top bar, explains it.
+  { name: 'info-enemy', act: tap(4, 5) },
+  { name: 'info-hud', act: async (page) => { await page.click('[data-info=fuse]') } },
+  // The menu (top right), and the confirmation before abandoning a run.
+  { name: 'menu', act: async (page) => { await page.click('#menu-button') } },
+  {
+    name: 'new-run',
+    act: async (page) => {
+      await page.click('#menu-button')
+      await page.click('#dialog-buttons .danger')
+    },
+  },
   // Holding action charges: their buttons appear; Throw picked shows where a bomb can land.
   { name: 'charges', query: 'charges=2', act: async (page) => { await page.click('[data-action=throw]') } },
 ]