fuzzyworld

explodorama / commit

Bigger boards with a fixed view; TNT and stat limits; many UI fixes

e6bb7c42c6d90502c0424ab3686d0fdf8a66d44b · Pavel Safronov · 2026-10-25 15:11:04 -0700 · parents de91d3715a63

Claude Code usage: this commit ≈0 out / ~152.0M cached · cost unknown (no per-model trailer) · session 4 tally 3.8K in / 1.7M out / 857.7M cached · usage by commit and session

Bigger boards with a fixed view; TNT and stat limits; many UI fixes

- Boards grow with the level (9 x 11 to 15 x 21), seen through the same
  9 x 13 tiles on every screen (bars round the view), following the
  player; drag to look around (until the next turn); edge arrows for
  enemies, bombs, the exit, the gem and the player out of view. Enemies
  by concentration (threat per open tile), par by the board's size.
- TNT (blast +2) and a limit of 5 on bombs, blast and fuse; a power-up
  without room isn't offered; cards show "Blast 2 -> 4 (max 5)".
- Shield and Freeze need a confirming tap on yourself.
- Auto-move's way drawn as a line of arrows over the tiles it lands on
  (Run and Teleport skip tiles); with Diagonal it cuts corners.
- Fixes: the shop's coins stay in view; the almanac's tabs fit and
  stay on top.
- Backlog: Flame Pass (3 fire hits) and Teleport (3 jumps) to become
  charges next. ?shop test parameter; SIM_VERSION 34.

Usage: 3.8K in / 1.7M out / 857.7M cached (session tally)
rtk:   1.9M saved, 38.8% over 4641 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
 CHANGELOG.md                         |  10 +++
 PLAN.md                              |  46 ++++++++++++-
 README.md                            |  45 +++++++++----
 assets/composed/powerup_tnt.png      | Bin 0 -> 5736 bytes
 decisions.md                         | 122 +++++++++++++++++++++++++++++++++++
 docs/screenshots/almanac-charges.png | Bin 387660 -> 403787 bytes
 docs/screenshots/almanac-enemies.png | Bin 316622 -> 316636 bytes
 docs/screenshots/almanac.png         | Bin 537608 -> 540083 bytes
 docs/screenshots/auto-move.png       | Bin 216588 -> 216816 bytes
 docs/screenshots/bomb-picked.png     | Bin 215822 -> 215802 bytes
 docs/screenshots/charges.png         | Bin 224664 -> 224648 bytes
 docs/screenshots/countdown.png       | Bin 219733 -> 219713 bytes
 docs/screenshots/death.png           | Bin 233775 -> 233705 bytes
 docs/screenshots/demo.png            | Bin 952229 -> 951309 bytes
 docs/screenshots/desktop.png         | Bin 313044 -> 270235 bytes
 docs/screenshots/dev-offer.png       | Bin 202961 -> 202642 bytes
 docs/screenshots/dev.png             | Bin 219399 -> 219408 bytes
 docs/screenshots/enemies.png         | Bin 215194 -> 243296 bytes
 docs/screenshots/gem-offer.png       | Bin 217813 -> 197669 bytes
 docs/screenshots/gem.png             | Bin 200159 -> 200049 bytes
 docs/screenshots/info-enemy.png      | Bin 144526 -> 144527 bytes
 docs/screenshots/info-hud.png        | Bin 184678 -> 182952 bytes
 docs/screenshots/info-mode.png       | Bin 214204 -> 214142 bytes
 docs/screenshots/lingering.png       | Bin 219173 -> 219228 bytes
 docs/screenshots/long-press.png      | Bin 219476 -> 219391 bytes
 docs/screenshots/menu.png            | Bin 150070 -> 150439 bytes
 docs/screenshots/move-picked.png     | Bin 216107 -> 216090 bytes
 docs/screenshots/new-run.png         | Bin 160024 -> 160213 bytes
 docs/screenshots/nuke.png            | Bin 231997 -> 231959 bytes
 docs/screenshots/shop.png            | Bin 202986 -> 199118 bytes
 docs/screenshots/start.png           | Bin 215409 -> 215464 bytes
 index.html                           |   1 +
 src/main.ts                          |  85 ++++++++++++++++++++----
 src/render/board.ts                  |  59 ++++++++++++++++-
 src/render/camera.ts                 |  80 +++++++++++++++++++----
 src/render/sprites.ts                |   2 +
 src/sim/actions.ts                   |  11 ++--
 src/sim/demo.ts                      |   2 -
 src/sim/levelgen.ts                  |  77 +++++++++++++++++-----
 src/sim/path.ts                      |  34 +++++++++-
 src/sim/record.ts                    |   2 +-
 src/sim/shop.ts                      |   2 +-
 src/sim/turn.ts                      |  35 ++++++++--
 src/sim/types.ts                     |   2 +-
 src/style.css                        |  47 +++++++++++++-
 src/ui/almanac.ts                    |   2 +-
 src/ui/info.ts                       |  48 +++++++++++---
 src/ui/offer.ts                      |   8 +--
 src/ui/shop.ts                       |   4 +-
 test/charges.test.ts                 |  18 +++++-
 test/demo.test.ts                    |   2 -
 test/enemies.test.ts                 |  16 +++--
 test/levelgen.test.ts                |   7 +-
 test/options.test.ts                 |  19 +++---
 test/path.test.ts                    |  45 ++++++++++++-
 test/powerups.test.ts                |  28 +++++++-
 tools/compose-icons.sh               |   2 +
 tools/screenshots.mjs                |  20 ++----
 58 files changed, 748 insertions(+), 133 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 80bf8bd..b06f273 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,9 @@ saved before the change is dropped and the next visit starts a new run.
 
 ### Added
 
+- **TNT**, a power-up worth two Fire +1s, and **a limit of 5 on bombs, blast and fuse**; a power-up with no room left isn't offered, and cards show the limit. Version 34.
+- **Bigger boards for later levels:** from 9 × 11 up to 15 × 21, seen through the same 9 × 13 tiles on every screen, scrolling with you, with arrows at the edge pointing to enemies, bombs, the exit and the gem out of sight. Enemy counts and par grow with the board. Version 32: runs saved before don't resume.
+- **Bigger boards for later levels:** from 9 × 11 up to 15 × 21, seen through the same 9 × 13 tiles on every screen, scrolling with you, with arrows at the edge pointing to enemies, bombs, the exit and the gem out of sight. Enemy counts and par grow with the board. Version 32: runs saved before don't resume.
 - **A demo scene for the charge swap:** carrying a Pierce, the player picks up a Nuke, and the finger takes it in the choice that opens.
 - **One charge at a time, used when you choose:** a charge button beside Bomb (or V) puts down a Pierce, Power or Nuke bomb, fires an ICBM, freezes the enemies or places a Decoy; Bomb always places a plain bomb; a Shield is raised with it too and stays up until something hits you, and a Decoy is put up to 3 tiles away in a straight line and lasts until the level ends or something reaches it. The charge button is always there (an empty slot with none). Picking up, choosing or buying a different charge asks: keep yours and sell the new one, or take it and sell yours. Ends the Nuke trap, where a Nuke forced on every bomb made a level impossible. Version 29: runs saved before don't resume.
 - **The balance lab** (for development): bots play thousands of games on the real rules to measure what each power-up does (`npm run balance`, reports in `docs/balance/`).
@@ -90,6 +93,9 @@ saved before the change is dropped and the next visit starts a new run.
 
 ### Changed
 
+- **The gem's and the shop's cards show what they change:** "Blast 2 → 3", "Bombs 1 → 2", "Lives 3 → 4"…
+- **Auto-move's way** shows as a line of arrows through its blue tiles, one per move, and only the tiles you will land on are lit: with Run, every other one.
+- **Shield and Freeze ask for a confirmation:** the charge button picks them; tap yourself (or press it again) to use them. Version 33.
 - **The demo's title is on top,** above the TV, and the **menu is compact:** Resume and New side by side, then Almanac, Scores, Demo and Bug in one row.
 - **Proximity alert:** a bomb now goes off only when an enemy is right next to it, not anywhere in its reach, so a bomb dropped between you and an enemy no longer blows up next to you.
 - The demo plays its scenes in one order, numbered, with ◀ ▶ (and ← →) to go through them and ↺ back to the first; it carries on where it was, after a reload too.
@@ -137,6 +143,10 @@ saved before the change is dropped and the next visit starts a new run.
 
 ### Fixed
 
