fuzzyworld

explodorama / commit

Board sizes, modes, player and enemy animation, playtest fixes

beb9f2b7ca3f0d3f2a52e4474546e158bd07f213 · Pavel Safronov · 2026-10-21 09:51:54 -0700 · parents a4fab0fbe7a6

Claude Code usage: this commit ~111.0K out / ~46.5M cached · cost unknown (no per-model trailer) · session 1 tally 1.1K in / 650.9K out / 166.8M cached · usage by commit and session

Board sizes, modes, player and enemy animation, playtest fixes

New-run dialog: Normal (31 x 13, 50 levels) or Small (9 x 11, fits the
screen, 20 levels), plus Developer (every ability, unlimited charges, still
killable), God (unkillable) and Crumbling crates. The player and enemies
hop between tiles; the player also bobs, crouches, jumps and shakes. Drops
never stack: a taken tile sends a drop to the nearest free one. Wall info
shows the exact tile; the Kenney credit lives only in the menu. Exclusive
power-ups written up in PLAN.md 5b as an open idea. SIM_VERSION 6.

Usage: 1.1K in / 650.9K out / 166.8M cached (session tally)
rtk:   1.7M saved, 25.6% over 2745 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
 PLAN.md                            |  68 ++++++++++++++++-
 README.md                          |  29 ++++++--
 decisions.md                       |  44 +++++++++++
 docs/screenshots/auto-move.png     | Bin 248711 -> 249023 bytes
 docs/screenshots/bomb-picked.png   | Bin 248823 -> 248985 bytes
 docs/screenshots/charges.png       | Bin 270548 -> 276870 bytes
 docs/screenshots/countdown.png     | Bin 253449 -> 253578 bytes
 docs/screenshots/death.png         | Bin 214407 -> 198263 bytes
 docs/screenshots/desktop.png       | Bin 593323 -> 593333 bytes
 docs/screenshots/dev.png           | Bin 0 -> 266198 bytes
 docs/screenshots/enemies.png       | Bin 252484 -> 252627 bytes
 docs/screenshots/info-enemy.png    | Bin 192054 -> 192237 bytes
 docs/screenshots/info-hud.png      | Bin 182092 -> 182314 bytes
 docs/screenshots/lingering.png     | Bin 257773 -> 257820 bytes
 docs/screenshots/long-press.png    | Bin 252078 -> 251937 bytes
 docs/screenshots/menu.png          | Bin 186148 -> 190391 bytes
 docs/screenshots/new-run.png       | Bin 175001 -> 194300 bytes
 docs/screenshots/pierce-decoy.png  | Bin 280079 -> 284653 bytes
 docs/screenshots/power-up.png      | Bin 261789 -> 261831 bytes
 docs/screenshots/run.png           | Bin 237155 -> 237216 bytes
 docs/screenshots/scrolled.png      | Bin 261115 -> 261157 bytes
 docs/screenshots/small-desktop.png | Bin 0 -> 279278 bytes
 docs/screenshots/small.png         | Bin 0 -> 189686 bytes
 docs/screenshots/start.png         | Bin 247709 -> 247835 bytes
 index.html                         |   2 +-
 src/main.ts                        | 105 ++++++++++++++++++++++----
 src/render/board.ts                |  81 ++++++++++++++++----
 src/render/camera.ts               |   5 +-
 src/render/sprites.ts              |  13 ++++
 src/sim/levelgen.ts                |  65 ++++++++++++----
 src/sim/record.ts                  |  12 +--
 src/sim/state.ts                   |  24 ++++--
 src/sim/turn.ts                    |  78 ++++++++++++++++---
 src/sim/types.ts                   |  22 ++++++
 src/style.css                      |  92 ++++++++++++++++++++---
 src/ui/controls.ts                 |  18 ++++-
 src/ui/info.ts                     |  37 +++++++--
 test/enemies.test.ts               |  41 ++++++++++
 test/helpers.ts                    |   6 +-
 test/options.test.ts               | 149 +++++++++++++++++++++++++++++++++++++
 tools/screenshots.mjs              |   5 ++
 41 files changed, 798 insertions(+), 98 deletions(-)

diff --git a/PLAN.md b/PLAN.md
index c4abd62..7f7f023 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -213,6 +213,41 @@ NES rules kept (they create the classic tension):
 
 ---
 
+## 5b. Exclusive power-ups — a roguelike direction (idea, user, 2026-10-21; *not decided*)
+
+The user wants to start thinking about making power-ups **exclusive**: picking one up can
+**replace** another, so the player can't simply collect everything, and is pushed to
+**specialise, experiment and choose** — which to keep, which to drop. A sketch to discuss:
+
+- **Slots.** The player has a few power-up slots (e.g. 3). Taking a power-up with a free slot
+  fills it; with all slots full, a dialog asks which one to give up (or to leave the new one).
+  The dropped power-up stays on that tile, so a swap can be undone while still on the level.
+- **What takes a slot.** Natural candidates: the abilities and passes (Skates / Run, Detonator,
+  Wall / Bomb / Flame Pass), maybe later new ones (Kick as a permanent ability, Pierce bombs…).
+  Open: do the stat gems (Bomb+, Fire+, Fuse+) also take slots (e.g. "Bomb+ ×2" in one slot,
+  stacking within it), or stay uncapped as in the NES? Instant ones (Heart, Coin bag, Star)
+  would not take a slot.
+- **Synergies and trade-offs** make choices interesting: e.g. Bomb Pass + Kick, Wall Pass +
+  Pierce, Flame Pass + Line bombs. A power-up could also have a downside (a "cursed" version).
+
+**Inconsistencies with the current plan, to resolve before building** (CLAUDE.md asks to raise
+these, not paper over them):
+
+1. **Keep / Fresh on death (§9)** is balanced around *accumulated* stats and abilities
+   ("Keep: 💣2 🔥3 Run"). With slots, "Keep" keeps the slot loadout; Fresh's payout formula
+   would need rethinking.
+2. **The shop (§8)** sells abilities and stats; with slots, a purchase may also force a swap.
+3. **The Workshop (§7)** has tracks like "Bomb satchel +1 starting capacity" and "Skates from
+   the start" — they'd become starting slot contents or extra slots ("+1 slot" is a natural
+   Workshop track).
+4. **NES rule: passes and Detonator are lost on death (§5, §9)** — with slots, perhaps nothing
+   is lost on death and the slot choice itself is the cost.
+5. **Developer mode (§13b)** gives everything; it would bypass slots (fine for testing).
+
+Open questions for the user: how many slots? Do stats take slots? Is a swapped-out power-up
+gone or left on the tile? Should action charges (one-use, from enemies) stay separate from slots
+(probably yes)?
+
 ## 6. Curses (from the *Super Bomberman* skull)
 
 - **Never before the first shop** — i.e. not on level 1. From level 2 onward a **skull** can
@@ -341,7 +376,9 @@ contact. Intent shown every turn. Adapted from the NES eight:
   +1 every 3 levels, at most 10. New type every 6 levels: Seeker from 7, Drifter from 13.
   Enemies may share a tile (they don't block each other). Enemies spawned from a flamed exit
   drop nothing, so the exit can't be farmed.
-- Drops land on the enemy's tile and stay until collected or flamed.
+- Drops land on the enemy's tile and stay until collected or flamed. *Built (2026-10-21, user
+  request):* drops never stack — a drop whose tile is taken (by a drop, a bomb or the uncovered
+  power-up) rolls to the nearest free floor tile within 2 steps (BFS over floor), else stays.
 - **Introduction schedule over 50 levels** (like the NES): Blob only at first; one new type
   every ~6 levels; later levels mix types. Exact table to be drafted in `design.md`.
 
@@ -411,7 +448,8 @@ plan of black Icongeek26 outline icons from Flaticon over a plain drawn board.
 
 - **Licence:** CC0, so attribution isn't required, but **the user wants Kenney credited
   properly**: in the README's Attribution section and in the game (a credit line with a link to
-  kenney.nl on the death / game-over banner; move it to the About dialog when there is one). The
+  kenney.nl in the menu — *only there*, user request 2026-10-21; it used to be on the death /
+  game-over banner too). The
   pack's `License.txt` is kept in `assets/kenney/`.
 - **Source files:** only the sprites the game uses (or is about to use, below) are copied, unchanged
   and under the pack's own file names, into `assets/kenney/`, so each can be found again in the
@@ -471,6 +509,24 @@ settings, sound, and most action charges) are still open.
 
 ---
 
+## 13b. Run options (user request, 2026-10-21; built)
+
+Chosen in the **New run** dialog (menu, or after game over / victory), remembered in
+`localStorage` for the next new run, fixed for the run, stored in its record (`RunRecord.options`)
+and state (`GameState.options`), so replays are exact. Shown as tags in the top bar and in the menu.
+
+| Option | Rules |
+|---|---|
+| **Board: Normal** | 31 × 13, density 35%, 50 levels, enemies 3 + 1 per 3 levels (max 10), par 50 + 10/enemy. |
+| **Board: Small** | 9 × 11 (fits a phone whole; the camera scales to fit both ways, no scrolling), density 25%, **20 levels**, enemies 2 + 1 per 4 levels (max 5), par 25 + 8/enemy. Same enemy-type schedule and Skates rule. Levelgen tops up crates if fewer than 2 are far enough for the exit and power-up. |
+| **Developer** | Every ability and pass, 5 bombs, blast 5, unlimited charges (never spent; badges show ∞). Re-applied every level and after every death. **Killable** (the user split it from God mode). Second-row buttons pack five to a row when there are more than 8. |
+| **God** | The player can't be hurt at all. |
+| **Crumbling crates** | When the last enemy dies, every crate gets a countdown of 1–3 turns (seeded, `hash(seed, "crumble", level)`), shown by shaking and fading; at 0 it is gone, revealing whatever was under it. It is not flame: no punishment, nothing burned. |
+
+URL test parameters `?small`, `?dev`, `?god`, `?crumble` set them for a test board.
+
+---
+
 ## 14. Open questions
 
 1. **Workshop banking:** only coins **left over** at the end of a run, or **all** coins earned?
@@ -490,6 +546,10 @@ settings, sound, and most action charges) are still open.
    deterministic) or a simple checksum?
 8. **Board theme:** ~~light "paper" board or dark board?~~ Settled by the Kenney art
    (2026-10-20): a pale sky-blue board under dark bars, coloured as the sprites are.
+9. **Exclusive power-ups (§5b):** slots instead of collecting everything? How many, and what
+   takes one? Needs answers before the shop and Keep / Fresh are built, since both depend on it.
+10. **Enemies sharing a tile:** they do now, as in the NES original (as far as remembered). Should
+    they block each other instead?
 
 ---
 