+- With Diagonal, auto-move cuts corners with diagonal steps instead of walking round them.
+- The almanac's tabs fit a phone's width, and entries no longer show through them when scrolled.
+- A swipe on the board, meant to look around, made a move (a teleport, say). Now dragging the board moves the view instead, until your next turn.
+- The shop's coins stay in view while its items scroll.
 - A run resumed with a gem's choice or the shop open lost its dialog when the opening demo ended; it opens again now.
 - Dialogs never run off the bottom of a phone's screen: only their middle scrolls, and their buttons always show.
 - Long dialogs (the almanac, the rainbow gem's offer) scroll with a finger again.
diff --git a/PLAN.md b/PLAN.md
index 7b6a15c..f58ba1c 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -188,7 +188,10 @@ levels (`state.shieldUp`, carried from level to level). The **Decoy** goes up to
 tiles away in a straight line, every tile on the way free — a wall, crate, bomb, enemy or flame
 stops it, as a slide or a kick is stopped — and **lasts until the level ends**, an enemy reaches
 it, or flame burns it. (For one round both lasted 3 turns; the user found that far too short.)
-`SIM_VERSION` 31. `boosts` and `shield` are gone from the state; `?charges=n` became
+`SIM_VERSION` 31.
+*Also 2026-10-25 (user request):* **Shield and Freeze need a confirmation**: the charge button
+picks them, lighting the player's own tile (their only target); a tap on yourself, or the button
+pressed again, uses them. `SIM_VERSION` 33. `boosts` and `shield` are gone from the state; `?charges=n` became
 `?charge=<kind>`. This also ends the Nuke trap the balance lab found (§19.1). The rest of this
 section is the earlier design, kept as history.
 
@@ -487,8 +490,9 @@ the former "Small" board (density 25%, enemies 2 + 1 per 4 levels up to 5, par 2
 with no scrolling, and crates always crumbling once the last enemy is gone (§13b). The NES-size
 notes below (31 × 13, 50 levels, the side-scrolling camera) are kept as the history of the
 design; the board code for them was removed.
-*Proposed 2026-10-24 (user):* bigger boards for later levels, scrolling — see §19.2. It would
-undo the one-size choice above.
+*Built 2026-10-25 (user request, §19.2):* boards grow with the level (9 × 11 to 15 × 21), seen
+through a fixed 9 × 13 view that follows the player, with arrows at its edge; enemies by
+concentration. This undoes the one-size choice above.
 
 - **Grid 31 × 13** like the NES: hard walls around the border, indestructible pillars at every
   even (x, y), soft blocks placed by seeded RNG at ~30–40% density, start corner (3 tiles in an L)
@@ -1024,6 +1028,22 @@ tools/                 compose-icons.sh (composed sprites and the app icons), sc
 
 ### Backlog
 
+- *Built 2026-10-25 (user request):* **TNT**, a power-up worth two Fire +1s (blast +2; weight 6,
+  price 7), and **limits on the stats**: bombs, blast and fuse each at most 5 (`STAT_MAX`). A stat
+  power-up without room for all of its step isn't offered (`heldAlready`); its card shows the limit
+  ("Blast 2 → 4 (max 5)"). Teleport's reach gets its own limit when it becomes a charge (next task).
+- **Next, after bigger boards (§19.2) are playtested and done (user, 2026-10-25): Flame Pass and
+  Teleport become charges.** The lab measured both turning a weak player into a near-certain winner
+  (§19.1). Decided by the user:
+  - **Flame Pass → a charge counted in flame hits, not turns:** it saves the player from **3 fire
+    hits** (a blast, a chain, lingering flame), one used up each time flame would have hurt them,
+    then runs out. **Raised with the charge button first**, as the Shield is (user, 2026-10-25),
+    with the same confirming tap on yourself.
+  - **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
+    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
   what is left of it, as a drop the retry can pick up again (option 1 below). To build.
   Was: **Lose the carried charge on a death? (user idea, 2026-10-25.)** Today the charge
@@ -1184,6 +1204,26 @@ taller, since a phone is tall:
   too, since they can kill.
 - **Size by "concentration"** (the user's idea, worked out below), instead of a size table.
 
+**Built 2026-10-25** (`boardSize`, `concentration`, `ENEMY_THREAT` in sim/levelgen.ts;
+render/camera.ts; `drawIndicators` in render/board.ts):
+
+- **Sizes:** 9 × 11 (levels 1–3), 11 × 13 (4–6), 11 × 15 (7–9), 13 × 17 (10–12), 13 × 19 (13–15),
+  15 × 21 (16 on). Odd, so pillars meet the walls evenly.
+- **The view:** 9 × 13 tiles on every screen; `#view-mask` bars cover the rest (and taps there do
+  nothing). The demo's small rooms are still seen whole.
+- **Following:** the view centres on the player, eased, kept inside the board; a board narrower or
+  shorter than the view is centred that way.
+- **Indicators:** for each enemy, bomb, and the uncovered exit and gem out of view, a small picture
+  just inside the view's edge on the way to it, with an arrow on.
+- **Enemies:** added (random types of those met so far) until their threat reaches the level's
+  concentration × open tiles, at least 2. Concentration rises linearly from 1/26 on level 1 (so its
+  51 open tiles get 2 Blobs, as before) to 1/11 on level 20, then by 0.002 a level toward 1/8.
+  Threat weights (first guesses, to fit with the lab): Blob 1, Seeker 1.4, Drifter 1.1, Sprinter
+  1.6, Ghost 1.7, Wisp 1.3, Hunter 2. Level 20 gets about 12 enemies on 193 open tiles.
+- **Par** = 10 + 0.3 × open tiles + 8 × enemies (41 on level 1, as before).
+- **Not yet:** fitting the curve and the weights with the balance lab; the largest size by how
+  long a level takes. `SIM_VERSION` 32.
+
 **Concentration: one number that sets how crowded a level is.** Today each level's enemy count is
 a table (2, then one more every 4 levels). Instead:
 
diff --git a/README.md b/README.md
index 27993a8..1df59bd 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@ seven charges (one carried at a time, used when you choose), a hidden gem on eve
 mid-run work. Curses and the Reaper enemy are planned —
 see [PLAN.md](PLAN.md).
 
-Version: **31**, shown at the bottom of the menu with the commit the game was built from (a link
+Version: **34**, shown at the bottom of the menu with the commit the game was built from (a link
 to it on GitLab, plus "plus local changes" in a build with uncommitted changes) and a link to the
 [source code](https://gitlab.com/PavelSafronov/explodorama). The version is the rules version
 (`SIM_VERSION` in [src/sim/record.ts](src/sim/record.ts)): it goes up by one whenever a rule
@@ -98,8 +98,11 @@ for tiles further away.
 
 **Auto-move:** tap any tile further away that you can walk to — empty floor, the exit, the
 gem or a drop. **You take the first step at once**, just as a tap next to you would (if that
-step is safe). Every tile of the rest of the way, including where you're going, is highlighted
-blue, and the **Move** button becomes **Go** (same walk icon, with a green border): each press of
+step is safe). Every tile you will **land on** along the way, including where you're going, is
+highlighted blue — with Run or Teleport that's only the tiles you land on, not the ones you skip; with
+Diagonal the way cuts corners with diagonal steps —
+with **a line of arrows** through them from you to there, one arrow on each pointing the way that
+move goes (the first in red when it isn't safe), and the **Move** button becomes **Go** (same walk icon, with a green border): each press of
 Go — or each tap on the destination 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 sets a
 new way. Auto-move ends when you arrive, when you **move somewhere off the blue way** (you
@@ -178,8 +181,8 @@ piled up). The top bar shows it too.
 | **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. |
 | **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 | Press the charge button to raise it: a ring round you that **stays up, level after level, until something hits you**. It absorbs that hit (flame or enemy), and anything else that hits you that turn. Then it's gone. Until 2026-10-25 it worked by itself while carried. |
-| **Freeze** | a padlock | Press the charge button: every enemy freezes in place for **3 turns**, that one included: they turn blue inside a block of ice and thaw a step each turn, then carry on with the moves their (faded, icy) arrows show. |
+| **Shield** | the Move figure in a ring | Press the charge button, then tap yourself — or press it again — to raise it (a confirmation, so it can't go up by a stray tap): a ring round you that **stays up, level after level, until something hits you**. It absorbs that hit (flame or enemy), and anything else that hits you that turn. Then it's gone. Until 2026-10-25 it worked by itself while carried. |
+| **Freeze** | a padlock | Press the charge button, then tap yourself — or press it again: every enemy freezes in place for **3 turns**, that one included: they turn blue inside a block of ice and thaw a step each turn, then carry on with the moves their (faded, icy) arrows show. |
 | **Decoy** | three pawns | Press the charge button, then tap where it goes — **up to 3 tiles away in a straight line**, with nothing in the way (a wall, crate, bomb, enemy or flame stops it, as it stops a slide): a pale copy of you. Enemies that chase you (Seekers, Ghosts, Hunters, Sprinters) go after it instead, **until the level ends** — or until an enemy reaches it or flame burns it, which destroys it. It doesn't block anyone. |
 | **ICBM** | bomb with a target badge | 3 launches: the charge button fires a bomb as an RPG's (or **I**, then a direction) and it goes off the moment it lands, at any distance. Your bomb limit doesn't count. |
 
@@ -251,7 +254,10 @@ Ghosts and Wisps can be inside a crate (drawn see-through there); flame that bur
 kills them, but flame stopped by a crate in front of them doesn't reach them. Bombs stop every
 enemy. Each level mixes the types met so far.
 
-Level 1 has 2 enemies, with one more every 4 levels (at most 5). Enemies never start within
+How many enemies a level has follows its **crowding**: each type counts for how dangerous it is
+(a Blob 1, up to a Hunter 2), and they are added until that reaches 1 per 26 open tiles on level 1,
+rising to 1 per 11 by level 20 — so a bigger board brings more enemies, and later levels are more
+crowded. Level 1 has 2; level 20 about a dozen, on a much bigger board. Enemies never start within
 4 tiles of you.
 
 ### Drops and coins
@@ -272,7 +278,7 @@ open, so pick them up soon. Coins are spent in **the shop** (below).
   toughest type). Those drop nothing. They can't be hurt by the flame that let them out (it
   lingers a turn), but a new explosion kills them as usual.
 - **Clearing a level pays 2 coins.** **Par:** the top bar shows `turns taken / par`. Finish within par for **5 more**.
-  Par is 25 turns plus 8 per enemy on the level.
+  Par is 10 turns, plus 0.3 a tile of the board (floor and crates), plus 8 per enemy: 41 on level 1.
 
 ### Power-ups
 
@@ -284,7 +290,7 @@ those enemies can't be hurt by the flame that let them out).
 **Taking the gem offers 5 different power-ups, and you choose one.** The dialog shows each with
 what it does; it can't be closed without choosing, and choosing takes no turn. Choosing takes two
 taps: **tap a card** to select it (it turns green, and a clip above the cards shows it in action),
-then **Choose** to take it. The first card is selected to begin with. Heart and Coin bag, which
+then **Choose** to take it. A card for something with a number shows what it changes from where you are: **Blast 2 → 3** for Fire +1, Bombs, Fuse, Lives, Coins, a Star's turns, or a charge's uses when you carry the same kind (the shop's cards too). The first card is selected to begin with. Heart and Coin bag, which
 have nothing to show on the board, show their picture there instead. The 5 are drawn
 at random, but not all are equally likely: common ones (Bomb +1, Fire +1) come up far more often
 than rare ones (Teleport, Heart). An ability or pass you already hold is never offered. The
@@ -294,9 +300,14 @@ before moving on.
 
 | Power-up | Looks like | Effect |
 |---|---|---|
-| **Bomb +1** | bomb with a + badge | One more bomb on the board at a time. |
-| **Fire +1** | bomb with an expanding-arrows badge | Blast reaches one tile further. |
-| **Fuse +1** | bomb with a timer badge | Your bombs start one higher (more time to get away). |
+| **Bomb +1** | bomb with a + badge | One more bomb on the board at a time, **up to 5**. |
+| **Fire +1** | bomb with an expanding-arrows badge | Blast reaches one tile further, **up to 5**. |
+| **TNT** | fireball with an expanding-arrows badge | Blast reaches **two** tiles further: two Fire +1s in one, up to 5. |
+| **Fuse +1** | bomb with a timer badge | Your bombs start one higher (more time to get away), **up to 5**. |
+
+Each stat has a **limit of 5**, so blasts can't span the board. A power-up with no room left for all
+it gives isn't offered (no Fire +1 at a blast of 5, no TNT at 4), and its card says how far you'd
+go: **Blast 2 → 4 (max 5)**.
 | **Run** | the Move figure with a fast-forward badge | **Move** can run: 2 tiles in a straight line in one turn (or **R**, then a direction). Both tiles must be empty; an enemy on the tile in between is run into. Anything on that tile is picked up. |
 | **Kick** | bomb with a right-arrow badge | Walk into a bomb next to you and it slides away until the next tile is blocked (a wall, crate, bomb or enemy), keeping its number. |
 | **Throw** | bomb with an up-right-arrow badge | **Bomb** can also go exactly 2 tiles away in a straight line, over a crate, bomb or enemy in between — but not over a pillar or the outer wall (or **T**, then a direction). |
@@ -397,7 +408,15 @@ The demo and the clips in the dialogs are silent. On an iPhone, the silent switc
 
 ### The board
 
-9 × 11 tiles, fitting the screen whole (no scrolling): a stone outer wall and metal pillars
+**Boards grow with the level:** 9 × 11 on levels 1–3, 11 × 13 from level 4, 11 × 15 from 7,
+13 × 17 from 10, 13 × 19 from 13 and 15 × 21 from 16. **You always see 9 × 13 tiles**, on a phone
+and on a desktop alike (bars cover the rest of a wide or tall screen), so no screen sees more of the
+board than another. A bigger board **scrolls with you**, smoothly, and stops at its edges. **Drag
+the board** (with nothing picked) to look around it; letting go does nothing, and your next turn
+brings the view back to you. What is out of sight shows at the edge of the view as a small picture
+with an arrow pointing to it: every enemy, every bomb, the exit and the gem once uncovered, and you,
+when the view is dragged away. (Every board was 9 × 11, seen whole,
+until 2026-10-25.) A stone outer wall and metal pillars
 (indestructible; the pillars change colour from level to level) and wooden crates as soft
 blocks (destructible). **Once the last enemy is gone, every crate crumbles away** over 1–3 turns
 (shaking first), revealing the exit and the gem — not flame, so nothing is punished. (The
@@ -434,7 +453,7 @@ runs of `powers` take about 10 minutes on 10 cores.
 
 URL parameters: `?seed=123` plays a fixed board; `?level=4` starts at a later level (level 19
 has all seven enemy types); `?charge=nuke` (or any charge) starts carrying that charge, for
-trying it out; `?dev` and `?immortal` set the modes; `?demo` shows the demo, which test URLs otherwise skip (with `?seed`, the same demo every time). `?fps` shows a frame-rate meter in the bottom left corner: frames a second, the slowest frame, the game's own time per frame, and the canvas size. For tracking down slowness on a device: `?gl=0` draws with Canvas 2D instead of WebGL (no demo TV effect, no clips), `?ratio=1` draws at one pixel per CSS pixel, `?aa` turns WebGL's antialiasing on (it is off: it held Android Chrome to 12 fps). These four keep your saved run. A URL with any of these parameters plays a test board: it doesn't load or overwrite your
+trying it out; `?level=3&shop` opens the shop after that level straight away, with 9 coins; `?dev` and `?immortal` set the modes; `?demo` shows the demo, which test URLs otherwise skip (with `?seed`, the same demo every time). `?fps` shows a frame-rate meter in the bottom left corner: frames a second, the slowest frame, the game's own time per frame, and the canvas size. For tracking down slowness on a device: `?gl=0` draws with Canvas 2D instead of WebGL (no demo TV effect, no clips), `?ratio=1` draws at one pixel per CSS pixel, `?aa` turns WebGL's antialiasing on (it is off: it held Android Chrome to 12 fps). These four keep your saved run. A URL with any of these parameters plays a test board: it doesn't load or overwrite your
 saved run.
 
 Deployed to GitLab Pages by `.gitlab-ci.yml` on every push to the default branch.
diff --git a/assets/composed/powerup_tnt.png b/assets/composed/powerup_tnt.png
new file mode 100644
index 0000000..9f9484e
Binary files /dev/null and b/assets/composed/powerup_tnt.png differ
diff --git a/decisions.md b/decisions.md
index 73ae391..5df70a6 100644
--- a/decisions.md
+++ b/decisions.md
@@ -1946,3 +1946,125 @@ dialog opens over the scene (not dimming the caption, as "What is that?" does) a
 **Take Nuke**. A new gesture, `choice`, plays it (`swapCharge` / `sellCharge`); in a dialog's clip,
 which has no dialog, the choice just happens. Only this scene keeps turns that ask. No separate
 charge-button scene: each charge's scene already shows the finger pressing it.
+
+### 2026-10-25 — Bigger boards, a fixed view, enemies by concentration (built)
+
+PLAN.md §19.2, as agreed. Choices made while building:
+
+- **Sizes:** 9 × 11 for levels 1–3, then a step every 3 levels to 15 × 21 from level 16 (growing in
+  both directions, more in height, as phones are tall). The largest is a first guess; the lab is
+  to measure how long levels take.
+- **The view:** 9 × 13 tiles everywhere, the bars round it the page's dark ink (`#view-mask`, a box
+  whose shadow covers the rest). A tap outside the view does nothing, so nothing unseen is tapped.
+  On a phone the 9 columns set the tile size, so the bars are above and below; on a desktop, beside.
+- **Following** is eased (a fifth of the way each frame) and stops at the board's edges.
+- **Indicators:** enemies and bombs with a red arrow, the exit and gem with a white one; a picture on
+  a pale disc just inside the edge, toward the thing.
+- **Enemies by concentration:** threat per open tile, from 1/26 (2 Blobs on level 1, as before) to
+  1/11 on level 20; the weights by type are guesses for the lab to fit. Par grows with the board:
+  10 + 0.3 a tile + 8 an enemy (41 on level 1, as before).
+- **Screenshots:** the tool asks the game where a tile is (`window.tilePoint`), since the board
+  scrolls; and `?shop` (with `?level=3`) opens the shop directly, since the key sequence that played
+  through level 3 stopped fitting the new enemy counts.
+- `SIM_VERSION` 32.
+
+### 2026-10-25 — Bigger boards, a fixed view, enemies by concentration (built)
+
+PLAN.md §19.2, as agreed. Choices made while building:
+
+- **Sizes:** 9 × 11 for levels 1–3, then a step every 3 levels to 15 × 21 from level 16 (growing in
+  both directions, more in height, as phones are tall). The largest is a first guess; the lab is
+  to measure how long levels take.
+- **The view:** 9 × 13 tiles everywhere, the bars round it the page's dark ink (`#view-mask`, a box
+  whose shadow covers the rest). A tap outside the view does nothing, so nothing unseen is tapped.
+  On a phone the 9 columns set the tile size, so the bars are above and below; on a desktop, beside.
+- **Following** is eased (a fifth of the way each frame) and stops at the board's edges.
+- **Indicators:** enemies and bombs with a red arrow, the exit and gem with a white one; a picture on
+  a pale disc just inside the edge, toward the thing.
+- **Enemies by concentration:** threat per open tile, from 1/26 (2 Blobs on level 1, as before) to
+  1/11 on level 20; the weights by type are guesses for the lab to fit. Par grows with the board:
+  10 + 0.3 a tile + 8 an enemy (41 on level 1, as before).
+- **Screenshots:** the tool asks the game where a tile is (`window.tilePoint`), since the board
+  scrolls; and `?shop` (with `?level=3`) opens the shop directly, since the key sequence that played
+  through level 3 stopped fitting the new enemy counts.
+- `SIM_VERSION` 32.
+
+### 2026-10-25 — Flame Pass and Teleport to become charges (scheduled)
+
+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
+now, to be playtested more. Scheduled as the next task, after the bigger boards (being playtested
+by the user). Backlogged in PLAN.md §18.
+
+### 2026-10-25 — Shield and Freeze: a confirming tap
+
+User request: the Shield went up the moment the charge button was pressed; add a confirmation.
+Shield and Freeze are now aimed like the other actions, at one tile — the player's own: the charge
+button picks them and lights that tile, and a tap on yourself uses them. Pressing the button again
+also confirms (for these two only; for other actions a second press puts the action away), so the
+keyboard needs only V, V. It is the same two steps as every other action, with nothing new to
+learn. `SIM_VERSION` 33 (the actions now carry a tile).
+
+### 2026-10-25 — Auto-move's way as a line of arrows
+
+User request: the blue tiles of auto-move's way weren't enough; arrows on them, forming a line.
+Now a line runs from the player through the middle of each tile of the way, with an arrow on each
+tile pointing the way that step goes (`drawPath` in render/board.ts). A darker blue than the tiles,
+so it reads over them; the first step's line and arrow turn red when that step isn't safe, as its
+tile already did. Drawn under the pieces, so the player and enemies stay on top.
+- **Follow-up (user report, same day):** with Run, every tile of the way lit up though only every
+  other one is landed on. The way drawn is now where auto-move will land, move by move
+  (`autoRoute` in sim/path.ts: `autoMoveAction` played out on the player's position, the first move
+  only where it is safe now): a Run's landings, a Teleport's, or each step. The walk itself
+  (`playerPath`) still decides auto-move's moves and when it ends. When the first move can't be
+  made now, the walk is drawn as before, its first tile red.
+
+### 2026-10-25 — The shop's coins stay in view
+
+User report: scrolling the shop's items moved "You have N coins" out of view. The coins line now
+sticks at the top of the shop's body, and the clip sticks just under it, so both stay while the
+items scroll.
+
+### 2026-10-25 — Dragging the board moves the view
+
+User report: a swipe on the board, meant to look around, teleported the player and a turn went by
+(the press landed on one tile and the release on another, which with nothing picked is a tap on
+that tile). Now, with nothing picked, a press that moves more than 12 CSS pixels is a drag: it
+moves the view (kept inside the board) and letting go does nothing, nor does a long press arm a
+bomb. The next turn brings the view back to the player, eased. With an action picked, sliding
+still changes the tile chosen, as before (the commit preview). When the view is dragged off the
+player, an edge arrow points back to them. Every player can drag the same way, so the fixed 9 × 13
+view stays fair.
+
+### 2026-10-25 — Cards show what they change, in numbers
+
+User request: when a gem or the shop offers, say, Fire +1 to a player who already has one, show its
+impact ("fire 2 → 3"). Each card of the gem's offer and the shop now has a line under the name:
+**Blast 2 → 3**, **Bombs 1 → 2**, **Fuse 3 → 4**, **Lives 3 → 4**, **Coins 5 → 15**, a Star's turns
+when one is running, and a charge's uses when the same kind is carried. The current value, then
+the new one: one step, since an offer or a shop never holds two of the same thing. Abilities and
+passes have no number, so no line (`changeOf` in ui/info.ts).
+
+### 2026-10-25 — TNT, and a limit on every stat
+
+User request: "make tnt a power-up that gives us 2 increases", and a maximum, "to limit
+ridiculousnesses like board-spanning bombs". **TNT** raises the blast by 2 (its icon: the fireball
+with Fire +1's badge; weight 6 against Fire +1's 20, price 7 against 4). **Bombs, blast and fuse
+each stop at 5** (first guesses, to tune). A stat power-up that would be cut short isn't offered by
+a gem or the shop, so nothing is half-wasted; cards and the top bar's explanations show the limit.
+On cards the user asked whether to show the history ("1 → 2 → 3"): the current value and the new
+one, with the limit, say what matters for choosing ("Blast 2 → 4 (max 5)"). `SIM_VERSION` 34.
+
+### 2026-10-25 — The almanac's tabs fit, and stay on top
+
+User report: on a phone the three tabs ran off both ends of the row, and scrolled entries showed
+through them. The tabs now share the row equally at a smaller size (10 px Kenney Future), and the
+sticky tab row sits above the entries scrolling under it.
+
+### 2026-10-25 — Auto-move uses Diagonal
+
+User report: with Diagonal, auto-move walked round corners it could cut. The walk (`playerPath`)
+now takes diagonal steps too when the player has Diagonal — squeezes included, as the action
+allows — and a diagonal first step is taken as the Diagonal action. Among equally short ways the
+search keeps its order (straight steps first), so the number of moves is what is saved.
diff --git a/docs/screenshots/almanac-charges.png b/docs/screenshots/almanac-charges.png
index baca1ac..45351ae 100644
Binary files a/docs/screenshots/almanac-charges.png and b/docs/screenshots/almanac-charges.png differ
diff --git a/docs/screenshots/almanac-enemies.png b/docs/screenshots/almanac-enemies.png
index b512ed3..14bb191 100644
Binary files a/docs/screenshots/almanac-enemies.png and b/docs/screenshots/almanac-enemies.png differ
diff --git a/docs/screenshots/almanac.png b/docs/screenshots/almanac.png
index 78588f9..5e9bcb4 100644
Binary files a/docs/screenshots/almanac.png and b/docs/screenshots/almanac.png differ
diff --git a/docs/screenshots/auto-move.png b/docs/screenshots/auto-move.png
index 79b2b8b..6f34fbf 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 9cef059..018e461 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 9b0e846..d9a3320 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 72b5273..3907931 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 7a16827..a489246 100644
Binary files a/docs/screenshots/death.png and b/docs/screenshots/death.png differ
diff --git a/docs/screenshots/demo.png b/docs/screenshots/demo.png
index a8593ca..1070046 100644
Binary files a/docs/screenshots/demo.png and b/docs/screenshots/demo.png differ
diff --git a/docs/screenshots/desktop.png b/docs/screenshots/desktop.png
index b2c2651..d3d7f06 100644
Binary files a/docs/screenshots/desktop.png and b/docs/screenshots/desktop.png differ
diff --git a/docs/screenshots/dev-offer.png b/docs/screenshots/dev-offer.png
index cf1f785..304de90 100644
Binary files a/docs/screenshots/dev-offer.png and b/docs/screenshots/dev-offer.png differ
diff --git a/docs/screenshots/dev.png b/docs/screenshots/dev.png
index b9f07b5..71da21e 100644
Binary files a/docs/screenshots/dev.png and b/docs/screenshots/dev.png differ
diff --git a/docs/screenshots/enemies.png b/docs/screenshots/enemies.png
index 42aa232..d8829da 100644
Binary files a/docs/screenshots/enemies.png and b/docs/screenshots/enemies.png differ
diff --git a/docs/screenshots/gem-offer.png b/docs/screenshots/gem-offer.png
index fbc147b..f56e52a 100644
Binary files a/docs/screenshots/gem-offer.png and b/docs/screenshots/gem-offer.png differ
diff --git a/docs/screenshots/gem.png b/docs/screenshots/gem.png
index 6ba8dfd..3b7288c 100644
Binary files a/docs/screenshots/gem.png and b/docs/screenshots/gem.png differ
diff --git a/docs/screenshots/info-enemy.png b/docs/screenshots/info-enemy.png
index f14ee55..3cfdb68 100644
Binary files a/docs/screenshots/info-enemy.png and b/docs/screenshots/info-enemy.png differ
diff --git a/docs/screenshots/info-hud.png b/docs/screenshots/info-hud.png
index ec0e177..21422a7 100644
Binary files a/docs/screenshots/info-hud.png and b/docs/screenshots/info-hud.png differ
diff --git a/docs/screenshots/info-mode.png b/docs/screenshots/info-mode.png
index 5fa541b..32da675 100644
Binary files a/docs/screenshots/info-mode.png and b/docs/screenshots/info-mode.png differ
diff --git a/docs/screenshots/lingering.png b/docs/screenshots/lingering.png
index 4db39f7..0073e71 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 219852e..d4169ca 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
index 48ab68d..1b84269 100644
Binary files a/docs/screenshots/menu.png and b/docs/screenshots/menu.png differ
diff --git a/docs/screenshots/move-picked.png b/docs/screenshots/move-picked.png
index 133f250..e6c2ffc 100644
Binary files a/docs/screenshots/move-picked.png and b/docs/screenshots/move-picked.png differ
diff --git a/docs/screenshots/new-run.png b/docs/screenshots/new-run.png
index a6f6331..467e030 100644
Binary files a/docs/screenshots/new-run.png and b/docs/screenshots/new-run.png differ
diff --git a/docs/screenshots/nuke.png b/docs/screenshots/nuke.png
index cedc9ae..78b13c7 100644
Binary files a/docs/screenshots/nuke.png and b/docs/screenshots/nuke.png differ
diff --git a/docs/screenshots/shop.png b/docs/screenshots/shop.png
index 900a84a..e69affb 100644
Binary files a/docs/screenshots/shop.png and b/docs/screenshots/shop.png differ
diff --git a/docs/screenshots/start.png b/docs/screenshots/start.png
index e742b63..4b4846d 100644
Binary files a/docs/screenshots/start.png and b/docs/screenshots/start.png differ
diff --git a/index.html b/index.html
index 163a43b..1ce7264 100644
--- a/index.html
+++ b/index.html
@@ -55,6 +55,7 @@
     <div id="dialog-buttons"></div>
   </div>
 </dialog>
+<div id="view-mask" aria-hidden="true"></div>
 <h1 id="demo-title" hidden>EXPLODORAMA!</h1>
 <div id="demo" hidden aria-live="polite">
   <p id="demo-caption"></p>
diff --git a/src/main.ts b/src/main.ts
index d31335c..ac4c96f 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -1,4 +1,5 @@
 import {
+  cameraScale,
   engineInit,
   mainCanvas,
   screenToWorld,
@@ -18,14 +19,14 @@ import {
   vec2,
   worldToScreen,
 } from 'littlejsengine'
-import { BACKGROUND, drawBoard, playerPos, toTile, toWorld } from './render/board'
+import { BACKGROUND, drawBoard, drawIndicators, playerPos, toTile, toWorld } from './render/board'
 import { animateTurn as animateBoard, clearAnimation, newAnimation, pruneAnimation } from './render/animate'
-import { updateCamera } from './render/camera'
+import { endPan, panView, resetPan, 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 { canPick, membersOf, resolvePick, tapMeaning } from './sim/picks'
 import { enemyAt } from './sim/enemies'
-import { autoMoveAction, playerPath, safeStep as isSafeStep } from './sim/path'
+import { autoMoveAction, autoRoute, playerPath, safeStep as isSafeStep } from './sim/path'
 import { predictDanger, type Danger } from './sim/danger'
 import { newRecord, replay, SIM_VERSION, type RunRecord } from './sim/record'
 import { cellAt, eq, inBounds, isBurning, newRun, PAR_BONUS } from './sim/state'
@@ -39,6 +40,7 @@ import { actionInfo, chargeInfo, hudInfo, tileInfo, type Info } from './ui/info'
 import { openChargeOffer } from './ui/chargeOffer'
 import { openOffer } from './ui/offer'
 import { showShop, updateShop } from './ui/shop'
+import { shopAfter } from './sim/shop'
 import { startClip, drawClips } from './ui/clip'
 import { makeScene } from './ui/scenes'
 import { openScores, recordScore } from './ui/scores'
@@ -110,6 +112,13 @@ function startRun(seed: number, options: RunOptions): void {
   resetView()
   record = newRecord(seed, startLevel, startCharge, options)
   state = newRun(seed, startLevel, startCharge, options)
+  // `?shop` (with a `?level` after which the shop opens, as 3): straight into that shop with 9
+  // coins, a testing aid (tools/screenshots.mjs) — a test URL, so the run isn't saved.
+  if (params.has('shop') && shopAfter(state.level)) {
+    state = step({ ...state, status: 'cleared', coins: 9 }, { id: 'continue' }).state
+    afterChange()
+    return openShopDialog()
+  }
   save()
   afterChange()
 }
@@ -147,7 +156,7 @@ const SAVE_KEY = 'explodorama.run'
 function urlOptions(): RunOptions {
   return { dev: params.has('dev'), immortal: params.has('immortal') }
 }
-const testing = ['seed', 'level', 'charge', 'dev', 'immortal'].some((p) => params.has(p))
+const testing = ['seed', 'level', 'charge', 'dev', 'immortal', 'shop'].some((p) => params.has(p))
 
 function save(): void {
   if (testing) return
@@ -270,6 +279,8 @@ function rememberTurn(prev: GameState, action: Action, events: GameEvent[]): voi
 function animateTurn(result: ReturnType<typeof step>, action: Action): void {
   const newLevel = animateBoard(anim, state, result, action, time)
   state = result.state
+  // A turn brings a view dragged away back to the player.
+  resetPan()
   if (newLevel) snapCamera = true
   if (newLevel || result.events.some((e) => e.type === 'levelCleared')) destination = null
 }
@@ -279,7 +290,7 @@ const PICKUP_DIALOG_MS = 450
 
 /** The gem's choice, if one is waiting. Choosing takes no turn. */
 function showOffer(): void {
-  if (state.offer) openOffer(state.offer, (kind) => act({ id: 'choose', kind }))
+  if (state.offer) openOffer(state, state.offer, (kind) => act({ id: 'choose', kind }))
 }
 
 /** The choice a charge picked up while carrying another asks for (one charge at a time). */
@@ -569,14 +580,15 @@ function pick(id: ActionId, pressed = selected === id): void {
   }
   if (!canPick(state, id)) return
   if (id === 'move' && destination && !selected && stepTowardDestination()) return
-  // Wait, and a Freeze from the charge button, happen at once. Everything else is picked first,
-  // then aimed at a tile.
+  // Wait happens at once. Everything else is picked first, then aimed at a tile.
   const now = membersOf(id).find((a) => isAvailable(state, a) && !ACTIONS[a].needsTarget)
-  if (now) act({ id: now })
-  else {
-    selected = pressed ? null : id
-    afterChange()
-  }
+  if (now) return act({ id: now })
+  // A Freeze or Shield picked, whose only tile is the player's own: pressing the button again
+  // confirms it (as tapping yourself does), rather than putting it away.
+  const self = pressed && selected ? resolvePick(state, selected, state.player) : null
+  if (self && (self === 'freeze' || self === 'shield')) return act({ id: self, target: state.player })
+  selected = pressed ? null : id
+  afterChange()
 }
 
 /**
@@ -661,6 +673,11 @@ window.addEventListener('keydown', (e) => {
 })
 
 /** Start timing a long press on `p`, or with null, cancel any press in progress. */
+/** A press on the board that may become a drag: where it is now, and whether it moves the view. */
+let drag: { x: number; y: number; panning: boolean } | null = null
+/** How far (CSS pixels) a press may wander and still be a tap; further, it is a drag. */
+const DRAG_PIXELS = 12
+
 function startPress(p: Vec | null): void {
   clearTimeout(pressTimer)
   armed = null
@@ -674,7 +691,11 @@ function startPress(p: Vec | null): void {
 
 function tileUnder(e: PointerEvent): Vec | null {
   const r = mainCanvas.getBoundingClientRect()
-  const p = toTile(state, screenToWorld(vec2(e.clientX - r.left, e.clientY - r.top)))
+  const w = screenToWorld(vec2(e.clientX - r.left, e.clientY - r.top))
+  // Only what is in view: a tile under the bars round it can't be tapped.
+  const v = viewRect()
+  if (!demo && (w.x < v.left || w.x > v.right || w.y < v.bottom || w.y > v.top)) return null
+  const p = toTile(state, w)
   return inBounds(state, p) ? p : null
 }
 
@@ -760,6 +781,8 @@ function saveDemoIndex(i: number): void {
   }
 }
 const demoPanel = document.getElementById('demo')!
+/** The bars round the view, so every screen sees the same 9 × 13 tiles (render/camera.ts). */
+const viewMask = document.getElementById('view-mask')!
 /** The demo's title, on top above the TV (user request, 2026-10-25: title, TV, caption). */
 const demoTitle = document.getElementById('demo-title')!
 const demoCount = document.getElementById('demo-count')!
@@ -1082,6 +1105,12 @@ function tilePoint(p: Vec) {
   return worldToScreen(toWorld(state, p)).add(vec2(r.left, r.top))
 }
 
+// For tools/screenshots.mjs, which taps tiles: the board scrolls, so only the game knows where one is.
+;(window as unknown as { tilePoint: (x: number, y: number) => Vec }).tilePoint = (x, y) => {
+  const p = tilePoint({ x, y })
+  return { x: p.x, y: p.y }
+}
+
 function centreOf(el: Element) {
   const r = el.getBoundingClientRect()
   return vec2(r.left + r.width / 2, r.top + r.height / 2)
@@ -1280,15 +1309,35 @@ await engineInit(
     // next to the player, with nothing picked, arms a bomb there instead (placed on release).
     mainCanvas.addEventListener('pointerdown', (e) => {
       hover = tileUnder(e)
+      drag = { x: e.clientX, y: e.clientY, panning: false }
       startPress(hover)
     })
     mainCanvas.addEventListener('pointermove', (e) => {
       if (e.pointerType !== 'mouse' && !e.buttons) return
+      // With nothing picked, a drag moves the view (user request, 2026-10-25: a swipe to look
+      // round the board teleported instead); letting go then does nothing. With an action picked,
+      // sliding still changes the tile chosen, as before.
+      if (e.buttons && drag && !selected && !demo) {
+        const moved = Math.hypot(e.clientX - drag.x, e.clientY - drag.y)
+        if (drag.panning || moved > DRAG_PIXELS) {
+          if (!drag.panning) startPress(null)
+          drag.panning = true
+          hover = null
+          panView(-(e.clientX - drag.x) / cameraScale, (e.clientY - drag.y) / cameraScale)
+          drag.x = e.clientX
+          drag.y = e.clientY
+          return
+        }
+      }
       const p = tileUnder(e)
       if (e.buttons && !(p && pressTile && eq(p, pressTile))) startPress(null)
       hover = p
     })
     mainCanvas.addEventListener('pointerup', (e) => {
+      const panned = drag?.panning
+      drag = null
+      endPan()
+      if (panned) return startPress(null)
       const p = tileUnder(e)
       if (e.pointerType !== 'mouse') hover = null
       const bombAt = armed
@@ -1298,6 +1347,8 @@ await engineInit(
     })
     mainCanvas.addEventListener('pointerleave', () => {
       hover = null
+      drag = null
+      endPan()
       startPress(null)
     })
     // A long press shouldn't open the browser's context menu.
@@ -1308,7 +1359,10 @@ await engineInit(
     // In the demo the board and the action bar under it fit above the caption panel, with room
     // between them and it.
     const panel = demoPanel.getBoundingClientRect().height
-    updateCamera(state, demo ? { top: controls.insets().top, bottom: panel + DEMO_PANEL_GAP + DEMO_GAP + controls.insets().bottom } : controls.insets(), snapCamera)
+    const view = updateCamera(state, demo ? { top: controls.insets().top, bottom: panel + DEMO_PANEL_GAP + DEMO_GAP + controls.insets().bottom } : controls.insets(), snapCamera, !!demo)
+    // The bars round the view (style.css #view-mask), in play only: the demo's rooms are seen whole.
+    viewMask.hidden = !!demo
+    viewMask.style.cssText = `left:${view.x}px;top:${view.y}px;width:${view.width}px;height:${view.height}px`
     if (demo) placeDemoBar()
     // The caption panel's height, for what sits above it (style.css: the CRT screen, the dialog),
     // and the TV's sides: at most 4:3 wide, black bars beside it on a wide screen (user request,
@@ -1330,8 +1384,11 @@ await engineInit(
     drawClips()
     drawBoard({
       state, danger, selected, hover, destination, path, armed, ...anim,
+      route: destination ? autoRoute(state, destination, safeStep) : null,
       pathBlocked: !!path?.[0] && !safeStep(path[0]),
     })
+    // What is out of sight on a board bigger than the view: arrows at its edge.
+    if (!demo) drawIndicators(state, viewRect())
     countFrame()
   })),
   () => {},
diff --git a/src/render/board.ts b/src/render/board.ts
index ca8ba12..1d308f0 100644
--- a/src/render/board.ts
+++ b/src/render/board.ts
@@ -6,6 +6,7 @@ import { blastTiles } from '../sim/explosions'
 import { dirBetween } from '../sim/enemies'
 import { cellAt, cloneState, eq, exitOpen, idx } from '../sim/state'
 import type { ActionId, Bomb, Enemy, GameState, Vec } from '../sim/types'
+import type { ViewRect } from './camera'
 import {
   appearScale, dropSprite, frostCrack, frostLevel, GONE_SECONDS, isSlide, key, moveProgress, moveSeconds, progress, pulseScale, TELEPORT_SECONDS, WALK_SECONDS,
   type Effects, type Gone, type PlayerMove, type PlayerPose,
@@ -52,6 +53,8 @@ export interface View {
   /** Auto-move: the tapped far tile, and the walk there (next step first). */
   destination: Vec | null
   path: Vec[] | null
+  /** Where auto-move will land, move by move (a Run or Teleport skips tiles): what is drawn. */
+  route?: Vec[] | null
   /** The next auto-move step is unsafe, so Go and the flag won't take it this turn. */
   pathBlocked: boolean
   /** A tile a long press has armed for a bomb. */
@@ -83,6 +86,8 @@ const C = {
   dangerNow: rgb(0.9, 0.15, 0.1, 0.45),
   dangerNext: rgb(1, 0.35, 0.2, 0.18),
   target: rgb(0.25, 0.62, 0.87, 0.4),
+  /** Auto-move's way, drawn as a line of arrows over its blue tiles. */
+  pathLine: rgb(0.13, 0.42, 0.72, 0.9),
   targetHover: rgb(0.25, 0.62, 0.87, 0.7),
   preview: rgb(1, 0.55, 0.1, 0.4),
   flame: rgb(1, 0.6, 0.1),
@@ -180,8 +185,13 @@ export function drawBoard(view: View): void {
   for (const t of targets) drawRect(toWorld(s, t), vec2(0.94), hovered && eq(t, hovered) ? C.targetHover : C.target)
   // Auto-move's way: every tile of it highlighted blue, the destination too, with no flag or dots
   // (user request, 2026-10-23). The next step is red when it isn't safe to take.
+  // Since 2026-10-25 it shows where the player will land, move by move: with Run or Teleport, only
+  // the tiles landed on (user report: every tile lit up though every other one is jumped).
   if (view.destination && view.path) {
-    for (const [i, t] of view.path.entries()) drawRect(toWorld(s, t), vec2(0.94), i === 0 && view.pathBlocked ? C.blocked : C.target)
+    const way = view.route?.length ? view.route : view.path
+    const blocked = !view.route?.length && view.pathBlocked
+    for (const [i, t] of way.entries()) drawRect(toWorld(s, t), vec2(0.94), i === 0 && blocked ? C.blocked : C.target)
+    drawPath(s, way, blocked)
   }
   // The commit preview: the picked action played on a copy, then whatever it changed drawn faded.
   const after = view.armed
@@ -514,3 +524,50 @@ function drawBomb(pos: Vector2, timer: number, body: BoardSprite, color: Color,
 function tileOf(s: GameState, i: number): Vec {
   return { x: i % s.width, y: Math.floor(i / s.width) }
 }
+
+/**
+ * Arrows at the edge of the view (PLAN.md §19.2) for what is out of sight on a board bigger than
+ * the view: the player (after a drag), each enemy, the uncovered exit and gem, and every bomb, each a small picture just
+ * inside the edge on the way to it, with an arrow pointing on. The game's board only, not a clip.
+ */
+export function drawIndicators(s: GameState, view: ViewRect): void {
+  const margin = 0.42
+  const marks: { at: Vec; sprite: BoardSprite; color: Color }[] = []
+  // The player too, when a drag has moved the view off them (2026-10-25).
+  marks.push({ at: s.player, sprite: 'character_green_front', color: C.target.scale(1, 2.2) })
+  for (const e of s.enemies) marks.push({ at: e.pos, sprite: ENEMY_SPRITES[e.kind].a, color: C.intent })
+  for (const b of s.bombs) marks.push({ at: b.pos, sprite: 'bomb', color: C.intent })
+  if (cellAt(s, s.exit) === 'floor') marks.push({ at: s.exit, sprite: exitOpen(s) ? 'door_open_top' : 'door_closed_top', color: C.white })
+  for (const g of s.gems) if (cellAt(s, g.pos) === 'floor') marks.push({ at: g.pos, sprite: GEM_SPRITES[g.color], color: C.white })
+  for (const m of marks) {
+    const w = toWorld(s, m.at)
+    if (w.x > view.left && w.x < view.right && w.y > view.bottom && w.y < view.top) continue
+    const at = vec2(
+      Math.min(view.right - margin, Math.max(view.left + margin, w.x)),
+      Math.min(view.top - margin, Math.max(view.bottom + margin, w.y)),
+    )
+    const d = w.subtract(at)
+    const dir = d.length() > 0 ? d.normalize() : vec2(0, 1)
+    drawCircle(at, 0.62, C.white.scale(1, 0.75))
+    drawTile(at, vec2(0.5), sprite(m.sprite))
+    drawArrow(at.add(dir.scale(0.36)), dir, 0.22, m.color)
+  }
+}
+
+/**
+ * Auto-move's way as a line (user request, 2026-10-25: the blue tiles alone weren't enough): from
+ * the player through the middle of each tile landed on, with an arrow on each pointing the way that
+ * move goes, so the line reads as the route, move by move (a Run's arrow two tiles on). The first is
+ * red when it isn't safe.
+ */
+function drawPath(s: GameState, path: readonly Vec[], blocked: boolean): void {
+  let from = toWorld(s, s.player)
+  for (const [i, t] of path.entries()) {
+    const to = toWorld(s, t)
+    const color = i === 0 && blocked ? C.intent.scale(1, 0.85) : C.pathLine
+    drawLine(from, to, 0.08, color)
+    const d = to.subtract(from).normalize()
+    drawArrow(to.subtract(d.scale(0.08)), d, 0.34, color)
+    from = to
+  }
+}
diff --git a/src/render/camera.ts b/src/render/camera.ts
index 1f7bd43..9091389 100644
--- a/src/render/camera.ts
+++ b/src/render/camera.ts
@@ -7,28 +7,84 @@ export interface Insets {
   bottom: number
 }
 
-let x: number | undefined
+/**
+ * The view (PLAN.md §19.2; user's choice, 2026-10-24): the same number of tiles on every screen,
+ * 9 across and 13 down, so a phone and a desktop see as much of the board, and no screen gives
+ * an advantage. A board bigger than that scrolls: the view follows the player, eased, and stays
+ * inside the board. Outside the view, bars cover the rest (style.css `#view-mask`).
+ */
+export const VIEW_COLUMNS = 9
+export const VIEW_ROWS = 13
+
+/** The part of the board in view, in world units (x right, y up): what the edge arrows point out of. */
+export interface ViewRect {
+  left: number
+  bottom: number
+  right: number
+  top: number
+}
+
+let center: { x: number; y: number } | undefined
+let rect: ViewRect = { left: 0, bottom: 0, right: 0, top: 0 }
+/**
+ * How far a drag has moved the view off the player, in world units (user request, 2026-10-25: a
+ * swipe to look around teleported instead). Until the next turn, which brings it back.
+ */
+let pan = { x: 0, y: 0 }
+let panning = false
+
+/** 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 {
+  pan = { x: pan.x + dx, y: pan.y + dy }
+  panning = true
+}
+
+/** The drag is over: the view stays where it was dragged, eased again from here on. */
+export function endPan(): void {
+  panning = false
+}
+
+/** Back to following the player (a turn was played). */
+export function resetPan(): void {
+  pan = { x: 0, y: 0 }
+}
+
+/** The part of the board in view this frame. */
+export function viewRect(): ViewRect {
+  return rect
+}
 
 /**
- * The whole board fits between the HUD and the action bar: rows and columns (one board size
- * since 2026-10-23, so there is no scrolling). If a screen were ever too narrow for it, the
- * camera would follow the player sideways, eased, stopping at the level's edges.
- * `snap` jumps straight there, for the first frame of a level.
+ * The view between the HUD and the action bar. `whole`: fit the whole board instead (the demo's
+ * small rooms, which are made to be seen at once). `snap` jumps straight there, for the first
+ * frame of a level. Returns the view's place on the screen, in CSS pixels, for the bars around it.
  */
-export function updateCamera(s: GameState, insets: Insets, snap = false): void {
+export function updateCamera(s: GameState, insets: Insets, snap = false, whole = false): { x: number; y: number; width: number; height: number } {
   const width = mainCanvasSize.x
   const height = mainCanvasSize.y
   const available = Math.max(1, height - insets.top - insets.bottom)
-  const scale = Math.min(available / s.height, width / s.width)
+  const columns = whole ? s.width : VIEW_COLUMNS
+  const rows = whole ? s.height : VIEW_ROWS
+  const scale = Math.min(available / rows, width / columns)
   setCameraScale(scale)
 
-  const halfView = width / 2 / scale
-  const target = s.width <= 2 * halfView ? s.width / 2 : clamp(s.player.x + 0.5, halfView, s.width - halfView)
-  x = x === undefined || snap ? target : x + (target - x) * 0.2
+  // Centre on the player, kept inside the board; a board narrower (or shorter) than the view is
+  // centred that way instead.
+  // A drag moves it off the player, still inside the board (the pan kept to what the board allows).
+  const follow = (player: number, size: number, view: number) => (size <= view ? size / 2 : clamp(player, view / 2, size - view / 2))
+  const base = { x: s.player.x + 0.5, y: s.height - s.player.y - 0.5 }
+  const target = {
+    x: follow(base.x + (whole ? 0 : pan.x), s.width, columns),
+    y: follow(base.y + (whole ? 0 : pan.y), s.height, rows),
+  }
+  if (!whole) pan = { x: target.x - base.x, y: target.y - base.y }
+  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 }
 
-  // The camera centres the canvas; move it so the board centres in the gap between the bars.
+  // The camera centres the canvas; move it so the view centres in the gap between the bars.
   const offset = insets.top + available / 2 - height / 2
-  setCameraPos(vec2(x, s.height / 2 + offset / scale))
+  setCameraPos(vec2(center.x, center.y + offset / scale))
+  return { x: (width - columns * scale) / 2, y: insets.top + (available - rows * scale) / 2, width: columns * scale, height: rows * scale }
 }
 
 function clamp(v: number, lo: number, hi: number): number {
diff --git a/src/render/sprites.ts b/src/render/sprites.ts
index 46b8520..0fe2cbb 100644
--- a/src/render/sprites.ts
+++ b/src/render/sprites.ts
@@ -91,6 +91,7 @@ const BOARD = [
   'frog_jump',
   'powerup_bomb_plus',
   'powerup_fire_plus',
+  'powerup_tnt',
   'powerup_fuse_plus',
   'powerup_run',
   'powerup_kick',
@@ -225,6 +226,7 @@ export const GEM_SPRITES: Record<GemColor, BoardSprite> = {
 export const POWER_UP_ICONS: Record<PowerUpKind, BoardSprite> = {
   bombPlus: 'powerup_bomb_plus',
   firePlus: 'powerup_fire_plus',
+  tnt: 'powerup_tnt',
   fusePlus: 'powerup_fuse_plus',
   run: 'powerup_run',
   wallPass: 'powerup_wall_pass',
diff --git a/src/sim/actions.ts b/src/sim/actions.ts
index 49e6ebd..fa690a5 100644
--- a/src/sim/actions.ts
+++ b/src/sim/actions.ts
@@ -296,12 +296,15 @@ export const ACTIONS: Record<ActionId, ActionDef> = {
       placeBomb(s, target!, events, kind)
     },
   },
+  // Freeze and Shield are aimed at the player's own tile: the charge button picks them, and a tap on
+  // yourself (or the button again) uses them — a confirmation, so one can't go off by a stray tap
+  // (user request, 2026-10-25: the Shield went up at once).
   freeze: {
     id: 'freeze',
     label: 'Freeze',
-    needsTarget: false,
+    needsTarget: true,
     available: (s) => holds(s, 'freeze'),
-    validTargets: () => [],
+    validTargets: (s) => [s.player],
     apply(s, _target, events) {
       spendCharge(s)
       s.frozen = Math.max(s.frozen, FREEZE_TURNS)
@@ -336,9 +339,9 @@ export const ACTIONS: Record<ActionId, ActionDef> = {
   shield: {
     id: 'shield',
     label: 'Shield',
-    needsTarget: false,
+    needsTarget: true,
     available: (s) => holds(s, 'shield') && !s.shieldUp,
-    validTargets: () => [],
+    validTargets: (s) => [s.player],
     apply(s, _target, events) {
       spendCharge(s)
       s.shieldUp = true
diff --git a/src/sim/demo.ts b/src/sim/demo.ts
index 3018129..2717b12 100644
--- a/src/sim/demo.ts
+++ b/src/sim/demo.ts
@@ -770,8 +770,6 @@ function reasonable(kind: SceneKind, t: Turn): boolean {
  */
 function gestureFor(def: SceneDef, s: GameState, action: Action): Gesture | null {
   if (action.id === 'wait') return { kind: 'button', button: 'wait' }
-  // A Freeze or a Shield: the charge button alone (it acts at once).
-  if (action.id === 'freeze' || action.id === 'shield') return { kind: 'button', button: 'charged' }
   if (!('target' in action) || !action.target) return null
   const tile = action.target
   if (action.id === 'bomb' && !def.bombButton) return { kind: 'hold', tile }
diff --git a/src/sim/levelgen.ts b/src/sim/levelgen.ts
index 13b59a9..2e39876 100644
--- a/src/sim/levelgen.ts
+++ b/src/sim/levelgen.ts
@@ -2,12 +2,29 @@ import { stream } from './rng'
 import { DIR_LIST, type Cell, type Dir, type EnemyKind, type GemColor, type PowerUp, type Vec } from './types'
 
 /**
- * The board: 9 × 11 with a wall all round and a pillar at every even (x, y), fitting a phone
- * screen whole. The only size since 2026-10-23 (user request); until then this was the "small"
- * board, beside the NES's 31 × 13 with 50 levels.
+ * The board: a wall all round and a pillar at every even (x, y). It grows with the level (PLAN.md
+ * §19.2; user request, 2026-10-24: later levels felt too small), past the fixed view (render/
+ * camera.ts), which then follows the player. Odd sizes, so the pillars meet the walls evenly.
+ * (One size, 9 × 11, from 2026-10-23 until then.)
  */
-export const LEVEL_WIDTH = 9
-export const LEVEL_HEIGHT = 11
+const BOARD_SIZES: readonly { from: number; width: number; height: number }[] = [
+  { from: 1, width: 9, height: 11 },
+  { from: 4, width: 11, height: 13 },
+  { from: 7, width: 11, height: 15 },
+  { from: 10, width: 13, height: 17 },
+  { from: 13, width: 13, height: 19 },
+  { from: 16, width: 15, height: 21 },
+]
+
+/** The board's size on `level`: the last size whose first level it has reached. */
+export function boardSize(level: number): { width: number; height: number } {
+  const size = BOARD_SIZES.filter((b) => b.from <= level).at(-1)!
+  return { width: size.width, height: size.height }
+}
+
+/** The first level's board, the smallest (and every board until 2026-10-25). */
+export const LEVEL_WIDTH = BOARD_SIZES[0]!.width
+export const LEVEL_HEIGHT = BOARD_SIZES[0]!.height
 export const START: Vec = { x: 1, y: 1 }
 /** Share of the open tiles that get a soft block. */
 export const BLOCK_DENSITY = 0.25
@@ -27,14 +44,37 @@ export function enemyKinds(level: number): EnemyKind[] {
   return ENEMY_ORDER.slice(0, 1 + Math.floor((level - 1) / NEW_ENEMY_EVERY))
 }
 
-/** 2 enemies on level 1, one more every 4 levels, at most 5. To tune by playtesting. */
-export function enemyCount(level: number): number {
-  return Math.min(5, 2 + Math.floor((level - 1) / 4))
+/**
+ * How dangerous each type is, for the level's crowding (PLAN.md §19.2, "concentration"): first
+ * guesses, a Blob 1, to be fitted with the balance lab (how often each type kills the bots).
+ */
+export const ENEMY_THREAT: Record<EnemyKind, number> = {
+  blob: 1, seeker: 1.4, drifter: 1.1, sprinter: 1.6, ghost: 1.7, wisp: 1.3, hunter: 2,
+}
+
+/**
+ * The level's crowding: threat (the enemies' ENEMY_THREAT added up) per open tile (floor and
+ * crates, wherever an enemy could be), rising from 1 per 26 tiles on level 1 (2 Blobs on its 51
+ * tiles, as before) to 1 per 11 on level 20, and on past it, more slowly, toward 1 per 8. To fit
+ * with the balance lab.
+ */
+export function concentration(level: number): number {
+  const first = 1 / 26
+  const last = 1 / 11
+  if (level <= LAST_LEVEL) return first + ((last - first) * (level - 1)) / (LAST_LEVEL - 1)
+  return Math.min(1 / 8, last + 0.002 * (level - LAST_LEVEL))
 }
 
-/** The turn budget for the par bonus. */
-export function parFor(enemies: number): number {
-  return 25 + 8 * enemies
+/** At least this many enemies on any level. */
+const MIN_ENEMIES = 2
+
+/**
+ * The turn budget for the par bonus: 10, plus 0.3 a tile for getting about (open tiles: floor and
+ * crates), plus 8 an enemy. On level 1's 9 × 11 board (51 open tiles, 2 enemies) it is 41, as it
+ * was (25 + 8 an enemy) while every board was that size.
+ */
+export function parFor(openTiles: number, enemies: number): number {
+  return 10 + Math.round(0.3 * openTiles) + 8 * enemies
 }
 
 /** A gem's colour is only how it looks. */
@@ -60,7 +100,8 @@ export interface Layout {
 }
 
 export function generateLevel(seed: number, level: number): Layout {
-  const [width, height, density] = [LEVEL_WIDTH, LEVEL_HEIGHT, BLOCK_DENSITY]
+  const { width, height } = boardSize(level)
+  const density = BLOCK_DENSITY
   const rng = stream(seed, 'levelgen', level)
   const cells: Cell[] = []
   for (let y = 0; y < height; y++) {
@@ -92,16 +133,22 @@ export function generateLevel(seed: number, level: number): Layout {
   const exit = at(blocks.splice(rng.int(blocks.length), 1)[0]!)
 
   const open = cells.flatMap((c, i) => (c === 'floor' && far(i) ? [i] : []))
+  const openTiles = cells.filter((c) => c !== 'wall').length
   const kinds = enemyKinds(level)
   const enemies: EnemyStart[] = []
-  for (let n = enemyCount(level); n > 0 && open.length; n--) {
+  // Enemies until their threat reaches the level's concentration times its open tiles.
+  const threat = concentration(level) * openTiles
+  let total = 0
+  while ((total < threat || enemies.length < MIN_ENEMIES) && open.length) {
     const i = open.splice(rng.int(open.length), 1)[0]!
-    enemies.push({ kind: kinds[rng.int(kinds.length)]!, pos: at(i), dir: DIR_LIST[rng.int(4)]! })
+    const kind = kinds[rng.int(kinds.length)]!
+    enemies.push({ kind, pos: at(i), dir: DIR_LIST[rng.int(4)]! })
+    total += ENEMY_THREAT[kind]
   }
   // Any other block, near the start or not, so an early gem is possible.
   const others = cells.flatMap((c, i) => (c === 'block' && !(i % width === exit.x && Math.floor(i / width) === exit.y) ? [i] : []))
   const powerUp = { pos: at(others[rng.int(others.length)]!), color: GEM_COLORS[rng.int(GEM_COLORS.length)]! }
-  return { width, height, cells, start: { ...START }, exit, powerUp, enemies, par: parFor(enemies.length) }
+  return { width, height, cells, start: { ...START }, exit, powerUp, enemies, par: parFor(openTiles, enemies.length) }
 }
 
 function key(p: Vec): string {
diff --git a/src/sim/path.ts b/src/sim/path.ts
index 559e12e..fa38908 100644
--- a/src/sim/path.ts
+++ b/src/sim/path.ts
@@ -4,6 +4,9 @@ import { enemyAt, moveTiles } from './enemies'
 import { add, eq, exitOpen, flameSafe, idx, playerCanPass } from './state'
 import { DIRS, DIR_LIST, type Action, type GameState, type Vec } from './types'
 
+/** The four diagonal steps, for a walk with Diagonal. */
+const DIAGONALS: readonly Vec[] = [{ x: -1, y: -1 }, { x: 1, y: -1 }, { x: -1, y: 1 }, { x: 1, y: 1 }]
+
 /**
  * The shortest walk for the player to `to`, for auto-move, over tiles the player can pass (free
  * floor, plus crates or bombs with Wall or Bomb Pass). The result leaves out the player's own
@@ -12,6 +15,8 @@ import { DIRS, DIR_LIST, type Action, type GameState, type Vec } from './types'
  * the UI refuses a step onto an enemy instead.
  * An open exit is only walked onto as the destination: passing over it would end the level on
  * the way somewhere else (user report, 2026-10-23).
+ * With Diagonal, a diagonal step counts as one too, squeezes included, as the action allows (user
+ * report, 2026-10-25: auto-move went round two sides of a corner it could cut).
  */
 export function playerPath(s: GameState, to: Vec): Vec[] | null {
   if (eq(s.player, to) || !playerCanPass(s, to)) return null
@@ -19,10 +24,11 @@ export function playerPath(s: GameState, to: Vec): Vec[] | null {
   const prev = new Map<number, Vec>([[key(s.player), s.player]])
   const avoidExit = exitOpen(s) && !eq(s.exit, to)
   const queue = [s.player]
+  const steps = s.powers.diagonal ? [...DIR_LIST.map((d) => DIRS[d]), ...DIAGONALS] : DIR_LIST.map((d) => DIRS[d])
   for (let i = 0; i < queue.length; i++) {
     const p = queue[i]!
-    for (const d of DIR_LIST) {
-      const q = add(p, DIRS[d])
+    for (const d of steps) {
+      const q = add(p, d)
       if (prev.has(key(q)) || !playerCanPass(s, q) || (avoidExit && eq(q, s.exit))) continue
       prev.set(key(q), p)
       if (eq(q, to)) {
@@ -48,7 +54,10 @@ export function playerPath(s: GameState, to: Vec): Vec[] | null {
 export function autoMoveAction(s: GameState, to: Vec, safe: (p: Vec) => boolean): Action | null {
   const path = playerPath(s, to)
   const walkLeft = (from: Vec) => (eq(from, to) ? 0 : (playerPath({ ...s, player: from }, to)?.length ?? Infinity))
-  const step: Action | null = path?.[0] && safe(path[0]) && isValidTarget(s, 'move', path[0]) ? { id: 'move', target: path[0] } : null
+  // The first step along it: a plain step, or a diagonal one (with Diagonal).
+  const first = path?.[0]
+  const stepId = first && Math.abs(first.x - s.player.x) + Math.abs(first.y - s.player.y) === 2 ? 'diagonal' : 'move'
+  const step: Action | null = first && safe(first) && isValidTarget(s, stepId, first) ? { id: stepId, target: first } : null
   if (path?.length === 1) return step
   const options: { action: Action; left: number }[] = []
   if (isAvailable(s, 'teleport')) {
@@ -79,3 +88,22 @@ export function safeStep(s: GameState, danger: Danger, p: Vec): boolean {
   const intended = s.frozen === 0 && s.enemies.some((e) => e.plan[0] && moveTiles(e.pos, e.plan[0]).some((t) => eq(t, p)))
   return !enemyAt(s, p) && !intended && (flameSafe(s) || !danger.now.has(idx(s, p)))
 }
+
+/**
+ * Where auto-move will land, move by move, from here to `to` (user report, 2026-10-25: with Run,
+ * every tile of the way lit up though only every other one is landed on): `autoMoveAction` played
+ * out on the player's place alone, the first move only where `safe` allows (later turns' dangers
+ * aren't known yet). A Run or Teleport skips tiles; a step lands on each. Empty if the first move
+ * can't be made now.
+ */
+export function autoRoute(s: GameState, to: Vec, safe: (p: Vec) => boolean): Vec[] {
+  const route: Vec[] = []
+  let at = s
+  for (let i = 0; i < s.width * s.height && !eq(at.player, to); i++) {
+    const action = autoMoveAction(at, to, i === 0 ? safe : () => true)
+    if (!action || !('target' in action) || !action.target) break
+    route.push(action.target)
+    at = { ...at, player: action.target }
+  }
+  return route
+}
diff --git a/src/sim/record.ts b/src/sim/record.ts
index 86c6a1c..caebaad 100644
--- a/src/sim/record.ts
+++ b/src/sim/record.ts
@@ -6,7 +6,7 @@ import { DEFAULT_OPTIONS, type Action, type ChargeId, type GameEvent, type GameS
  * Bumped whenever a rule change would make an old record replay differently. Records from
  * another version can't be trusted to replay (PLAN.md §11).
  */
-export const SIM_VERSION = 31
+export const SIM_VERSION = 34
 
 /** Everything needed to reproduce a run exactly: mid-run saves, replays, certificates. */
 export interface RunRecord {
diff --git a/src/sim/shop.ts b/src/sim/shop.ts
index cf6ce72..2f8ee5e 100644
--- a/src/sim/shop.ts
+++ b/src/sim/shop.ts
@@ -20,7 +20,7 @@ export const SHOP_SIZE = 4
  * time (PLAN.md §8), so the first shop buys two cheap things, or three or four with par bonuses.
  */
 export const PRICES: Record<OfferKind, number> = {
-  bombPlus: 4, firePlus: 4, fusePlus: 5, run: 6, kick: 6, throw: 6, diagonal: 6, slide: 8, rpg: 8, detonator: 8,
+  bombPlus: 4, firePlus: 4, tnt: 7, fusePlus: 5, run: 6, kick: 6, throw: 6, diagonal: 6, slide: 8, rpg: 8, detonator: 8,
   wallPass: 6, bombPass: 6, flamePass: 9, star: 6, heart: 12, coinBag: 0, teleport: 10, proximity: 10,
   pierce: 4, power: 4, nuke: 6, shield: 5, freeze: 4, decoy: 3, icbm: 6,
 }
diff --git a/src/sim/turn.ts b/src/sim/turn.ts
index 8d7297f..5febce2 100644
--- a/src/sim/turn.ts
+++ b/src/sim/turn.ts
@@ -6,7 +6,7 @@ import { detonate, proximityAlert, tickBombs } from './explosions'
 import { add, bombAt, carryOf, cellAt, cloneState, createLevel, eq, exitOpen, idx, CLEAR_BONUS, PAR_BONUS } from './state'
 import { stream } from './rng'
 import { buy, openShop, reroll, shopAfter } from './shop'
-import { CHARGE_IDS, DIRS, DIR_LIST, isCharge, type Action, type Death, type GameEvent, type GameState, type OfferKind, type Powers, type PowerUpKind, type Vec } from './types'
+import { CHARGE_IDS, DIRS, DIR_LIST, isCharge, type Action, type Death, type GameEvent, type GameState, type OfferKind, type Powers, type PowerUpKind, type Stats, type Vec } from './types'
 
 export interface StepResult {
   state: GameState
@@ -318,7 +318,7 @@ export const OFFER_SIZE = 5
  * or 2 before gems (the user chose no guarantee). To tune by playtesting.
  */
 export const POWER_UP_WEIGHTS: [PowerUpKind, number][] = [
-  ['bombPlus', 20], ['firePlus', 20], ['fusePlus', 6], ['run', 10], ['kick', 8], ['throw', 8], ['teleport', 3], ['diagonal', 8],
+  ['bombPlus', 20], ['firePlus', 20], ['tnt', 6], ['fusePlus', 6], ['run', 10], ['kick', 8], ['throw', 8], ['teleport', 3], ['diagonal', 8],
   ['slide', 6], ['rpg', 6], ['detonator', 9], ['proximity', 5], ['wallPass', 8], ['bombPass', 8], ['flamePass', 5], ['star', 8],
   ['heart', 4], ['coinBag', 12],
 ]
@@ -335,10 +335,30 @@ const HELD_AS: Partial<Record<PowerUpKind, keyof Powers>> = {
  * offers the same. Developer mode's extra gem offers every one that would do anything, then
  * every charge, nothing drawn (user request, 2026-10-24).
  */
-/** An ability or pass already held: offering it would change nothing. */
+/**
+ * The most each stat can be raised to (user request, 2026-10-25: a limit stops board-spanning
+ * blasts and the like). To tune by playtesting.
+ */
+export const STAT_MAX: Stats = { capacity: 5, fire: 5, fuse: 5 }
+
+/** The stat each stat power-up raises, and by how much (TNT: Fire +2, user request, 2026-10-25). */
+export const STAT_STEP: Partial<Record<PowerUpKind, { stat: keyof Stats; by: number }>> = {
+  bombPlus: { stat: 'capacity', by: 1 }, firePlus: { stat: 'fire', by: 1 }, tnt: { stat: 'fire', by: 2 }, fusePlus: { stat: 'fuse', by: 1 },
+}
+
+/** A stat raised by `by`, never past its STAT_MAX. */
+function raise(s: GameState, stat: keyof Stats, by: number): void {
+  s.stats[stat] = Math.min(STAT_MAX[stat], s.stats[stat] + by)
+}
+
+/**
+ * Whether offering `kind` would be (partly) wasted: an ability or pass already held, or a stat
+ * power-up with no room for all of its step under the limit (TNT at a blast of 4 would give only 1).
+ */
 export function heldAlready(s: GameState, kind: PowerUpKind): boolean {
   const held = HELD_AS[kind]
-  return !!held && s.powers[held]
+  const step = STAT_STEP[kind]
+  return (!!held && s.powers[held]) || (!!step && s.stats[step.stat] + step.by > STAT_MAX[step.stat])
 }
 
 export function drawOffer(s: GameState, extra = false): OfferKind[] {
@@ -356,9 +376,10 @@ export function drawOffer(s: GameState, extra = false): OfferKind[] {
 
 export function applyPowerUp(s: GameState, kind: PowerUpKind): void {
   switch (kind) {
-    case 'bombPlus': s.stats.capacity++; break
-    case 'firePlus': s.stats.fire++; break
-    case 'fusePlus': s.stats.fuse++; break
+    case 'bombPlus': raise(s, 'capacity', 1); break
+    case 'firePlus': raise(s, 'fire', 1); break
+    case 'tnt': raise(s, 'fire', 2); break
+    case 'fusePlus': raise(s, 'fuse', 1); break
     case 'run': s.powers.run = true; break
     case 'detonator': s.powers.detonate = true; break
     case 'wallPass': s.powers.wallPass = true; break
diff --git a/src/sim/types.ts b/src/sim/types.ts
index 2747ee5..c57be27 100644
--- a/src/sim/types.ts
+++ b/src/sim/types.ts
@@ -78,7 +78,7 @@ export const DEFAULT_OPTIONS: RunOptions = { dev: false, immortal: false }
 
 /** The power-ups (PLAN.md §5, after the NES eight). Taking a level's gem offers a choice of them. */
 export type PowerUpKind =
-  | 'bombPlus' | 'firePlus' | 'fusePlus' | 'run' | 'wallPass' | 'bombPass' | 'flamePass' | 'detonator'
+  | 'bombPlus' | 'firePlus' | 'tnt' | 'fusePlus' | 'run' | 'wallPass' | 'bombPass' | 'flamePass' | 'detonator'
   | 'star' | 'coinBag' | 'heart' | 'kick' | 'throw' | 'teleport' | 'diagonal'
   | 'rpg' | 'proximity' | 'slide'
 
diff --git a/src/style.css b/src/style.css
index 73ee73a..15d64c8 100644
--- a/src/style.css
+++ b/src/style.css
@@ -615,6 +615,8 @@ img {
 .almanac-tabs {
   position: sticky;
   top: 0;
+  /* Over the entries scrolling under it (user report, 2026-10-25: their text showed through). */
+  z-index: 2;
   display: flex;
   justify-content: center;
   gap: 6px;
@@ -622,14 +624,18 @@ img {
   background: #ece3cc;
 }
 
+/* Three equal tabs that always fit the row (user report, 2026-10-25: on a phone they ran off both
+   ends, cut off). */
 .almanac-tabs button {
-  padding: 4px 8px;
+  flex: 1 1 0;
+  min-width: 0;
+  padding: 4px 2px;
   white-space: nowrap;
   border: 2px solid var(--ink);
   border-radius: 14px;
   background: none;
   color: var(--ink);
-  font: 13px 'Kenney Future', ui-rounded, system-ui, sans-serif;
+  font: 10px 'Kenney Future', ui-rounded, system-ui, sans-serif;
   cursor: pointer;
 }
 
@@ -707,6 +713,14 @@ button.almanac-entry.has-clip:active {
   background: #ece3ce;
 }
 
+/* What a power-up changes, in numbers (ui/info.ts changeOf): "Blast 2 → 3". */
+.offer-choice .change,
+.shop-item .change {
+  margin: 2px 0 0;
+  color: var(--accent-dark);
+  font-weight: 800;
+}
+
 .offer-choice {
   width: 100%;
   margin: 0 0 8px;
@@ -878,6 +892,19 @@ button.almanac-entry.has-clip:active {
   display: none;
 }
 
+/* The view (render/camera.ts): the same 9 × 13 tiles on every screen. Its box is set each frame;
+   its shadow is the bars round it, over the rest of the board, under the top and action bars. */
+#view-mask {
+  position: fixed;
+  z-index: 1;
+  box-shadow: 0 0 0 100vmax var(--ink);
+  pointer-events: none;
+}
+
+#view-mask[hidden] {
+  display: none;
+}
+
 /* The demo before the game (main.ts): the board alone, a caption panel at the bottom. */
 body.demo #banner {
   display: none;
@@ -1450,7 +1477,16 @@ canvas.clip[hidden] {
   align-items: center;
   justify-content: center;
   gap: 6px;
-  margin: 0 0 8px;
+  box-sizing: border-box;
+  height: 36px;
+  margin: 0;
+  /* Stays at the top while the items scroll under it (user report, 2026-10-25: scrolling hid the
+     coins), on the parchment's colour so nothing shows through. */
+  position: sticky;
+  top: 0;
+  z-index: 1;
+  padding: 4px 0;
+  background: #ece3cc;
 }
 
 #dialog-body .shop-coins img,
@@ -1502,6 +1538,11 @@ canvas.clip[hidden] {
   font-size: 10px;
 }
 
+/* In the shop the clip sticks just under the coins, which stick at the very top. */
+#dialog .panel.shop .offer-clip {
+  top: 36px;
+}
+
 /* The shop's buttons: Restock and Next level side by side, one short row. The selected item's own
    Buy button (smaller; only on the selected card), and a smaller clip, so the items have room. */
 #dialog .panel.shop #dialog-buttons {
diff --git a/src/ui/almanac.ts b/src/ui/almanac.ts
index b89ae40..8a9c09f 100644
--- a/src/ui/almanac.ts
+++ b/src/ui/almanac.ts
@@ -21,7 +21,7 @@ const TABS: [AlmanacTab, string][] = [
 
 /** The power-ups in the order they make sense to read: stats, abilities, passes, the rest. */
 const POWER_UPS: PowerUpKind[] = [
-  'bombPlus', 'firePlus', 'fusePlus', 'run', 'kick', 'throw', 'teleport', 'diagonal', 'slide', 'rpg', 'detonator', 'proximity',
+  'bombPlus', 'firePlus', 'tnt', 'fusePlus', 'run', 'kick', 'throw', 'teleport', 'diagonal', 'slide', 'rpg', 'detonator', 'proximity',
   'wallPass', 'bombPass', 'flamePass', 'star', 'heart', 'coinBag',
 ]
 
diff --git a/src/ui/info.ts b/src/ui/info.ts
index 32802dc..cc4f7e7 100644
--- a/src/ui/info.ts
+++ b/src/ui/info.ts
@@ -1,10 +1,10 @@
 import { DECOY_RANGE, FREEZE_TURNS, NUKE_RANGE, TELEPORT_RANGE, teleportRange } from '../sim/actions'
-import { BOMB_BOOST, salePrice } from '../sim/charges'
+import { BOMB_BOOST, CHARGE_AMOUNT, salePrice } from '../sim/charges'
 import { LAST_LEVEL } from '../sim/levelgen'
 import { bombAt, cellAt, eq, exitOpen, isBurning, CLEAR_BONUS, PAR_BONUS } from '../sim/state'
-import { COIN_BAG, EXIT_SPAWN, OFFER_SIZE, STAR_TURNS } from '../sim/turn'
+import { COIN_BAG, EXIT_SPAWN, OFFER_SIZE, STAR_TURNS, STAT_MAX, STAT_STEP } from '../sim/turn'
 import { SHOP_EVERY } from '../sim/shop'
-import type { ActionId, ChargeId, Death, Dir, EnemyKind, GameState, Powers, PowerUpKind, Vec } from '../sim/types'
+import { isCharge, type ActionId, type ChargeId, type Death, type Dir, type EnemyKind, type GameState, type OfferKind, type Powers, type PowerUpKind, type Vec } from '../sim/types'
 import { dirBetween, RESTS } from '../sim/enemies'
 import type { SceneKind } from '../sim/demo'
 import { CHARGE_ICONS, coinSprite, ENEMY_SPRITES, GEM_SPRITES, isOuterWall, POWER_UP_ICONS, spriteUrl, wallSprite } from '../render/sprites'
@@ -36,6 +36,7 @@ export const ENEMY_TEXT: Record<EnemyKind, { name: string; moves: string }> = {
 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.' },
+  tnt: { name: 'TNT', text: 'Your blasts reach two tiles further in each direction: two Fire +1s in one.' },
   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.' },
@@ -56,7 +57,7 @@ export const POWER_UP_TEXT: Record<PowerUpKind, { name: string; text: string }>
 
 /** The scene that shows each power-up, for its clip (PLAN.md §13c); Heart and Coin bag have none. */
 export const POWER_UP_CLIP: Partial<Record<PowerUpKind, SceneKind>> = {
-  bombPlus: 'bombPlus', firePlus: 'firePlus', fusePlus: 'fusePlus', run: 'run', detonator: 'detonate', wallPass: 'wallPass',
+  bombPlus: 'bombPlus', firePlus: 'firePlus', tnt: 'firePlus', fusePlus: 'fusePlus', run: 'run', detonator: 'detonate', wallPass: 'wallPass',
   bombPass: 'bombPass', flamePass: 'flamePass', star: 'star', kick: 'kick', throw: 'throw', teleport: 'teleport', diagonal: 'diagonal',
   rpg: 'rpg', proximity: 'proximity', slide: 'slide',
 }
@@ -69,8 +70,8 @@ export const CHARGE_TEXT: Record<ChargeId, { name: string; text: string }> = {
   pierce: { name: 'Pierce', text: `${BOMB_BOOST} bombs whose flame goes through crates, burning every one in reach. Put one down with the charge button.` },
   power: { name: 'Power', text: `${BOMB_BOOST} bombs whose flame reaches all the way to the walls (crates still stop it). Put one down with the charge button.` },
   nuke: { name: 'Nuke', text: `${BOMB_BOOST} bombs that also burn every tile within ${NUKE_RANGE} steps, through crates and round corners. Get well away! Put one down with the charge button.` },
-  shield: { name: 'Shield', text: 'Press the charge button to raise it. It stays up, level after level, until something hits you: it absorbs that hit (flame or enemy), and anything else that hits you that turn. Then it is gone.' },
-  freeze: { name: 'Freeze', text: `Press the charge button: every enemy freezes in place for ${FREEZE_TURNS} turns, that one included, thawing as it goes.` },
+  shield: { name: 'Shield', text: 'Press the charge button, then tap yourself (or press it again) to raise it. It stays up, level after level, until something hits you: it absorbs that hit (flame or enemy), and anything else that hits you that turn. Then it is gone.' },
+  freeze: { name: 'Freeze', text: `Press the charge button, then tap yourself (or press it again): every enemy freezes in place for ${FREEZE_TURNS} turns, that one included, thawing as it goes.` },
   decoy: { name: 'Decoy', text: `Press the charge button, then tap where it goes: up to ${DECOY_RANGE} tiles away in a straight line, with nothing in the way. A copy of you that enemies chasing you go after instead, until the level ends, an enemy reaches it, or flame burns it.` },
   icbm: { name: 'ICBM', text: `${BOMB_BOOST} launches: the charge button fires a bomb as far as it flies, as an RPG's, and it goes off the moment it lands. Your bomb limit doesn't count.` },
 }
@@ -79,6 +80,35 @@ export const CHARGE_TEXT: Record<ChargeId, { name: string; text: string }> = {
 const CHOICE_TEXT =
   'Picking up a charge of another kind (or choosing or buying one) while you carry one asks: keep yours and sell the new one, or take the new one and sell yours. Either way you get coins for the one you let go: half its shop price, for what is left of it. Picking up the same kind adds its uses to yours.'
 
+/**
+ * What taking `kind` changes, as a number from what it is now (user request, 2026-10-25: "fire 2 →
+ * 3"), for the gem's offer and the shop: the stat or count it raises, or a charge's uses when the
+ * same kind is carried. Null for what has no number (an ability, a pass).
+ */
+export function changeOf(s: GameState, kind: OfferKind): string | null {
+  const step = (name: string, now: number, by: number) => `${name} ${now} → ${now + by}`
+  // A stat shows its limit too (user request, 2026-10-25), so the room left is plain.
+  const stat = STAT_STEP[kind as PowerUpKind]
+  if (stat) {
+    const name = { capacity: 'Bombs', fire: 'Blast', fuse: 'Fuse' }[stat.stat]
+    const now = s.stats[stat.stat]
+    return `${name} ${now} → ${Math.min(STAT_MAX[stat.stat], now + stat.by)} (max ${STAT_MAX[stat.stat]})`
+  }
+  switch (kind) {
+    case 'heart': return step('Lives', s.lives, 1)
+    case 'coinBag': return step('Coins', s.coins, COIN_BAG)
+    case 'star': return s.star > 0 ? step('Star turns', s.star, STAR_TURNS) : null
+  }
+  if (isCharge(kind) && s.charge && s.charge.kind === kind) return step('Uses', s.charge.left, CHARGE_AMOUNT[kind])
+  return null
+}
+
+/** The change as a line on a card (the offer's, the shop's), or nothing. */
+export function changeLine(s: GameState, kind: OfferKind): string {
+  const change = changeOf(s, kind)
+  return change ? `<p class="change">${change}</p>` : ''
+}
+
 /** The rule shared by every charge, for their dialogs (PLAN.md §19.7). */
 export function chargeRule(kind: ChargeId): string {
   return `You carry one charge at a time. Picking up another asks: sell it (${kind === 'shield' || kind === 'freeze' || kind === 'decoy' ? '' : 'a full one '}for ${salePrice(kind)} coins) or swap it for the one you hold, which is sold.`
@@ -183,21 +213,21 @@ export function hudInfo(item: HudItem, s: GameState): Info {
       return {
         icon: 'bomb',
         title: `Bombs: ${s.stats.capacity} at a time`,
-        body: ['How many of your bombs can be on the board at once. The Bomb +1 power-up adds one.'],
+        body: [`How many of your bombs can be on the board at once. The Bomb +1 power-up adds one, up to ${STAT_MAX.capacity}.`],
         clip: 'bombPlus',
       }
     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. The Fire +1 power-up adds one.'],
+        body: [`How far your blasts reach in each of the four directions. The Fire +1 power-up adds one, TNT two, up to ${STAT_MAX.fire}.`],
         clip: 'firePlus',
       }
     case 'fuse':
       return {
         icon: 'icon_fuse',
         title: `Fuse: ${s.stats.fuse}`,
-        body: [`The number your new bombs start at: they explode after ${s.stats.fuse} more of your actions. The Fuse +1 power-up adds one.`],
+        body: [`The number your new bombs start at: they explode after ${s.stats.fuse} more of your actions. The Fuse +1 power-up adds one, up to ${STAT_MAX.fuse}.`],
         clip: 'fusePlus',
       }
     case 'star':
diff --git a/src/ui/offer.ts b/src/ui/offer.ts
index 4a71827..59b0db7 100644
--- a/src/ui/offer.ts
+++ b/src/ui/offer.ts
@@ -1,9 +1,9 @@
-import { isCharge, type OfferKind } from '../sim/types'
+import { isCharge, type GameState, type OfferKind } from '../sim/types'
 import { CHARGE_ICONS, POWER_UP_ICONS, spriteUrl } from '../render/sprites'
 import type { SceneKind } from '../sim/demo'
 import { startClip, stopClip } from './clip'
 import { openDialog } from './dialog'
-import { CHARGE_TEXT, POWER_UP_CLIP, POWER_UP_TEXT } from './info'
+import { CHARGE_TEXT, changeLine, POWER_UP_CLIP, POWER_UP_TEXT } from './info'
 
 /**
  * The gem's choice (user request, 2026-10-23): its offer of power-ups, each with what it does,
@@ -17,7 +17,7 @@ import { CHARGE_TEXT, POWER_UP_CLIP, POWER_UP_TEXT } from './info'
 let chosen: ((kind: OfferKind) => void) | null = null
 let selected: OfferKind | null = null
 
-export function openOffer(offer: OfferKind[], choose: (kind: OfferKind) => void): void {
+export function openOffer(s: GameState, offer: OfferKind[], choose: (kind: OfferKind) => void): void {
   chosen = choose
   openDialog({
     title: 'Choose a power-up',
@@ -29,7 +29,7 @@ export function openOffer(offer: OfferKind[], choose: (kind: OfferKind) => void)
           return (
             `<button type="button" class="almanac-entry offer-choice" data-offer="${k}" aria-pressed="false">` +
             `<img src="${spriteUrl(isCharge(k) ? CHARGE_ICONS[k] : POWER_UP_ICONS[k])}" alt="" draggable="false">` +
-            `<div><strong>${text.name}</strong><p>${text.text}</p></div></button>`
+            `<div><strong>${text.name}</strong>${changeLine(s, k)}<p>${text.text}</p></div></button>`
           )
         })
         .join(''),
diff --git a/src/ui/shop.ts b/src/ui/shop.ts
index 0107cef..369a926 100644
--- a/src/ui/shop.ts
+++ b/src/ui/shop.ts
@@ -4,7 +4,7 @@ import { PRICES, rerollPrice } from '../sim/shop'
 import { isCharge, type GameState, type OfferKind } from '../sim/types'
 import { startClip, stopClip } from './clip'
 import { dialogOpen, openDialog } from './dialog'
-import { CHARGE_TEXT, POWER_UP_CLIP, POWER_UP_TEXT } from './info'
+import { CHARGE_TEXT, changeLine, POWER_UP_CLIP, POWER_UP_TEXT } from './info'
 import coinIcon from '../../assets/kenney/hud_coin.png'
 
 /**
@@ -74,7 +74,7 @@ export function updateShop(s: GameState): void {
       return (
         `<div class="almanac-entry offer-choice shop-item${sold ? ' sold' : ''}" data-item="${k}" role="button" tabindex="0" aria-pressed="false">` +
         `<img src="${spriteUrl(isCharge(k) ? CHARGE_ICONS[k] : POWER_UP_ICONS[k])}" alt="" draggable="false">` +
-        `<div><strong>${textOf(k).name}<span class="price">${price}</span></strong><p>${textOf(k).text}</p>${buy}</div></div>`
+        `<div><strong>${textOf(k).name}<span class="price">${price}</span></strong>${changeLine(s, k)}<p>${textOf(k).text}</p>${buy}</div></div>`
       )
     })
     .join('')
diff --git a/test/charges.test.ts b/test/charges.test.ts
index 59ae0ae..98c8a2e 100644
--- a/test/charges.test.ts
+++ b/test/charges.test.ts
@@ -96,7 +96,7 @@ describe('charges are used on purpose', () => {
     s.bombs[0]!.timer = 1
     // Carried, it does nothing: the blast kills.
     expect(step(s, { id: 'wait' }).state.status).toBe('dead')
-    const { state, events } = step(s, { id: 'shield' })
+    const { state, events } = step(s, { id: 'shield', target: s.player })
     expect(state.status).toBe('playing')
     expect(state.charge).toBeNull()
     expect(state.shieldUp).toBe(false)
@@ -104,13 +104,25 @@ describe('charges are used on purpose', () => {
     expect(events.some((e) => e.type === 'shieldBroke')).toBe(true)
   })
 
+  it('Shield and Freeze need a tile, the player’s own: a confirmation, not a stray tap (2026-10-25)', () => {
+    const s = board(`
+      #####
+      #P..#
+      #####`)
+    for (const kind of ['shield', 'freeze'] as const) {
+      s.charge = { kind, left: 1 }
+      expect(ACTIONS[kind].validTargets(s)).toEqual([s.player])
+      expect(() => step(s, { id: kind })).toThrow()
+    }
+  })
+
   it('a raised Shield stays up until a hit, level after level (user request, 2026-10-25)', () => {
     let s = board(`
       #####
       #P..#
       #####`)
     s.charge = { kind: 'shield', left: 1 }
-    s = step(s, { id: 'shield' }).state
+    s = step(s, { id: 'shield', target: s.player }).state
     for (let t = 0; t < 20; t++) s = step(s, { id: 'wait' }).state
     expect(s.shieldUp).toBe(true)
     expect(createLevel(s.seed, 2, s.options, carryOf(s)).shieldUp).toBe(true)
@@ -123,7 +135,7 @@ describe('charges are used on purpose', () => {
       #######`)
     s.charge = { kind: 'freeze', left: 1 }
     s.enemies[0]!.plan = [v(4, 1), v(3, 1), v(2, 1)]
-    let after = step(s, { id: 'freeze' }).state
+    let after = step(s, { id: 'freeze', target: s.player }).state
     expect(after.enemies[0]!.pos).toEqual(v(5, 1))
     expect(after.charge).toBeNull()
     for (let t = 1; t < FREEZE_TURNS; t++) after = step(after, { id: 'wait' }).state
diff --git a/test/demo.test.ts b/test/demo.test.ts
index 90262df..36165af 100644
--- a/test/demo.test.ts
+++ b/test/demo.test.ts
@@ -24,8 +24,6 @@ function controlsTake(s: GameState, g: Gesture, destination: Vec | null): Action
   if (g.kind === 'go') return destination && autoMoveAction(s, destination, safe)
   if (g.kind === 'choice') return s.chargeOffer ? { id: g.swap ? 'swapCharge' : 'sellCharge' } : null
   if (g.button === 'wait') return { id: 'wait' }
-  // The charge button alone: a Freeze or a Shield, used at once (main.ts `pick`).
-  if (g.button === 'charged' && !g.tile) return { id: s.charge?.kind === 'shield' ? 'shield' : 'freeze' }
   const id = g.tile && resolvePick(s, g.button, g.tile)
   return id ? { id, target: g.tile } : null
 }
diff --git a/test/enemies.test.ts b/test/enemies.test.ts
index b66c0e8..17dc44a 100644
--- a/test/enemies.test.ts
+++ b/test/enemies.test.ts
@@ -1,7 +1,7 @@
 import { describe, expect, it } from 'vitest'
 import { ACTIONS, FREEZE_TURNS, isAvailable } from '../src/sim/actions'
 import { LASTING_CHARGES, PLAN_AHEAD, planIntents, rollDrops, stepToward } from '../src/sim/enemies'
-import { enemyCount, enemyKinds, generateLevel, SAFE_DISTANCE, START } from '../src/sim/levelgen'
+import { concentration, ENEMY_THREAT, enemyKinds, generateLevel, SAFE_DISTANCE, START } from '../src/sim/levelgen'
 import { CLEAR_BONUS, newRun, PAR_BONUS } from '../src/sim/state'
 import { EXIT_SPAWN, step } from '../src/sim/turn'
 import type { GameState, Vec } from '../src/sim/types'
@@ -17,7 +17,13 @@ describe('levelgen: enemies and exit', () => {
       const at = (p: Vec) => g.cells[p.y * g.width + p.x]
       expect(at(g.exit)).toBe('block')
       expect(dist(g.exit, START)).toBeGreaterThan(SAFE_DISTANCE)
-      expect(g.enemies).toHaveLength(enemyCount(level))
+      // Enough enemies for the level's crowding: their threat reaches its concentration (at least 2),
+      // and one fewer would fall short.
+      const open = g.cells.filter((c) => c !== 'wall').length
+      const threat = g.enemies.reduce((sum, e) => sum + ENEMY_THREAT[e.kind], 0)
+      expect(g.enemies.length).toBeGreaterThanOrEqual(2)
+      expect(threat).toBeGreaterThanOrEqual(concentration(level) * open)
+      if (g.enemies.length > 2) expect(threat - ENEMY_THREAT[g.enemies.at(-1)!.kind]).toBeLessThan(concentration(level) * open)
       expect(new Set(g.enemies.map((e) => `${e.pos.x},${e.pos.y}`)).size).toBe(g.enemies.length)
       for (const e of g.enemies) {
         expect(at(e.pos)).toBe('floor')
@@ -190,7 +196,7 @@ describe('plans three moves ahead', () => {
       #P..B#
       ######`), v(3, 1))
     s.charge = { kind: 'freeze', left: 1 }
-    const { state } = step(s, { id: 'freeze' })
+    const { state } = step(s, { id: 'freeze', target: s.player })
     expect(state.enemies[0]!.plan[0]).toEqual(v(3, 1))
   })
 })
@@ -405,7 +411,7 @@ describe('the exit', () => {
     expect(state).toMatchObject({ level: 2, turn: 0, lives: 2, coins: 4 + CLEAR_BONUS + PAR_BONUS, status: 'playing' })
     expect(state.stats.fire).toBe(3)
     expect(state.charge).toEqual({ kind: 'pierce', left: 2 })
-    expect(state.enemies.length).toBe(enemyCount(2))
+    expect(state.enemies.length).toBe(generateLevel(state.seed, 2).enemies.length)
   })
 
   it('flaming the uncovered exit lets out enemies that drop nothing', () => {
@@ -559,7 +565,7 @@ describe('action charges and abilities', () => {
       #P....B#
       ########`), v(5, 1))
     s.charge = { kind: 'freeze', left: 1 }
-    s = step(s, { id: 'freeze' }).state
+    s = step(s, { id: 'freeze', target: s.player }).state
     expect(s.frozen).toBe(FREEZE_TURNS - 1)
     for (let i = 1; i < FREEZE_TURNS; i++) {
       s = step(s, { id: 'wait' }).state
diff --git a/test/levelgen.test.ts b/test/levelgen.test.ts
index 9265806..d92c075 100644
--- a/test/levelgen.test.ts
+++ b/test/levelgen.test.ts
@@ -1,5 +1,5 @@
 import { describe, expect, it } from 'vitest'
-import { generateLevel, LEVEL_HEIGHT, LEVEL_WIDTH, START } from '../src/sim/levelgen'
+import { boardSize, generateLevel, START } from '../src/sim/levelgen'
 import type { Vec } from '../src/sim/types'
 
 describe('levelgen', () => {
@@ -10,8 +10,9 @@ describe('levelgen', () => {
 
   for (let seed = 1; seed <= 200; seed++) {
     it(`seed ${seed}: walls, pillars, a clear start, everything reachable`, () => {
-      const { width, height, cells, start } = generateLevel(seed, 1 + (seed % 50))
-      expect([width, height]).toEqual([LEVEL_WIDTH, LEVEL_HEIGHT])
+      const level = 1 + (seed % 50)
+      const { width, height, cells, start } = generateLevel(seed, level)
+      expect({ width, height }).toEqual(boardSize(level))
       const at = (p: Vec) => cells[p.y * width + p.x]
       for (let x = 0; x < width; x++) expect([at({ x, y: 0 }), at({ x, y: height - 1 })]).toEqual(['wall', 'wall'])
       for (let y = 0; y < height; y++) expect([at({ x: 0, y }), at({ x: width - 1, y })]).toEqual(['wall', 'wall'])
diff --git a/test/options.test.ts b/test/options.test.ts
index 7182a6f..0952120 100644
--- a/test/options.test.ts
+++ b/test/options.test.ts
@@ -1,5 +1,5 @@
 import { describe, expect, it } from 'vitest'
-import { enemyCount, generateLevel, LAST_LEVEL, START } from '../src/sim/levelgen'
+import { boardSize, generateLevel, LAST_LEVEL, START } from '../src/sim/levelgen'
 import { newRecord, replay } from '../src/sim/record'
 import { newRun } from '../src/sim/state'
 import { drawOffer, POWER_UP_WEIGHTS, step } from '../src/sim/turn'
@@ -7,17 +7,17 @@ import { CHARGE_IDS, DEFAULT_OPTIONS, isCharge, type Vec } from '../src/sim/type
 import { BOMB_BOOST } from '../src/sim/charges'
 import { board, v } from './helpers'
 
-describe('the board (one size since 2026-10-23)', () => {
+describe('the board (growing with the level since 2026-10-25)', () => {
   for (let seed = 1; seed <= 100; seed++) {
     const level = 1 + (seed % 20)
-    it(`seed ${seed}: 9 × 11, exit and power-up under crates, enemies placed, everything reachable`, () => {
+    it(`seed ${seed}: its level's size, exit and power-up under crates, enemies placed, everything reachable`, () => {
       const g = generateLevel(seed, level)
-      expect([g.width, g.height]).toEqual([9, 11])
+      expect({ width: g.width, height: g.height }).toEqual(boardSize(level))
       const at = (p: Vec) => g.cells[p.y * g.width + p.x]
       expect(at(g.exit)).toBe('block')
       expect(at(g.powerUp.pos)).toBe('block')
       expect(g.powerUp.pos).not.toEqual(g.exit)
-      expect(g.enemies).toHaveLength(enemyCount(level))
+      expect(g.enemies.length).toBeGreaterThanOrEqual(2)
       for (const p of [START, { x: 2, y: 1 }, { x: 1, y: 2 }]) expect(at(p)).toBe('floor')
       const seen = new Set([`${START.x},${START.y}`])
       const queue = [START]
@@ -34,9 +34,12 @@ describe('the board (one size since 2026-10-23)', () => {
     })
   }
 
-  it('2 enemies on level 1, at most 5, and a 20-level run', () => {
-    expect(enemyCount(1)).toBe(2)
-    expect(enemyCount(20)).toBe(5)
+  it('boards grow with the level, from 9 × 11 to 15 × 21, over a 20-level run', () => {
+    expect(boardSize(1)).toEqual({ width: 9, height: 11 })
+    expect(boardSize(4)).toEqual({ width: 11, height: 13 })
+    expect(boardSize(20)).toEqual({ width: 15, height: 21 })
+    expect(boardSize(50)).toEqual({ width: 15, height: 21 })
+    expect(generateLevel(1, 1).enemies).toHaveLength(2)
     expect(LAST_LEVEL).toBe(20)
   })
 
diff --git a/test/path.test.ts b/test/path.test.ts
index 5f83aeb..cf2aca1 100644
--- a/test/path.test.ts
+++ b/test/path.test.ts
@@ -1,5 +1,5 @@
 import { describe, expect, it } from 'vitest'
-import { autoMoveAction, playerPath } from '../src/sim/path'
+import { autoMoveAction, autoRoute, playerPath } from '../src/sim/path'
 import { board, v } from './helpers'
 
 describe('auto-move path', () => {
@@ -139,3 +139,46 @@ describe('auto-move: a step, a Run or a Teleport (user request, 2026-10-23)', ()
     expect(autoMoveAction(s, v(3, 1), always)).toEqual({ id: 'teleport', target: v(3, 1) })
   })
 })
+
+describe('autoRoute: where auto-move lands', () => {
+  it('a step on every tile without Run; every other tile with it (user report, 2026-10-25)', () => {
+    const s = board(`
+      #########
+      #P......#
+      #########`)
+    expect(autoRoute(s, v(7, 1), () => true)).toEqual([v(2, 1), v(3, 1), v(4, 1), v(5, 1), v(6, 1), v(7, 1)])
+    s.powers.run = true
+    expect(autoRoute(s, v(7, 1), () => true)).toEqual([v(3, 1), v(5, 1), v(7, 1)])
+  })
+
+  it('empty when the first move isn’t safe', () => {
+    const s = board(`
+      ######
+      #P...#
+      ######`)
+    expect(autoRoute(s, v(4, 1), () => false)).toEqual([])
+  })
+})
+
+describe('auto-move with Diagonal (user report, 2026-10-25)', () => {
+  it('cuts corners with diagonal steps, and takes one as its move', () => {
+    const s = board(`
+      ######
+      #P...#
+      #....#
+      #....#
+      ######`)
+    expect(playerPath(s, v(4, 3))).toHaveLength(5)
+    s.powers.diagonal = true
+    expect(playerPath(s, v(4, 3))).toHaveLength(3)
+    // Three moves, not five: one of them diagonal.
+    expect(autoRoute(s, v(4, 3), () => true)).toHaveLength(3)
+    const open = board(`
+      ####
+      #P.#
+      #..#
+      ####`)
+    open.powers.diagonal = true
+    expect(autoMoveAction(open, v(2, 2), () => true)).toEqual({ id: 'diagonal', target: v(2, 2) })
+  })
+})
diff --git a/test/powerups.test.ts b/test/powerups.test.ts
index a7f1a3d..b36a935 100644
--- a/test/powerups.test.ts
+++ b/test/powerups.test.ts
@@ -1,8 +1,8 @@
 import { describe, expect, it } from 'vitest'
 import { ACTIONS, isAvailable } from '../src/sim/actions'
 import { generateLevel, LAST_LEVEL } from '../src/sim/levelgen'
-import { newRun } from '../src/sim/state'
-import { COIN_BAG, drawOffer, EXIT_SPAWN, OFFER_SIZE, POWER_UP_WEIGHTS, STAR_TURNS, step } from '../src/sim/turn'
+import { newRun, START_STATS } from '../src/sim/state'
+import { applyPowerUp, COIN_BAG, drawOffer, EXIT_SPAWN, heldAlready, OFFER_SIZE, POWER_UP_WEIGHTS, STAR_TURNS, STAT_MAX, step } from '../src/sim/turn'
 import type { GameState, OfferKind, PowerUpKind } from '../src/sim/types'
 import { board, v } from './helpers'
 
@@ -344,3 +344,27 @@ describe('the ending', () => {
     expect(s.runTurns).toBe(2)
   })
 })
+
+describe('TNT and the limits on stats (user request, 2026-10-25)', () => {
+  it('TNT raises the blast by 2; every stat stops at its limit', () => {
+    const s = newRun(1)
+    applyPowerUp(s, 'tnt')
+    expect(s.stats.fire).toBe(START_STATS.fire + 2)
+    for (let i = 0; i < 10; i++) {
+      applyPowerUp(s, 'firePlus')
+      applyPowerUp(s, 'bombPlus')
+      applyPowerUp(s, 'fusePlus')
+    }
+    expect(s.stats).toEqual(STAT_MAX)
+  })
+
+  it('a stat power-up is not offered without room for all of it', () => {
+    const s = newRun(1)
+    s.stats.fire = STAT_MAX.fire - 1
+    expect(heldAlready(s, 'firePlus')).toBe(false)
+    expect(heldAlready(s, 'tnt')).toBe(true)
+    s.stats.fire = STAT_MAX.fire
+    expect(heldAlready(s, 'firePlus')).toBe(true)
+    for (let seed = 1; seed <= 30; seed++) expect(drawOffer({ ...s, seed })).not.toContain('firePlus')
+  })
+})
diff --git a/tools/compose-icons.sh b/tools/compose-icons.sh
index 888fdac..4704d48 100644
--- a/tools/compose-icons.sh
+++ b/tools/compose-icons.sh
@@ -64,6 +64,8 @@ badge "$K/bomb.png" "$I/forward.png" powerup_rpg
 badge "$K/bomb.png" "$I/warning.png" powerup_proximity
 badge "$OUT/icon_move.png" "$B/arrow_horizontal.png" powerup_slide
 badge "$K/bomb.png" "$I/target.png" charge_icbm
+# TNT (user request, 2026-10-25): Fire +2 — the fireball itself with the Fire +1 "larger" badge.
+badge "$K/fireball.png" "$I/larger.png" powerup_tnt
 
 # Shield: the Move figure inside an empty ring (the board-game timer at 0).
 magick "$B/timer_0.png" -fill '#353541' -colorize 100 \
diff --git a/tools/screenshots.mjs b/tools/screenshots.mjs
index 1613885..4d78e14 100644
--- a/tools/screenshots.mjs
+++ b/tools/screenshots.mjs
@@ -22,18 +22,12 @@ const press = (...keys) => async (page) => {
 }
 
 /**
- * The screen position of a tile's centre: the 9 × 11 board fits between the top bar and the action
- * bar, centred both ways (render/camera.ts). It assumed a 13-row board scrolled to the left until
- * 2026-10-24, so taps landed on the wrong tiles (the "enemy" shot showed a crate).
+ * The screen position of a tile's centre, asked of the game (main.ts `window.tilePoint`): since
+ * 2026-10-25 the board can be bigger than the view and scroll with the player, so only the game's
+ * camera knows. (Worked out here from the board's size until then.)
  */
 async function tileCentre(page, x, y) {
-  return page.evaluate(([x, y]) => {
-    const top = document.getElementById('hud').getBoundingClientRect().height
-    const bottom = document.getElementById('actions').getBoundingClientRect().height
-    const available = innerHeight - top - bottom
-    const size = Math.min(available / 11, innerWidth / 9)
-    return { x: (innerWidth - 9 * size) / 2 + (x + 0.5) * size, y: top + (available - 11 * size) / 2 + (y + 0.5) * size }
-  }, [x, y])
+  return page.evaluate(([x, y]) => window.tilePoint(x, y), [x, y])
 }
 
 const tap = (x, y) => async (page) => {
@@ -75,9 +69,9 @@ const SHOTS = [
   { name: 'gem', query: 'seed=107', act: press('KeyS', 'KeyB', 'KeyS', 'Space', 'KeyW', 'Space', 'Space', 'Space', 'KeyS', 'KeyS') },
   // ...then onto it: the choice of power-ups.
   { name: 'gem-offer', query: 'seed=107', act: press('KeyS', 'KeyB', 'KeyS', 'Space', 'KeyW', 'Space', 'Space', 'Space', 'KeyS', 'KeyS', 'KeyD') },
-  // The shop, after level 3 (seed 37, Immortal: a way through found by searching random play with
-  // the sim, 2026-10-24), its first item selected and playing above.
-  { name: 'shop', query: 'seed=37&level=3&immortal', act: async (page) => { await press('KeyS', 'KeyB', 'KeyS', 'KeyW', 'KeyS', 'KeyW', 'KeyD', 'KeyB', 'KeyD', 'KeyA', 'KeyS', 'KeyS', 'KeyD', 'KeyA', 'KeyS', 'KeyB', 'KeyW', 'KeyS', 'KeyW', 'KeyS', 'KeyS', 'KeyS', 'KeyD', 'KeyA', 'KeyD', 'KeyD', 'KeyS')(page); await page.waitForTimeout(800); await page.click('#banner-button'); await page.waitForTimeout(1200) } },
+  // The shop after level 3 (?shop: straight in, with 9 coins; the keys that played through level 3
+  // stopped working when the boards changed, 2026-10-25), its first item selected and playing above.
+  { name: 'shop', query: 'seed=37&level=3&shop', act: async (page) => await page.waitForTimeout(1200) },
   // Developer mode: the rainbow gem uncovered right of the player, and the mode's tag in the top bar...
   { name: 'dev', query: 'dev', act: async () => {} },
   // ...and taking it: every power-up on offer.