@@ -523,7 +583,9 @@ settings, sound, and most action charges) are still open.
   the first shop.
 - **Screenshots:** Playwright at a phone viewport (e.g. 390×844) and desktop, with URL params
   like `?seed=123&level=1` for reproducible scenes. The user reviews them before any commit.
-- **Start simple:** labelled shapes until icons arrive; no animation polish until M7.
+- **Start simple:** labelled shapes until icons arrive; no animation polish until M7. *(The
+  player's animation — idle bob and glance, walking slide, crouch, jump, hit — came early, at
+  the user's request, 2026-10-21, and enemies got the same hop the same day.)*
 
 ---
 
diff --git a/README.md b/README.md
index 63d9837..9b875af 100644
--- a/README.md
+++ b/README.md
@@ -15,7 +15,8 @@ see [PLAN.md](PLAN.md).
 
 ## How to play
 
-You are the green astronaut. **Goal of each level:** destroy every enemy, uncover the exit door
+You are the green astronaut (animated: it bobs while waiting, hops from tile to tile, crouches
+to place a bomb and jumps when a level is cleared; enemies hop between tiles too). **Goal of each level:** destroy every enemy, uncover the exit door
 hidden under one of the crates, and step onto it.
 
 Each turn you pick **one** action; then the world takes its turn, in this order: enemies step
@@ -121,7 +122,8 @@ Level 1 has 3 enemies, with one more every 3 levels (at most 10). Enemies never
 ### Drops and coins
 
 A slain enemy sometimes leaves **coins** (bronze = 1, silver = 2, gold = 5) or an **action
-charge** on its tile. Walk onto them to collect them. A later flame burns drops lying in the
+charge** on its tile. Drops never share a tile: if one is already there, the new one rolls to the
+nearest free floor tile (up to 2 steps away). Walk onto them to collect them. A later flame burns drops lying in the
 open, so pick them up soon. Coins will buy upgrades in the shop (not built yet).
 
 ### The exit
@@ -165,9 +167,25 @@ Wall Pass, Bomb Pass, Flame Pass, the Detonator and a Star (as in the original);
 what you lost. With none left, the run is over and the seed is shown, so the same board can be
 replayed.
 
+### New run: board size and modes
+
+**Menu → New run** (or the button after game over / victory) lets you choose:
+
+| Choice | What it does |
+|---|---|
+| **Normal** board | 31 × 13, scrolls sideways, **50 levels**. |
+| **Small** board | 9 × 11, fits the screen whole (no scrolling), **20 levels**, fewer enemies (2 on level 1, up to 5) and fewer crates. A quicker game. |
+| **Developer** (checkbox) | Every power-up and ability (Run, Detonate, all three passes), **unlimited** action charges (badges show ∞), 5 bombs, blast 5 — for trying everything out. You can still be killed. |
+| **God** (checkbox) | Nothing can kill you. |
+| **Crumbling crates** (checkbox) | Once the last enemy is gone, every crate crumbles away over 1–3 turns (shaking first), revealing the exit and the power-up. Not flame: nothing is punished. |
+
+The choice is remembered for next time, is fixed for the run, and shows as small yellow tags at
+the left of the top bar (tap them for details) and in the menu. Starting a new run while one is
+going warns that it will be lost.
+
 ### The run
 
-**50 levels.** Clearing level 50 ends the run with a victory banner: total turns, lives lost
+**50 levels** (20 on a small board). Clearing the last level ends the run with a victory banner: total turns, lives lost
 and the seed. The game **saves after every move** (in this browser), so closing the tab and
 coming back continues the run where you left it.
 
@@ -194,7 +212,7 @@ npm run screenshots  # build, then photograph the game into docs/screenshots/ (P
 
 URL parameters: `?seed=123` plays a fixed board; `?level=4` starts at a later level (level 13
 has all three enemy types); `?charges=2` starts with 2 of every action charge, for trying them
-out. A URL with any of these parameters plays a test board: it doesn't load or overwrite your
+out; `?small`, `?dev`, `?god` and `?crumble` set the board size and modes. 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.
@@ -222,8 +240,7 @@ Art: [New Platformer Pack](https://kenney.nl/assets/new-platformer-pack) 1.1,
 [UI Pack: RPG Expansion](https://kenney.nl/assets/ui-pack-rpg-expansion), all by
 [Kenney](https://www.kenney.nl), under [CC0](http://creativecommons.org/publicdomain/zero/1.0/)
 (public domain). Kenney doesn't require credit, but the pack was chosen deliberately and the game
-looks the way it does because of it, so it is credited here and in the game (in the menu, and on
-the banner shown after each death). Thank you, Kenney! Support his work at <https://www.kenney.nl/donate>.
+looks the way it does because of it, so it is credited here and in the game (in the menu). Thank you, Kenney! Support his work at <https://www.kenney.nl/donate>.
 
 The files used are copied, unchanged and under their original file names, into
 [`assets/kenney/`](assets/kenney/), [`assets/kenney-ui/`](assets/kenney-ui/) (keeping the pack's
diff --git a/decisions.md b/decisions.md
index f8b18e3..de8f1ba 100644
--- a/decisions.md
+++ b/decisions.md
@@ -193,3 +193,47 @@ fix, two parts:
   README and the menu like the platformer pack. The action bar keeps its CSS-drawn buttons for
   now; they already follow the same look.
 
+### 2026-10-21 — Board sizes, Developer / God / crumbling-crates modes, player animation
+
+Decisions made with the user:
+
+- **Small board = 9 × 11, 20 levels**, fewer enemies (2 → 5) and crates (25%). 9 × 11 keeps the
+  NES pillar pattern and fits a 390-wide phone at about 43 px a tile.
+- **Developer and God are separate checkboxes** (the user split them mid-way): Developer gives
+  everything (every ability and pass, unlimited charges, 5 bombs, blast 5) but stays killable,
+  "mainly to test out all the possible actions and power-ups"; God makes the player unkillable.
+  They combine.
+- **Crumbling crates: each crate 1–3 turns**, seeded, once the last enemy dies. Not flame, so
+  nothing is punished.
+- **Options are chosen in a New-run dialog** and remembered for next time; not asked on the
+  very first launch.
+
+Made by me:
+
+- **Options are part of the run record and state** (`SIM_VERSION` 5), so replays and saves stay
+  exact; older saves aren't resumed.
+- **Developer charges are 99 that are never spent**, shown as ∞, rather than a special case in
+  every check.
+- **The new-run dialog replaces the separate "Start a new run?" confirmation**: it carries the
+  warning itself when a run is in progress.
+- **Player animation is drawn, not simulated:** the sim still moves the player instantly; the
+  renderer slides the sprite over 0.16 s on walking frames, crouches for a bomb, jumps on a
+  cleared level, bobs and glances at the camera when idle, shakes in the hit pose. Teleports
+  just appear. Enemies got the same hop afterwards (user request): each `enemyMoved` event
+  slides that enemy over the same 0.16 s.
+
+### 2026-10-21 — Playtest feedback: credit, wall info, drops, enemy hops
+
+- **The Kenney credit is only in the menu** (the user's "settings / about" screen), no longer on
+  the death / game-over banner.
+- **Wall info shows the exact tile**: the stone outer wall, or the pillar in this level's
+  colour, titled "Outer wall" or "Pillar".
+- **Drops never share a tile.** A drop whose tile is taken (a drop, a bomb, the uncovered
+  power-up) rolls to the nearest free floor tile within 2 steps, searching over floor only (not
+  through walls or crates), else stays. It changes where drops land, so `SIM_VERSION` is 6 (a
+  run saved while testing version 5 would not replay the same).
+- **Enemies hop between tiles** like the player.
+- **Enemies sharing a tile is unchanged** for now: the NES original let enemies pass through
+  each other (from memory, not verified). Listed as open question 10 in PLAN.md §14.
+- **Exclusive power-ups** are written up as an idea with its conflicts (PLAN.md §5b), not built.
+
diff --git a/docs/screenshots/auto-move.png b/docs/screenshots/auto-move.png
index 4b18454..ce373cf 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 8826509..2781573 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 7a0c39f..70222b6 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 72d3c92..06b899d 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 cffcc5d..44fc872 100644
Binary files a/docs/screenshots/death.png and b/docs/screenshots/death.png differ
diff --git a/docs/screenshots/desktop.png b/docs/screenshots/desktop.png
index e85a36b..47affca 100644
Binary files a/docs/screenshots/desktop.png and b/docs/screenshots/desktop.png differ
diff --git a/docs/screenshots/dev.png b/docs/screenshots/dev.png
new file mode 100644
index 0000000..d6007a1
Binary files /dev/null and b/docs/screenshots/dev.png differ
diff --git a/docs/screenshots/enemies.png b/docs/screenshots/enemies.png
index 3ebf454..74e9193 100644
Binary files a/docs/screenshots/enemies.png and b/docs/screenshots/enemies.png differ
diff --git a/docs/screenshots/info-enemy.png b/docs/screenshots/info-enemy.png
index 7c758dd..7b18125 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 03168b3..189e32c 100644
Binary files a/docs/screenshots/info-hud.png and b/docs/screenshots/info-hud.png differ
diff --git a/docs/screenshots/lingering.png b/docs/screenshots/lingering.png
index 6314908..1ca8059 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 963e501..dd650c1 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 a8706d0..fbf0b1d 100644
Binary files a/docs/screenshots/menu.png and b/docs/screenshots/menu.png differ
diff --git a/docs/screenshots/new-run.png b/docs/screenshots/new-run.png
index 9545bbe..4b25e7b 100644
Binary files a/docs/screenshots/new-run.png and b/docs/screenshots/new-run.png differ
diff --git a/docs/screenshots/pierce-decoy.png b/docs/screenshots/pierce-decoy.png
index ccf311b..3103de5 100644
Binary files a/docs/screenshots/pierce-decoy.png and b/docs/screenshots/pierce-decoy.png differ
diff --git a/docs/screenshots/power-up.png b/docs/screenshots/power-up.png
index c5d51fe..538df8c 100644
Binary files a/docs/screenshots/power-up.png and b/docs/screenshots/power-up.png differ
diff --git a/docs/screenshots/run.png b/docs/screenshots/run.png
index 81c9777..b48073b 100644
Binary files a/docs/screenshots/run.png and b/docs/screenshots/run.png differ
diff --git a/docs/screenshots/scrolled.png b/docs/screenshots/scrolled.png
index ae2178a..5df6f6d 100644
Binary files a/docs/screenshots/scrolled.png and b/docs/screenshots/scrolled.png differ
diff --git a/docs/screenshots/small-desktop.png b/docs/screenshots/small-desktop.png
new file mode 100644
index 0000000..1ea247b
Binary files /dev/null and b/docs/screenshots/small-desktop.png differ
diff --git a/docs/screenshots/small.png b/docs/screenshots/small.png
new file mode 100644
index 0000000..90c6a19
Binary files /dev/null and b/docs/screenshots/small.png differ
diff --git a/docs/screenshots/start.png b/docs/screenshots/start.png
index 4e47755..7473295 100644
Binary files a/docs/screenshots/start.png and b/docs/screenshots/start.png differ
diff --git a/index.html b/index.html
index c2d1a92..066474e 100644
--- a/index.html
+++ b/index.html
@@ -13,6 +13,7 @@
 </head>
 <body>
 <header id="hud" aria-live="polite">
+  <span id="hud-modes"></span>
   <span id="hud-lives"></span>
   <span id="hud-level"></span>
   <span id="hud-enemies"></span>
@@ -26,7 +27,6 @@
   <img id="banner-icon" alt="" />
   <p id="banner-text"></p>
   <button id="banner-button" type="button"></button>
-  <p class="credit">Art by <a href="https://www.kenney.nl" target="_blank" rel="noopener">Kenney</a> · New Platformer Pack, UI Pack, UI Pack: RPG Expansion (CC0)</p>
 </div>
 <dialog id="dialog" aria-labelledby="dialog-title">
   <div class="panel" tabindex="-1">
diff --git a/src/main.ts b/src/main.ts
index 66fb217..b783078 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -10,7 +10,7 @@ import {
   time,
   vec2,
 } from 'littlejsengine'
-import { BACKGROUND, drawBoard, FLAME_SECONDS, toTile, type Flame } from './render/board'
+import { BACKGROUND, drawBoard, FLAME_SECONDS, toTile, type Flame, type PlayerAnim } from './render/board'
 import { updateCamera } from './render/camera'
 import { TEXTURES } from './render/sprites'
 import { isAvailable, isValidTarget, ACTIONS } from './sim/actions'
@@ -20,10 +20,10 @@ import { predictDanger, type Danger } from './sim/danger'
 import { newRecord, replay, SIM_VERSION, type RunRecord } from './sim/record'
 import { bombAt, cellAt, eq, idx, inBounds, isBurning, newRun } from './sim/state'
 import { step } from './sim/turn'
-import { DIRS, type Action, type ActionId, type Dir, type GameState, type Vec } from './sim/types'
+import { DEFAULT_OPTIONS, DIRS, type Action, type ActionId, type Dir, type GameState, type RunOptions, type Vec } from './sim/types'
 import { createControls } from './ui/controls'
 import { dialogOpen, openDialog, type DialogButton } from './ui/dialog'
-import { hudInfo, tileInfo, type Info } from './ui/info'
+import { hudInfo, modeLines, tileInfo, type Info } from './ui/info'
 import { spriteUrl } from './render/sprites'
 import playIcon from '../assets/kenney-ui/Extra/icon_play_light.png'
 import newRunIcon from '../assets/kenney-ui/Extra/icon_repeat_light.png'
@@ -42,6 +42,8 @@ let selected: ActionId | null = null
 let hover: Vec | null = null
 let flames: Flame[] = []
 let facingLeft = false
+let playerAnim: PlayerAnim = { kind: 'none', from: { x: 0, y: 0 }, at: 0 }
+const enemySteps = new Map<number, { from: Vec; at: number }>()
 let snapCamera = true
 /** Auto-move: a far tile the player tapped. Each press of Move takes one step along `path` toward it. */
 let destination: Vec | null = null
@@ -52,17 +54,38 @@ let pressTimer: number | undefined
 let pressTile: Vec | null = null
 const LONG_PRESS_MS = 450
 
-function startRun(seed: number): void {
+function startRun(seed: number, options: RunOptions): void {
+  playerAnim = { kind: 'none', from: { x: 0, y: 0 }, at: 0 }
+  enemySteps.clear()
   selected = null
   destination = null
   flames = []
   snapCamera = true
-  record = newRecord(seed, startLevel, startCharges)
-  state = newRun(seed, startLevel, startCharges)
+  record = newRecord(seed, startLevel, startCharges, options)
+  state = newRun(seed, startLevel, startCharges, options)
   save()
   afterChange()
 }
 
+/** The board size and modes picked last time, the default for the next new run. */
+const OPTIONS_KEY = 'explodorama.newRunOptions'
+
+function preferredOptions(): RunOptions {
+  try {
+    return { ...DEFAULT_OPTIONS, ...(JSON.parse(localStorage.getItem(OPTIONS_KEY) ?? '{}') as Partial<RunOptions>) }
+  } catch {
+    return { ...DEFAULT_OPTIONS }
+  }
+}
+
+function rememberOptions(options: RunOptions): void {
+  try {
+    localStorage.setItem(OPTIONS_KEY, JSON.stringify(options))
+  } catch {
+    // No storage: the choice just isn't remembered.
+  }
+}
+
 /**
  * Mid-run save (PLAN.md §11): the run record goes to localStorage after every action, and is
  * replayed on the next visit. A URL that sets up a test board (`?seed`, `?level`, `?charges`)
@@ -70,7 +93,12 @@ function startRun(seed: number): void {
  * missing or full; then the game simply doesn't save.
  */
 const SAVE_KEY = 'explodorama.run'
-const testing = params.has('seed') || params.has('level') || params.has('charges')
+
+/** A test URL's options: `?small`, `?dev`, `?god`, `?crumble` (for screenshots and bug reports). */
+function urlOptions(): RunOptions {
+  return { size: params.has('small') ? 'small' : 'normal', dev: params.has('dev'), god: params.has('god'), crumble: params.has('crumble') }
+}
+const testing = ['seed', 'level', 'charges', 'small', 'dev', 'god', 'crumble'].some((p) => params.has(p))
 
 function save(): void {
   if (testing) return
@@ -115,6 +143,18 @@ function act(action: Action): void {
     if (e.type === 'levelRestarted' || e.type === 'levelStarted') snapCamera = true
     if (e.type === 'levelRestarted' || e.type === 'levelStarted' || e.type === 'levelCleared') destination = null
     if (e.type === 'moved' && e.to.x !== e.from.x) facingLeft = e.to.x < e.from.x
+    // A step or run slides; a teleport (further than a run) just appears.
+    if (e.type === 'moved') {
+      const far = Math.abs(e.to.x - e.from.x) + Math.abs(e.to.y - e.from.y) > 2
+      playerAnim = { kind: far ? 'none' : 'walk', from: e.from, at: time }
+    }
+    if (e.type === 'bombPlaced' && playerAnim.kind !== 'walk') playerAnim = { kind: 'duck', from: state.player, at: time }
+    if (e.type === 'levelCleared') playerAnim = { kind: 'jump', from: state.player, at: time }
+    if (e.type === 'levelStarted' || e.type === 'levelRestarted') {
+      playerAnim = { kind: 'none', from: state.player, at: time }
+      enemySteps.clear()
+    }
+    if (e.type === 'enemyMoved') enemySteps.set(e.id, { from: e.from, at: time })
   }
   selected = null
   save()
@@ -195,26 +235,59 @@ function showMenu(): void {
     title: 'Menu',
     body:
       `<p>${where}Seed ${state.seed}. The game saves after every move, so you can close it and come back.</p>` +
+      modeLines(state).map((l) => `<p class="mode">${l}</p>`).join('') +
       `<p class="credit">Art by <a href="https://www.kenney.nl" target="_blank" rel="noopener">Kenney</a>: ` +
       `New Platformer Pack, UI Pack and UI Pack: RPG Expansion (CC0). Thank you!</p>`,
     buttons: [
       { label: 'Resume', kind: 'primary', icon: playIcon },
-      { label: 'New run', kind: 'danger', icon: newRunIcon, onClick: confirmNewRun },
+      { label: 'New run', kind: 'danger', icon: newRunIcon, onClick: newRunDialog },
     ],
   })
 }
 
-function confirmNewRun(): void {
+/**
+ * The new-run dialog: board size and the mode checkboxes (remembered for next time). From the
+ * menu, while a run is still going, it also warns that the run will be lost.
+ */
+function newRunDialog(): void {
+  const o = preferredOptions()
+  const ongoing = state.status !== 'gameOver' && state.status !== 'won'
+  const checked = (on: boolean) => (on ? ' checked' : '')
+  const warning = ongoing
+    ? `<p class="warning">This run — level ${state.level}, ${state.coins} coins, ${state.lives} ${state.lives === 1 ? 'life' : 'lives'} — will be lost. This can’t be undone.</p>`
+    : ''
   openDialog({
-    title: 'Start a new run?',
-    body: `<p>This run — level ${state.level}, ${state.coins} coins, ${state.lives} ${state.lives === 1 ? 'life' : 'lives'} — will be lost. This can’t be undone.</p>`,
+    title: 'New run',
+    body:
+      warning +
+      `<fieldset class="choices"><legend>Board</legend>` +
+      `<label><input type="radio" name="size" value="normal"${checked(o.size === 'normal')}><span><b>Normal</b> 31 × 13, scrolls sideways, 50 levels.</span></label>` +
+      `<label><input type="radio" name="size" value="small"${checked(o.size === 'small')}><span><b>Small</b> 9 × 11, fits the screen, 20 levels, fewer enemies and crates.</span></label>` +
+      `</fieldset><fieldset class="choices"><legend>Modes</legend>` +
+      `<label><input type="checkbox" name="dev"${checked(o.dev)}><span><b>Developer</b> Every power-up and ability, unlimited action charges, 5 bombs, blast 5. You can still be killed.</span></label>` +
+      `<label><input type="checkbox" name="god"${checked(o.god)}><span><b>God</b> Nothing can kill you.</span></label>` +
+      `<label><input type="checkbox" name="crumble"${checked(o.crumble)}><span><b>Crumbling crates</b> Once the last enemy is gone, the crates crumble away over 1–3 turns.</span></label>` +
+      `</fieldset>`,
     buttons: [
-      { label: 'Start a new run', kind: 'danger', icon: newRunIcon, onClick: () => startRun(randomSeed()) },
-      { label: 'Keep playing', kind: 'plain' },
+      { label: 'Start', kind: ongoing ? 'danger' : 'primary', icon: newRunIcon, onClick: startChosenRun },
+      { label: ongoing ? 'Keep playing' : 'Cancel', kind: 'plain' },
     ],
   })
 }
 
+function startChosenRun(): void {
+  const form = document.getElementById('dialog-body')!
+  const on = (name: string) => form.querySelector<HTMLInputElement>(`input[name=${name}]`)!.checked
+  const options: RunOptions = {
+    size: form.querySelector<HTMLInputElement>('input[name=size]:checked')?.value === 'small' ? 'small' : 'normal',
+    dev: on('dev'),
+    god: on('god'),
+    crumble: on('crumble'),
+  }
+  rememberOptions(options)
+  startRun(randomSeed(), options)
+}
+
 /**
  * Whether auto-move may take this step: not onto an enemy, not onto the tile an enemy moves to
  * next, and not into flame this turn. Those would be deaths the player didn't choose.
@@ -257,7 +330,7 @@ const controls = createControls({
   pick,
   banner() {
     if (state.status === 'dead' || state.status === 'cleared') act({ id: 'continue' })
-    else if (state.status === 'gameOver' || state.status === 'won') startRun(randomSeed())
+    else if (state.status === 'gameOver' || state.status === 'won') newRunDialog()
   },
   info: (item) => showInfo(hudInfo(item, state)),
   menu: showMenu,
@@ -316,7 +389,7 @@ function tileUnder(e: PointerEvent): Vec | null {
   return inBounds(state, p) ? p : null
 }
 
-if (!resumeSaved()) startRun(Number(params.get('seed')) || randomSeed())
+if (!resumeSaved()) startRun(Number(params.get('seed')) || randomSeed(), testing ? urlOptions() : preferredOptions())
 
 // Sharp on high-density screens, capped so phones don't pay for 3×.
 setCanvasPixelRatio(Math.min(devicePixelRatio, 2))
@@ -367,7 +440,7 @@ await engineInit(
   () => {},
   () =>
     drawBoard({
-      state, danger, selected, hover, flames, facingLeft, destination, path, armed,
+      state, danger, selected, hover, flames, facingLeft, destination, path, armed, playerAnim, enemySteps,
       pathBlocked: !!path?.[0] && !safeStep(path[0]),
     }),
   () => {},
diff --git a/src/render/board.ts b/src/render/board.ts
index b00ca2f..dff6db7 100644
--- a/src/render/board.ts
+++ b/src/render/board.ts
@@ -5,7 +5,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 { CHARGE_ICONS, coinSprite, digitSprite, ENEMY_SPRITES, POWER_UP_ICONS, sprite, type BoardSprite } from './sprites'
+import { CHARGE_ICONS, coinSprite, digitSprite, ENEMY_SPRITES, POWER_UP_ICONS, sprite, wallSprite, type BoardSprite } from './sprites'
 
 /**
  * The one place where sim tiles meet LittleJS world space. The sim's y grows downward
@@ -41,8 +41,27 @@ export interface View {
   pathBlocked: boolean
   /** A tile a long press has armed for a bomb. */
   armed: Vec | null
+  /** The player's latest animation (main.ts starts one for a step, a bomb, a cleared level). */
+  playerAnim: PlayerAnim
+  /** Each enemy's latest step, by id: where from, and when (LittleJS `time`). */
+  enemySteps: ReadonlyMap<number, { from: Vec; at: number }>
 }
 
+/**
+ * walk: slides from `from` to the player's tile on walking frames. duck: crouches (placing a
+ * bomb). jump: jumps for joy (level cleared), until the next level. none: idle.
+ */
+export interface PlayerAnim {
+  kind: 'none' | 'walk' | 'duck' | 'jump'
+  from: Vec
+  /** LittleJS `time` when it started. */
+  at: number
+}
+
+/** Seconds a step's slide takes, and a crouch. */
+export const WALK_SECONDS = 0.16
+const DUCK_SECONDS = 0.22
+
 export const FLAME_SECONDS = 0.45
 
 const C = {
@@ -69,15 +88,13 @@ const C = {
 
 export const BACKGROUND: Color = C.sky
 
-/** Pillars change colour from level to level; the outer wall is always stone. */
-const PILLARS: BoardSprite[] = ['block_blue', 'block_green', 'block_red', 'block_yellow']
 
 export function drawBoard(view: View): void {
   const { state: s, danger, selected, hover } = view
   // A hair over a tile, so neighbouring walls overlap and no anti-aliased seam shows between them.
   const wallSize = vec2(1.02)
   const tileSize = vec2(1)
-  const pillar = sprite(PILLARS[(s.level - 1) % PILLARS.length]!)
+  const crumbling = new Map(s.crumbling.map((c) => [c.cell, c.turns]))
 
   for (let y = 0; y < s.height; y++) {
     for (let x = 0; x < s.width; x++) {
@@ -85,10 +102,16 @@ export function drawBoard(view: View): void {
       const pos = toWorld(s, p)
       const cell = s.cells[idx(s, p)]
       drawRect(pos, wallSize, (x + y) % 2 ? C.floorAlt : C.floor)
-      if (cell === 'wall') {
-        const edge = x === 0 || y === 0 || x === s.width - 1 || y === s.height - 1
-        drawTile(pos, wallSize, edge ? sprite('terrain_stone_block_center') : pillar)
-      } else if (cell === 'block') drawTile(pos, tileSize, sprite('block_planks'))
+      if (cell === 'wall') drawTile(pos, wallSize, sprite(wallSprite(s.level, s.width, s.height, p)))
+      else if (cell === 'block') {
+        // A crumbling crate shakes, harder and fainter the closer it is to gone.
+        const left = crumbling.get(idx(s, p))
+        if (left === undefined) drawTile(pos, tileSize, sprite('block_planks'))
+        else {
+          const shake = (0.05 / left) * Math.sin(time * 30 + x * 7 + y * 3)
+          drawTile(pos.add(vec2(shake, 0)), vec2(1 - 0.08 / left), sprite('block_planks'), C.white.scale(1, 0.45 + 0.18 * left), shake)
+        }
+      }
     }
   }
   if (cellAt(s, s.exit) === 'floor') drawExit(toWorld(s, s.exit), exitOpen(s))
@@ -158,7 +181,7 @@ export function drawBoard(view: View): void {
     drawTile(pos, vec2(0.62), sprite(d.kind === 'coins' ? coinSprite(d.amount) : CHARGE_ICONS[d.kind]))
   }
 
-  for (const e of s.enemies) drawEnemy(s, e)
+  for (const e of s.enemies) drawEnemy(s, e, view.enemySteps.get(e.id))
 
   if (s.shield) drawCircle(toWorld(s, s.player), 1.08, C.clear, 0.07, C.shield)
   // A Star: a pulsing gold ring for as long as it lasts.
@@ -166,8 +189,38 @@ export function drawBoard(view: View): void {
   if (after && !eq(after.player, s.player)) {
     drawTile(toWorld(s, after.player), tileSize, sprite('character_green_idle'), C.ghost, 0, view.facingLeft)
   }
-  const hit = s.status === 'dead' || s.status === 'gameOver'
-  drawTile(toWorld(s, s.player), tileSize, sprite(hit ? 'character_green_hit' : 'character_green_idle'), C.white, 0, view.facingLeft)
+  drawPlayer(s, view.playerAnim, view.facingLeft)
+}
+
+/**
+ * The player, animated like the enemies: an idle bob with a glance at the camera now and then,
+ * walking frames while sliding into a new tile, a crouch when placing a bomb, a jump when the
+ * level is cleared, the hit pose when caught.
+ */
+function drawPlayer(s: GameState, anim: PlayerAnim, facingLeft: boolean): void {
+  const to = toWorld(s, s.player)
+  const t = time - anim.at
+  let pos = to
+  let frame: BoardSprite = 'character_green_idle'
+  if (s.status === 'dead' || s.status === 'gameOver') {
+    frame = 'character_green_hit'
+    pos = to.add(vec2(0.03 * Math.sin(time * 40) * Math.max(0, 1 - t * 2), 0))
+  } else if (anim.kind === 'jump') {
+    frame = 'character_green_jump'
+    pos = to.add(vec2(0, 0.18 * Math.abs(Math.sin(time * 6))))
+  } else if (anim.kind === 'walk' && t < WALK_SECONDS) {
+    const k = t / WALK_SECONDS
+    const from = toWorld(s, anim.from)
+    pos = from.lerp(to, k).add(vec2(0, 0.08 * Math.sin(k * Math.PI)))
+    frame = k < 0.5 ? 'character_green_walk_a' : 'character_green_walk_b'
+  } else if (anim.kind === 'duck' && t < DUCK_SECONDS) {
+    frame = 'character_green_duck'
+  } else {
+    // Idle: a slow bob, and every 4 seconds a short look at the camera.
+    pos = to.add(vec2(0, 0.025 * Math.sin(time * 3)))
+    if (time % 4 > 3.4) frame = 'character_green_front'
+  }
+  drawTile(pos, vec2(1), sprite(frame), C.white, 0, facingLeft)
 }
 
 /** The pack's door is two tiles tall; it is drawn narrower so both halves fit about one tile. */
@@ -185,8 +238,10 @@ const PLAN_ALPHA = [0.85, 0.55, 0.3]
  * one per planned move, the next move at the top (the user's choice of layout, decisions.md).
  * The enemy is drawn a little smaller and to the left to make room.
  */
-function drawEnemy(s: GameState, e: Enemy): void {
-  const pos = toWorld(s, e.pos)
+function drawEnemy(s: GameState, e: Enemy, stepped?: { from: Vec; at: number }): void {
+  // A step just taken: hop from the old tile to the new one, like the player.
+  const k = stepped ? (time - stepped.at) / WALK_SECONDS : 1
+  const pos = k < 1 ? toWorld(s, stepped!.from).lerp(toWorld(s, e.pos), k).add(vec2(0, 0.08 * Math.sin(k * Math.PI))) : toWorld(s, e.pos)
   const art = ENEMY_SPRITES[e.kind]
   const moving = e.plan.length > 0
   const frame = !moving ? art.rest : Math.floor(time * 2.5 + e.id) % 2 ? art.a : art.b
diff --git a/src/render/camera.ts b/src/render/camera.ts
index ef9803c..6a1b9a5 100644
--- a/src/render/camera.ts
+++ b/src/render/camera.ts
@@ -11,14 +11,15 @@ let x: number | undefined
 
 /**
  * All rows always fit between the HUD and the action bar. Horizontally the camera follows the
- * player, eased, and stops at the level's edges, as in the original (PLAN.md §11).
+ * player, eased, and stops at the level's edges, as in the original (PLAN.md §11). A small
+ * board is shown whole instead: its columns fit too, so there is no scrolling.
  * `snap` jumps straight there, for the first frame of a level.
  */
 export function updateCamera(s: GameState, insets: Insets, snap = false): void {
   const width = mainCanvasSize.x
   const height = mainCanvasSize.y
   const available = Math.max(1, height - insets.top - insets.bottom)
-  const scale = available / s.height
+  const scale = s.options.size === 'small' ? Math.min(available / s.height, width / s.width) : available / s.height
   setCameraScale(scale)
 
   const halfView = width / 2 / scale
diff --git a/src/render/sprites.ts b/src/render/sprites.ts
index 52c3939..bf43d52 100644
--- a/src/render/sprites.ts
+++ b/src/render/sprites.ts
@@ -57,6 +57,10 @@ const BOARD = [
   'bridge_logs',
   'hud_player_helmet_green',
   'character_green_front',
+  'character_green_walk_a',
+  'character_green_walk_b',
+  'character_green_duck',
+  'character_green_jump',
   'gem_blue',
   'gem_red',
   'gem_yellow',
@@ -97,6 +101,15 @@ export function digitSprite(d: number): TileInfo {
   return sprite(`hud_character_${d}` as BoardSprite)
 }
 
+/** Pillars change colour from level to level; the outer wall is always stone. */
+const PILLARS: BoardSprite[] = ['block_blue', 'block_green', 'block_red', 'block_yellow']
+
+/** The sprite of a wall tile: stone on the level's edge, otherwise the level's pillar colour. */
+export function wallSprite(level: number, width: number, height: number, p: { x: number; y: number }): BoardSprite {
+  const edge = p.x === 0 || p.y === 0 || p.x === width - 1 || p.y === height - 1
+  return edge ? 'terrain_stone_block_center' : PILLARS[(level - 1) % PILLARS.length]!
+}
+
 /** The icon for each action charge, on the board (as a drop) and on its button. */
 export const CHARGE_ICONS: Record<ChargeId, BoardSprite> = {
   kick: 'spring_out',
diff --git a/src/sim/levelgen.ts b/src/sim/levelgen.ts
index 79c6d4f..8d0a224 100644
--- a/src/sim/levelgen.ts
+++ b/src/sim/levelgen.ts
@@ -1,5 +1,5 @@
 import { hashSeed, stream, type Rng } from './rng'
-import { DIR_LIST, type Cell, type Dir, type EnemyKind, type PowerUp, type PowerUpKind, type Vec } from './types'
+import { DIR_LIST, type BoardSize, type Cell, type Dir, type EnemyKind, type PowerUp, type PowerUpKind, type Vec } from './types'
 
 /** The NES layout: 31 × 13 with a wall all round and a pillar at every even (x, y). */
 export const LEVEL_WIDTH = 31
@@ -19,18 +19,48 @@ export function enemyKinds(level: number): EnemyKind[] {
   return all.slice(0, 1 + Math.floor((level - 1) / 6))
 }
 
-/** 3 on level 1, one more every 3 levels, at most 10. To tune by playtesting. */
-export function enemyCount(level: number): number {
-  return Math.min(10, 3 + Math.floor((level - 1) / 3))
+/** What differs between board sizes. To tune by playtesting. */
+interface SizeRules {
+  width: number
+  height: number
+  /** Share of the open tiles that get a soft block. */
+  density: number
+  /** The run's last level; clearing it wins (PLAN.md §11). */
+  lastLevel: number
+  enemies(level: number): number
+  /** The turn budget for the par bonus. */
+  par(enemies: number): number
 }
 
-/** The turn budget for the par bonus: 50 plus 10 per enemy. To tune by playtesting. */
-export function parFor(enemies: number): number {
-  return 50 + 10 * enemies
+export const SIZES: Record<BoardSize, SizeRules> = {
+  // 3 enemies on level 1, one more every 3 levels, at most 10; par 50 + 10 per enemy.
+  normal: {
+    width: LEVEL_WIDTH, height: LEVEL_HEIGHT, density: BLOCK_DENSITY, lastLevel: 50,
+    enemies: (level) => Math.min(10, 3 + Math.floor((level - 1) / 3)),
+    par: (enemies) => 50 + 10 * enemies,
+  },
+  // Fits a phone screen whole. 2 enemies on level 1, one more every 4 levels, at most 5.
+  small: {
+    width: 9, height: 11, density: 0.25, lastLevel: 20,
+    enemies: (level) => Math.min(5, 2 + Math.floor((level - 1) / 4)),
+    par: (enemies) => 25 + 8 * enemies,
+  },
 }
 
-/** The run's last level; clearing it wins (PLAN.md §11). */
-export const LAST_LEVEL = 50
+export function enemyCount(level: number, size: BoardSize = 'normal'): number {
+  return SIZES[size].enemies(level)
+}
+
+export function parFor(enemies: number, size: BoardSize = 'normal'): number {
+  return SIZES[size].par(enemies)
+}
+
+/** The normal run's last level. */
+export const LAST_LEVEL = SIZES.normal.lastLevel
+
+export function lastLevel(size: BoardSize): number {
+  return SIZES[size].lastLevel
+}
 
 /**
  * How likely each power-up is to be a level's hidden one. Skates aren't in the draw: they are
@@ -72,9 +102,8 @@ export interface Layout {
   par: number
 }
 
-export function generateLevel(seed: number, level: number): Layout {
-  const width = LEVEL_WIDTH
-  const height = LEVEL_HEIGHT
+export function generateLevel(seed: number, level: number, size: BoardSize = 'normal'): Layout {
+  const { width, height, density } = SIZES[size]
   const rng = stream(seed, 'levelgen', level)
   const cells: Cell[] = []
   for (let y = 0; y < height; y++) {
@@ -90,26 +119,32 @@ export function generateLevel(seed: number, level: number): Layout {
   for (let y = 0; y < height; y++) {
     for (let x = 0; x < width; x++) {
       const i = y * width + x
-      if (cells[i] === 'floor' && !clear.has(key({ x, y })) && rng.next() < BLOCK_DENSITY) cells[i] = 'block'
+      if (cells[i] === 'floor' && !clear.has(key({ x, y })) && rng.next() < density) cells[i] = 'block'
     }
   }
 
   const far = (i: number) => Math.abs((i % width) - START.x) + Math.abs(Math.floor(i / width) - START.y) > SAFE_DISTANCE
   const at = (i: number): Vec => ({ x: i % width, y: Math.floor(i / width) })
+  // A small board can, rarely, have too few crates far enough away for the exit and the
+  // power-up: turn far floor tiles into crates until there are two.
+  for (let n = cells.filter((c, i) => c === 'block' && far(i)).length; n < 2; n++) {
+    const floors = cells.flatMap((c, i) => (c === 'floor' && far(i) ? [i] : []))
+    cells[floors[rng.int(floors.length)]!] = 'block'
+  }
   const blocks = cells.flatMap((c, i) => (c === 'block' && far(i) ? [i] : []))
   const exit = at(blocks.splice(rng.int(blocks.length), 1)[0]!)
 
   const open = cells.flatMap((c, i) => (c === 'floor' && far(i) ? [i] : []))
   const kinds = enemyKinds(level)
   const enemies: EnemyStart[] = []
-  for (let n = enemyCount(level); n > 0 && open.length; n--) {
+  for (let n = enemyCount(level, size); n > 0 && open.length; n--) {
     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)]! })
   }
   // Any other block, near the start or not, so an early power-up 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)]!), kind: pickPowerUp(seed, level, rng) }
-  return { width, height, cells, start: { ...START }, exit, powerUp, enemies, par: parFor(enemies.length) }
+  return { width, height, cells, start: { ...START }, exit, powerUp, enemies, par: parFor(enemies.length, size) }
 }
 
 function key(p: Vec): string {
diff --git a/src/sim/record.ts b/src/sim/record.ts
index 53faefc..3ca2d50 100644
--- a/src/sim/record.ts
+++ b/src/sim/record.ts
@@ -1,12 +1,12 @@
 import { newRun } from './state'
 import { step } from './turn'
-import type { Action, GameState } from './types'
+import { DEFAULT_OPTIONS, type Action, type GameState, type RunOptions } from './types'
 
 /**
  * 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 = 4
+export const SIM_VERSION = 6
 
 /** Everything needed to reproduce a run exactly: mid-run saves, replays, certificates. */
 export interface RunRecord {
@@ -15,15 +15,17 @@ export interface RunRecord {
   startLevel: number
   /** Action charges of each kind given at the start (testing aid; 0 in a normal run). */
   startCharges: number
+  /** Board size and modes, chosen when the run started. */
+  options: RunOptions
   actions: Action[]
 }
 
-export function newRecord(seed: number, startLevel = 1, startCharges = 0): RunRecord {
-  return { simVersion: SIM_VERSION, seed, startLevel, startCharges, actions: [] }
+export function newRecord(seed: number, startLevel = 1, startCharges = 0, options: RunOptions = DEFAULT_OPTIONS): RunRecord {
+  return { simVersion: SIM_VERSION, seed, startLevel, startCharges, options: { ...options }, actions: [] }
 }
 
 export function replay(record: RunRecord): GameState {
-  let s = newRun(record.seed, record.startLevel, record.startCharges)
+  let s = newRun(record.seed, record.startLevel, record.startCharges, record.options)
   for (const a of record.actions) s = step(s, a).state
   return s
 }
diff --git a/src/sim/state.ts b/src/sim/state.ts
index 08a3150..eea7958 100644
--- a/src/sim/state.ts
+++ b/src/sim/state.ts
@@ -1,7 +1,7 @@
 import { planIntents } from './enemies'
 import { generateLevel } from './levelgen'
 import { hashSeed } from './rng'
-import { CHARGE_IDS, type Bomb, type Cell, type ChargeId, type GameState, type Powers, type Stats, type Vec } from './types'
+import { CHARGE_IDS, DEFAULT_OPTIONS, type Bomb, type Cell, type ChargeId, type GameState, type Powers, type RunOptions, type Stats, type Vec } from './types'
 
 export const START_LIVES = 3
 export const START_STATS: Stats = { capacity: 1, fire: 1, fuse: 3 }
@@ -34,9 +34,13 @@ export function noCharges(): Record<ChargeId, number> {
   return charges(0)
 }
 
+/** Developer mode's stats, and its charges (shown as unlimited; never spent). */
+export const DEV_STATS: Stats = { capacity: 5, fire: 5, fuse: 3 }
+export const DEV_CHARGES = 99
+
 /** `n`: start with this many of every action charge (a testing aid, `?charges=` in the URL). */
-export function newRun(seed: number, level = 1, n = 0): GameState {
-  return createLevel(seed, level, {
+export function newRun(seed: number, level = 1, n = 0, options: RunOptions = DEFAULT_OPTIONS): GameState {
+  return createLevel(seed, level, options, {
     lives: START_LIVES, stats: { ...START_STATS }, coins: 0, charges: charges(n), powers: noPowers(), star: 0,
     runTurns: 0, deaths: 0,
   })
@@ -46,18 +50,24 @@ export function newRun(seed: number, level = 1, n = 0): GameState {
  * A fresh attempt at a level: the layout and enemies come from the seed, so a retry after a death
  * is the same board. What the player has comes with them (`carryOf` decides what a death costs).
  */
-export function createLevel(seed: number, level: number, carry: Carry): GameState {
-  const { width, height, cells, start, exit, powerUp, enemies, par } = generateLevel(seed, level)
+export function createLevel(seed: number, level: number, options: RunOptions, carry: Carry): GameState {
+  const { width, height, cells, start, exit, powerUp, enemies, par } = generateLevel(seed, level, options.size)
   const s: GameState = {
-    seed, level, turn: 0, width, height, cells, player: start,
+    seed, options: { ...options }, level, turn: 0, width, height, cells, player: start,
     lives: carry.lives, stats: { ...carry.stats }, coins: carry.coins, charges: { ...carry.charges },
     bombs: [], status: 'playing',
     enemies: enemies.map((e, id) => ({ id, kind: e.kind, pos: e.pos, dir: e.dir, plan: [], spawned: false })),
     nextEnemyId: enemies.length,
     drops: [], exit, par, shield: false, frozen: false, decoy: null, burning: [],
-    powers: { ...carry.powers }, star: carry.star, powerUp, runTurns: carry.runTurns, deaths: carry.deaths,
+    powers: { ...carry.powers }, star: carry.star, powerUp, runTurns: carry.runTurns, deaths: carry.deaths, crumbling: [],
     aiRng: hashSeed(seed, 'ai', level), dropRng: hashSeed(seed, 'drops', level),
   }
+  // Developer mode: everything, every level and after every death.
+  if (options.dev) {
+    s.powers = { run: true, detonate: true, wallPass: true, bombPass: true, flamePass: true }
+    s.stats = { ...DEV_STATS, fuse: Math.max(DEV_STATS.fuse, s.stats.fuse) }
+    s.charges = charges(DEV_CHARGES)
+  }
   planIntents(s)
   return s
 }
diff --git a/src/sim/turn.ts b/src/sim/turn.ts
index 8551e3c..2e6b6f0 100644
--- a/src/sim/turn.ts
+++ b/src/sim/turn.ts
@@ -1,9 +1,10 @@
 import { ACTIONS, chargeOf, isAvailable, isValidTarget } from './actions'
 import { enemyAt, moveEnemies, planIntents, rollDrops } from './enemies'
-import { enemyKinds, LAST_LEVEL } from './levelgen'
+import { enemyKinds, lastLevel } from './levelgen'
 import { detonate, tickBombs } from './explosions'
-import { add, carryOf, cellAt, cloneState, createLevel, eq, exitOpen, idx, PAR_BONUS } from './state'
-import type { Action, GameEvent, GameState, PowerUpKind, Vec } from './types'
+import { add, bombAt, carryOf, cellAt, cloneState, createLevel, eq, exitOpen, idx, PAR_BONUS } from './state'
+import { stream } from './rng'
+import { DIRS, DIR_LIST, type Action, type GameEvent, type GameState, type PowerUpKind, type Vec } from './types'
 
 export interface StepResult {
   state: GameState
@@ -28,11 +29,11 @@ export function step(prev: GameState, action: Action): StepResult {
   if (action.id === 'continue') {
     if (s.status === 'dead') {
       events.push({ type: 'levelRestarted' })
-      return { state: createLevel(s.seed, s.level, carryOf(s, true)), events }
+      return { state: createLevel(s.seed, s.level, s.options, carryOf(s, true)), events }
     }
     if (s.status === 'cleared') {
       events.push({ type: 'levelStarted', level: s.level + 1 })
-      return { state: createLevel(s.seed, s.level + 1, carryOf(s)), events }
+      return { state: createLevel(s.seed, s.level + 1, s.options, carryOf(s)), events }
     }
     throw new Error(`continue while ${s.status}`)
   }
@@ -45,7 +46,7 @@ export function step(prev: GameState, action: Action): StepResult {
   }
   const charge = chargeOf(action.id)
   if (charge) {
-    s.charges[charge]--
+    if (!s.options.dev) s.charges[charge]--
     events.push({ type: 'chargeUsed', id: charge })
   }
   const from = s.player
@@ -60,7 +61,7 @@ export function step(prev: GameState, action: Action): StepResult {
     s.turn++
     s.runTurns++
     events.push({ type: 'levelCleared', level: s.level, turns: s.turn, parBonus })
-    if (s.level >= LAST_LEVEL) {
+    if (s.level >= lastLevel(s.options.size)) {
       s.status = 'won'
       events.push({ type: 'runWon', turns: s.runTurns, deaths: s.deaths })
     } else s.status = 'cleared'
@@ -71,7 +72,7 @@ export function step(prev: GameState, action: Action): StepResult {
   // Flame Pass stops flame.
   let shielded = false
   const hurt = (cause: 'flame' | 'enemy'): void => {
-    if (shielded || s.star > 0 || (cause === 'flame' && s.powers.flamePass)) return
+    if (s.options.god || shielded || s.star > 0 || (cause === 'flame' && s.powers.flamePass)) return
     if (s.shield) {
       s.shield = false
       shielded = true
@@ -100,6 +101,7 @@ export function step(prev: GameState, action: Action): StepResult {
   const enemiesBefore = s.enemies.length
   tickBombs(s, events)
   const burned = detonate(s, events)
+  crumble(s, events)
 
   // Phase 4: damage. New flame and lingering flame both burn creatures: the player, enemies and
   // the decoy. Only new flame burns drops lying in the open; slain enemies then leave their own
@@ -114,6 +116,7 @@ export function step(prev: GameState, action: Action): StepResult {
     s.enemies.splice(s.enemies.indexOf(e), 1)
     events.push({ type: 'enemyDied', id: e.id, pos: e.pos })
     for (const drop of rollDrops(s, e)) {
+      drop.pos = dropSpot(s, e.pos)
       s.drops.push(drop)
       events.push({ type: 'dropSpawned', drop })
     }
@@ -136,7 +139,10 @@ export function step(prev: GameState, action: Action): StepResult {
       events.push({ type: 'powerUpRevealed', powerUp: s.powerUp })
     }
   }
-  if (enemiesBefore > 0 && s.enemies.length === 0) events.push({ type: 'exitOpened', pos: s.exit })
+  if (enemiesBefore > 0 && s.enemies.length === 0) {
+    events.push({ type: 'exitOpened', pos: s.exit })
+    if (s.options.crumble) startCrumbling(s, events)
+  }
 
   // This turn's flame lingers through the next turn; last turn's goes out.
   s.burning = [...burned]
@@ -154,6 +160,60 @@ export function step(prev: GameState, action: Action): StepResult {
   return { state: s, events }
 }
 
+/** How far (in steps) a drop may roll from where the enemy died to find a tile of its own. */
+export const DROP_SPREAD = 2
+
+/**
+ * Where a new drop lands: the enemy's tile if nothing lies there yet, otherwise the nearest
+ * open floor tile (within DROP_SPREAD steps, never through walls or crates) without a drop, a
+ * bomb or the uncovered power-up — so drops never pile up on one tile. If there is none, it
+ * stays on the enemy's tile after all.
+ */
+function dropSpot(s: GameState, from: Vec): Vec {
+  const taken = (p: Vec) =>
+    s.drops.some((d) => eq(d.pos, p)) || !!bombAt(s, p) || (!!s.powerUp && eq(s.powerUp.pos, p) && cellAt(s, p) === 'floor')
+  const seen = new Set([idx(s, from)])
+  let ring = [from]
+  for (let dist = 0; dist <= DROP_SPREAD && ring.length; dist++) {
+    const free = ring.find((p) => !taken(p))
+    if (free) return { ...free }
+    const next: Vec[] = []
+    for (const p of ring) {
+      for (const d of DIR_LIST) {
+        const q = add(p, DIRS[d])
+        if (seen.has(idx(s, q)) || cellAt(s, q) !== 'floor') continue
+        seen.add(idx(s, q))
+        next.push(q)
+      }
+    }
+    ring = next
+  }
+  return { ...from }
+}
+
+/** The crumble option: once the last enemy is gone, every crate gets 1–3 turns before it goes. */
+function startCrumbling(s: GameState, events: GameEvent[]): void {
+  if (s.crumbling.length) return
+  const rng = stream(s.seed, 'crumble', s.level)
+  s.cells.forEach((c, cell) => {
+    if (c === 'block') s.crumbling.push({ cell, turns: 1 + rng.int(3) })
+  })
+  events.push({ type: 'cratesCrumbling', count: s.crumbling.length })
+}
+
+/**
+ * Phase 3, after the blasts: crumbling crates count down, and those at 0 are gone. It is not
+ * flame: nothing burns, nothing is punished, and an exit or power-up under one is simply revealed.
+ */
+function crumble(s: GameState, events: GameEvent[]): void {
+  for (const c of s.crumbling) {
+    if (--c.turns > 0 || s.cells[c.cell] !== 'block') continue
+    s.cells[c.cell] = 'floor'
+    events.push({ type: 'crateCrumbled', pos: { x: c.cell % s.width, y: Math.floor(c.cell / s.width) } })
+  }
+  s.crumbling = s.crumbling.filter((c) => c.turns > 0)
+}
+
 /** A group of the level's toughest enemy type comes out at `pos` (NES rule, PLAN.md §5). They drop nothing. */
 function spawnPunishment(s: GameState, pos: Vec, events: GameEvent[]): void {
   const kinds = enemyKinds(s.level)
diff --git a/src/sim/types.ts b/src/sim/types.ts
index ebe23bf..d105d93 100644
--- a/src/sim/types.ts
+++ b/src/sim/types.ts
@@ -50,6 +50,23 @@ export interface Stats {
  */
 export type Status = 'playing' | 'dead' | 'gameOver' | 'cleared' | 'won'
 
+/** The board size a run is played on (user request, 2026-10-21). */
+export type BoardSize = 'normal' | 'small'
+
+/** Chosen when a run starts, fixed for the run, part of its record. */
+export interface RunOptions {
+  /** normal: 31 × 13, 50 levels. small: 9 × 11, 20 levels, fewer enemies and crates. */
+  size: BoardSize
+  /** Developer mode: every ability and pass, unlimited charges, 5 bombs, blast 5 — for trying things. Still killable. */
+  dev: boolean
+  /** God mode: nothing can kill the player. */
+  god: boolean
+  /** Once the last enemy dies, every crate crumbles away over 1–3 turns. */
+  crumble: boolean
+}
+
+export const DEFAULT_OPTIONS: RunOptions = { size: 'normal', dev: false, god: false, crumble: false }
+
 /** The power-ups hidden one per level under a soft block (PLAN.md §5, after the NES eight). */
 export type PowerUpKind =
   | 'bombPlus' | 'firePlus' | 'fusePlus' | 'skates' | 'wallPass' | 'bombPass' | 'flamePass' | 'detonator'
@@ -116,6 +133,7 @@ export type Drop = { pos: Vec; kind: 'coins'; amount: number } | { pos: Vec; kin
 
 export interface GameState {
   seed: number
+  options: RunOptions
   level: number
   /** Turns taken on this attempt at this level, starting at 0. */
   turn: number
@@ -155,6 +173,8 @@ export interface GameState {
    * a bomb there. It doesn't burn crates, drops or bombs a second time.
    */
   burning: number[]
+  /** Crumbling crates (the crumble option): tile index and turns until it is gone. */
+  crumbling: { cell: number; turns: number }[]
   decoy: Decoy | null
   /** The AI and drop random streams' positions (`Rng.state`), so a turn is a pure function of the state. */
   aiRng: number
@@ -197,3 +217,5 @@ export type GameEvent =
   | { type: 'powerUpTaken'; powerUp: PowerUp }
   | { type: 'detonated'; pos: Vec }
   | { type: 'runWon'; turns: number; deaths: number }
+  | { type: 'cratesCrumbling'; count: number }
+  | { type: 'crateCrumbled'; pos: Vec }
diff --git a/src/style.css b/src/style.css
index 61e9f97..bb2d00f 100644
--- a/src/style.css
+++ b/src/style.css
@@ -85,6 +85,22 @@ img {
   margin-left: 10px;
 }
 
+#hud .tag {
+  padding: 1px 6px;
+  border-radius: 6px;
+  background: #ffc83d;
+  color: var(--ink);
+  font: 11px 'Kenney Future', ui-rounded, system-ui, sans-serif;
+}
+
+#hud .tag + .tag {
+  margin-left: 3px;
+}
+
+#hud-modes:empty {
+  display: none;
+}
+
 #hud small.par {
   margin: 0 0 0 6px;
   font-size: 12px;
@@ -257,16 +273,6 @@ img {
   font-weight: 800;
 }
 
-#banner .credit {
-  font-size: 13px;
-  font-weight: 600;
-  opacity: 0.85;
-}
-
-#banner .credit a {
-  color: inherit;
-}
-
 #banner button {
   min-height: 56px;
   padding: 0 32px;
@@ -382,6 +388,8 @@ img {
 }
 
 #dialog-body {
+  max-height: calc(100vh - 260px);
+  overflow-y: auto;
   font-size: 15px;
   line-height: 1.4;
 }
@@ -446,3 +454,67 @@ img {
 .ui-button img {
   height: 20px;
 }
+
+/* The new-run dialog's choices. */
+#dialog-body fieldset.choices {
+  display: grid;
+  gap: 8px;
+  margin: 0 0 10px;
+  padding: 8px 10px 10px;
+  border: 2px solid #d9cba8;
+  border-radius: 10px;
+  text-align: left;
+}
+
+#dialog-body legend {
+  padding: 0 4px;
+  font: 13px 'Kenney Future', ui-rounded, system-ui, sans-serif;
+}
+
+#dialog-body .choices label {
+  display: flex;
+  align-items: flex-start;
+  gap: 8px;
+  font-size: 14px;
+  line-height: 1.3;
+}
+
+#dialog-body .choices input {
+  flex: none;
+  width: 20px;
+  height: 20px;
+  margin: 0;
+  accent-color: var(--accent);
+}
+
+#dialog-body .choices b {
+  display: block;
+}
+
+#dialog-body .warning {
+  color: #b3122e;
+  font-weight: 700;
+}
+
+#dialog-body .mode {
+  font-size: 13px;
+  text-align: left;
+}
+
+/* Lots of charges / abilities (Developer mode): five to a row, shorter, smaller icons. */
+#actions.many button.charge {
+  flex-basis: calc(20% - 7px);
+  min-height: 34px;
+  margin-top: 4px;
+  font-size: 11px;
+}
+
+#actions.many button.charge .icon {
+  width: 20px;
+  height: 20px;
+}
+
+#actions.many .count {
+  top: -8px;
+  font-size: 10px;
+}
diff --git a/src/ui/controls.ts b/src/ui/controls.ts
index 53e3d8f..7897099 100644
--- a/src/ui/controls.ts
+++ b/src/ui/controls.ts
@@ -86,6 +86,7 @@ export function createControls(handlers: Handlers) {
     enemies: document.getElementById('hud-enemies')!,
     coins: document.getElementById('hud-coins')!,
     stats: document.getElementById('hud-stats')!,
+    modes: document.getElementById('hud-modes')!,
   }
 
   return {
@@ -106,13 +107,28 @@ export function createControls(handlers: Handlers) {
         const charge = chargeOf(id)
         if (charge) {
           b.hidden = s.charges[charge] === 0
-          b.querySelector('.count')!.textContent = `×${s.charges[charge]}`
+          // Developer mode never spends them.
+          b.querySelector('.count')!.textContent = s.options.dev ? '∞' : `×${s.charges[charge]}`
         }
       }
+      // Many second-row buttons (Developer mode has 13): pack them tighter so the board keeps its room.
+      const extra = [...buttons.values()].filter((b) => b.classList.contains('charge') && !b.hidden).length
+      bar.classList.toggle('many', extra > 8)
+
       // Lives lost from the starting three show as empty hearts; extra lives add full ones.
       const hearts = Array.from({ length: Math.max(s.lives, START_LIVES) }, (_, i) =>
         icon(i < s.lives ? 'hud_heart' : 'hud_heart_empty'),
       )
+      // The run's settings, as small tags, when it isn't a plain normal run.
+      const tags = [
+        s.options.size === 'small' && 'Small',
+        s.options.dev && 'Dev',
+        s.options.god && 'God',
+        s.options.crumble && 'Crumble',
+      ].filter(Boolean)
+      hud.modes.innerHTML = tags.length
+        ? item('modes', tags.map((t) => `<span class="tag">${t}</span>`).join(''))
+        : ''
       hud.lives.innerHTML = item('lives', `<span class="sr-only">${s.lives} lives</span>${hearts.join('')}`)
       hud.level.innerHTML = item('level', icon('flag_green_a') + number(s.level, 'Level '))
       hud.enemies.innerHTML = item('enemies', icon('slime_normal_rest') + number(s.enemies.length, 'Enemies left '))
diff --git a/src/ui/info.ts b/src/ui/info.ts
index c584a52..34a7044 100644
--- a/src/ui/info.ts
+++ b/src/ui/info.ts
@@ -1,10 +1,10 @@
 import { DECOY_TURNS, TELEPORT_RANGE } from '../sim/actions'
-import { LAST_LEVEL } from '../sim/levelgen'
+import { lastLevel } from '../sim/levelgen'
 import { bombAt, cellAt, eq, exitOpen, isBurning, PAR_BONUS } from '../sim/state'
 import { COIN_BAG, EXIT_SPAWN, STAR_TURNS } from '../sim/turn'
 import type { ChargeId, Dir, EnemyKind, GameState, Powers, PowerUpKind, Vec } from '../sim/types'
 import { dirBetween } from '../sim/enemies'
-import { CHARGE_ICONS, coinSprite, ENEMY_SPRITES, POWER_UP_ICONS, spriteUrl } from '../render/sprites'
+import { CHARGE_ICONS, coinSprite, ENEMY_SPRITES, POWER_UP_ICONS, spriteUrl, wallSprite } from '../render/sprites'
 
 /**
  * What the game says about each thing on the board and in the top bar, for the info dialog
@@ -56,7 +56,16 @@ const ARROWS: Record<Dir, string> = { up: '↑', down: '↓', left: '←', right
 const PUNISH = `Don’t blast it once it’s uncovered: flame on it lets out ${EXIT_SPAWN} more enemies.`
 
 /** The top bar's items. */
-export type HudItem = 'lives' | 'level' | 'enemies' | 'coins' | 'turn' | 'bombs' | 'fire' | 'fuse' | 'star' | keyof Powers
+export type HudItem = 'modes' | 'lives' | 'level' | 'enemies' | 'coins' | 'turn' | 'bombs' | 'fire' | 'fuse' | 'star' | keyof Powers
+
+/** One line per setting of the run that differs from a plain normal run. */
+export function modeLines(s: GameState): string[] {
+  const lines = [s.options.size === 'small' ? `Small board: ${s.width} × ${s.height}, ${lastLevel('small')} levels, fewer enemies and crates.` : `Normal board: ${s.width} × ${s.height}, ${lastLevel('normal')} levels.`]
+  if (s.options.dev) lines.push('Developer mode: every power-up and ability, unlimited action charges, 5 bombs, blast 5. You can still be killed.')
+  if (s.options.god) lines.push('God mode: nothing can kill you.')
+  if (s.options.crumble) lines.push('Crumbling crates: once the last enemy is gone, every crate crumbles away over 1–3 turns.')
+  return lines
+}
 
 const PASS_KIND: Record<keyof Powers, PowerUpKind> = {
   run: 'skates', detonate: 'detonator', wallPass: 'wallPass', bombPass: 'bombPass', flamePass: 'flamePass',
@@ -67,14 +76,20 @@ export function hudInfo(item: HudItem, s: GameState): Info {
     case 'lives':
       return {
         icon: 'hud_heart',
-        title: `Lives: ${s.lives}`,
+        title: s.options.god ? `Lives: ${s.lives} (God mode)` : `Lives: ${s.lives}`,
         body: [
           'Caught by flame or touched by an enemy, you lose a life and the level starts again, same layout and enemies.',
-          'With no lives left, the run is over.',
+          s.options.god ? 'In God mode nothing can kill you, so you never lose one.' : 'With no lives left, the run is over.',
         ],
       }
+    case 'modes':
+      return { icon: 'lever', title: 'Run settings', body: modeLines(s) }
     case 'level':
-      return { icon: 'flag_green_a', title: `Level ${s.level} of ${LAST_LEVEL}`, body: [`Clear all ${LAST_LEVEL} levels to win the run. New enemy types join every few levels.`] }
+      return {
+        icon: 'flag_green_a',
+        title: `Level ${s.level} of ${lastLevel(s.options.size)}`,
+        body: [`Clear all ${lastLevel(s.options.size)} levels to win the run. New enemy types join every few levels.`],
+      }
     case 'enemies':
       return {
         icon: 'slime_normal_rest',
@@ -166,7 +181,15 @@ export function tileInfo(s: GameState, p: Vec): Info | null {
     }
   }
   const cell = cellAt(s, p)
-  if (cell === 'wall') return { icon: 'block_blue', title: 'Wall', body: ['Indestructible: nothing gets through, not even flame.'] }
+  if (cell === 'wall') {
+    const icon = wallSprite(s.level, s.width, s.height, p)
+    const pillar = icon !== 'terrain_stone_block_center'
+    return {
+      icon,
+      title: pillar ? 'Pillar' : 'Outer wall',
+      body: [`Indestructible: nothing gets through, not even flame.${pillar ? ' Pillars change colour from level to level.' : ''}`],
+    }
+  }
   if (cell === 'block') {
     return {
       icon: 'block_planks',
diff --git a/test/enemies.test.ts b/test/enemies.test.ts
index b51bcef..cd6d4e6 100644
--- a/test/enemies.test.ts
+++ b/test/enemies.test.ts
@@ -287,6 +287,47 @@ describe('drops', () => {
   })
 })
 
+describe('drops never share a tile', () => {
+  it('a second drop rolls to the nearest free floor tile', () => {
+    let both = false
+    for (let r = 1; r < 500 && !both; r++) {
+      const s = board(`
+        #########
+        #P..1S..#
+        #########`)
+      s.dropRng = r
+      const { state } = step(s, { id: 'wait' })
+      if (state.drops.length < 2) continue
+      both = true
+      const spots = state.drops.map((d) => `${d.pos.x},${d.pos.y}`)
+      expect(new Set(spots).size).toBe(spots.length)
+      for (const d of state.drops) expect(Math.abs(d.pos.x - 5)).toBeLessThanOrEqual(2)
+    }
+    expect(both).toBe(true)
+  })
+
+  it('a drop does not land where one already lies', () => {
+    let found = false
+    for (let r = 1; r < 500 && !found; r++) {
+      // The Blob dies in lingering flame, which (unlike a new blast) leaves the old drop alone.
+      const s = board(`
+        #########
+        #P...B..#
+        #########`)
+      s.burning = [1 * s.width + 5]
+      s.drops.push({ pos: v(5, 1), kind: 'shield' })
+      s.dropRng = r
+      const { state } = step(s, { id: 'wait' })
+      expect(state.drops[0]).toEqual({ pos: v(5, 1), kind: 'shield' })
+      const fresh = state.drops.slice(1)
+      if (!fresh.length) continue
+      found = true
+      for (const d of fresh) expect(d.pos).not.toEqual(v(5, 1))
+    }
+    expect(found).toBe(true)
+  })
+})
+
 describe('the exit', () => {
   it('a block over the exit burns away and reveals it', () => {
     const s = board(`
diff --git a/test/helpers.ts b/test/helpers.ts
index b2d2783..b325bae 100644
--- a/test/helpers.ts
+++ b/test/helpers.ts
@@ -1,5 +1,5 @@
 import { noCharges, noPowers, START_LIVES, START_STATS } from '../src/sim/state'
-import type { Cell, EnemyKind, GameState, Stats, Vec } from '../src/sim/types'
+import { DEFAULT_OPTIONS, type Cell, type EnemyKind, type GameState, type Stats, type Vec } from '../src/sim/types'
 
 const ENEMIES: Record<string, EnemyKind> = { B: 'blob', S: 'seeker', D: 'drifter' }
 
@@ -17,11 +17,11 @@ export function board(picture: string, stats: Partial<Stats> = {}): GameState {
   const cells: Cell[] = []
   let player: Vec | undefined
   const s: GameState = {
-    seed: 1, level: 1, turn: 5, width, height, cells, player: { x: 0, y: 0 },
+    seed: 1, options: { ...DEFAULT_OPTIONS }, level: 1, turn: 5, width, height, cells, player: { x: 0, y: 0 },
     lives: START_LIVES, stats: { ...START_STATS, ...stats }, bombs: [], status: 'playing',
     enemies: [], nextEnemyId: 0, drops: [], exit: { x: -1, y: -1 }, par: 100, coins: 0,
     charges: noCharges(), shield: false, frozen: false, decoy: null, burning: [],
-    powers: noPowers(), star: 0, powerUp: null, runTurns: 0, deaths: 0, aiRng: 1, dropRng: 1,
+    powers: noPowers(), star: 0, powerUp: null, runTurns: 0, deaths: 0, crumbling: [], aiRng: 1, dropRng: 1,
   }
   rows.forEach((row, y) => {
     if (row.length !== width) throw new Error(`row ${y} is ${row.length} wide, not ${width}`)
diff --git a/test/options.test.ts b/test/options.test.ts
new file mode 100644
index 0000000..1f942f1
--- /dev/null
+++ b/test/options.test.ts
@@ -0,0 +1,149 @@
+import { describe, expect, it } from 'vitest'
+import { enemyCount, generateLevel, lastLevel, SIZES, START } from '../src/sim/levelgen'
+import { newRecord, replay } from '../src/sim/record'
+import { DEV_STATS, newRun } from '../src/sim/state'
+import { step } from '../src/sim/turn'
+import { DEFAULT_OPTIONS, type Vec } from '../src/sim/types'
+import { board, v } from './helpers'
+
+const SMALL = { ...DEFAULT_OPTIONS, size: 'small' as const }
+
+describe('small board', () => {
+  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`, () => {
+      const g = generateLevel(seed, level, 'small')
+      expect([g.width, g.height]).toEqual([9, 11])
+      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, 'small'))
+      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]
+      while (queue.length) {
+        const p = queue.pop()!
+        for (const q of [{ x: p.x + 1, y: p.y }, { x: p.x - 1, y: p.y }, { x: p.x, y: p.y + 1 }, { x: p.x, y: p.y - 1 }]) {
+          if (at(q) !== 'wall' && !seen.has(`${q.x},${q.y}`)) {
+            seen.add(`${q.x},${q.y}`)
+            queue.push(q)
+          }
+        }
+      }
+      expect(seen.size).toBe(g.cells.filter((c) => c !== 'wall').length)
+    })
+  }
+
+  it('has fewer enemies and crates, a lower par, and a 20-level run', () => {
+    expect(enemyCount(1, 'small')).toBe(2)
+    expect(enemyCount(50, 'small')).toBe(5)
+    expect(SIZES.small.density).toBeLessThan(SIZES.normal.density)
+    expect(lastLevel('small')).toBe(20)
+    expect(newRun(1, 1, 0, SMALL).par).toBeLessThan(newRun(1, 1, 0).par)
+  })
+
+  it('clearing level 20 wins a small run', () => {
+    const s = { ...board(`
+      ####
+      #PE#
+      ####`), level: 20, options: SMALL }
+    expect(step(s, { id: 'move', target: v(2, 1) }).state.status).toBe('won')
+  })
+
+  it('the next level is small too', () => {
+    const s = { ...board(`
+      ####
+      #PE#
+      ####`), options: SMALL }
+    const next = step(step(s, { id: 'move', target: v(2, 1) }).state, { id: 'continue' }).state
+    expect([next.width, next.height]).toEqual([9, 11])
+  })
+})
+
+describe('developer mode', () => {
+  const dev = { ...DEFAULT_OPTIONS, dev: true }
+
+  it('has every ability, the developer stats and unlimited charges', () => {
+    const s = newRun(1, 1, 0, dev)
+    expect(Object.values(s.powers).every(Boolean)).toBe(true)
+    expect(s.stats).toEqual(DEV_STATS)
+    const before = s.charges.freeze
+    expect(step(s, { id: 'freeze' }).state.charges.freeze).toBe(before)
+  })
+
+  it('can still be killed by an enemy, and gets everything back after dying', () => {
+    const s = { ...board(`
+      #####
+      #PB.#
+      #####`), options: dev }
+    const dead = step(s, { id: 'move', target: v(2, 1) }).state
+    expect(dead.status).toBe('dead')
+    const again = step(dead, { id: 'continue' }).state
+    expect(Object.values(again.powers).every(Boolean)).toBe(true)
+  })
+})
+
+describe('god mode', () => {
+  it('nothing kills the player', () => {
+    const god = { ...DEFAULT_OPTIONS, god: true }
+    const flame = { ...board(`
+      #####
+      #P1.#
+      #####`), options: god }
+    expect(step(flame, { id: 'wait' }).state.status).toBe('playing')
+    const enemy = { ...board(`
+      #####
+      #PB.#
+      #####`), options: god }
+    expect(step(enemy, { id: 'move', target: v(2, 1) }).state.status).toBe('playing')
+  })
+})
+
+describe('crumbling crates', () => {
+  const crumbly = { ...DEFAULT_OPTIONS, crumble: true }
+
+  it('once the last enemy dies, every crate goes within 3 turns, revealing the exit, with no punishment', () => {
+    let s = { ...board(`
+      #########
+      #P...1B+#
+      #+++++#e#
+      #########`), options: crumbly }
+    const r = step(s, { id: 'wait' })
+    expect(r.events.map((e) => e.type)).toContain('cratesCrumbling')
+    s = r.state
+    expect(s.crumbling.length).toBe(s.cells.filter((c) => c === 'block').length)
+    const events = []
+    for (let t = 0; t < 3; t++) {
+      const next = step(s, { id: 'wait' })
+      events.push(...next.events)
+      s = next.state
+    }
+    expect(s.cells.filter((c) => c === 'block')).toEqual([])
+    expect(events.map((e) => e.type)).toContain('exitRevealed')
+    expect(s.enemies).toEqual([])
+  })
+
+  it('without the option, crates stay', () => {
+    const s = board(`
+      ########
+      #P...1B#
+      #+######
+      ########`)
+    const r = step(s, { id: 'wait' }).state
+    expect(r.crumbling).toEqual([])
+  })
+})
+
+describe('run record', () => {
+  it('keeps the options, so a replay is the same run', () => {
+    const options = { size: 'small' as const, dev: true, god: true, crumble: true }
+    const record = newRecord(77, 1, 0, options)
+    let s = newRun(77, 1, 0, options)
+    for (const a of [{ id: 'wait' as const }, { id: 'wait' as const }]) {
+      record.actions.push(a)
+      s = step(s, a).state
+    }
+    expect(replay(structuredClone(record))).toEqual(s)
+  })
+})
diff --git a/tools/screenshots.mjs b/tools/screenshots.mjs
index 1c51cc6..801799f 100644
--- a/tools/screenshots.mjs
+++ b/tools/screenshots.mjs
@@ -72,6 +72,11 @@ const SHOTS = [
     query: 'seed=55',
     act: press('KeyB', 'KeyS', 'KeyD', 'KeyD', 'Space', 'KeyA', 'KeyA', 'KeyS', 'KeyS', 'KeyR'),
   },
+  // A small board fits the screen whole: no scrolling.
+  { name: 'small', query: 'small', act: async () => {} },
+  { name: 'small-desktop', viewport: DESKTOP, query: 'small', act: async () => {} },
+  // Developer mode: every ability and unlimited charges (the ∞ badges); its tag in the top bar.
+  { name: 'dev', query: 'dev', act: async () => {} },
   // Tapping an enemy, or anything in the top bar, explains it.
   { name: 'info-enemy', act: tap(4, 5) },
   { name: 'info-hud', act: async (page) => { await page.click('[data-info=fuse]') } },