fuzzyworld

explodorama / commit

Auto-move runs and teleports; no throws over pillars; playtest fixes

28670c3e55d753764fef11744ab048bbee28c63f · Pavel Safronov · 2026-10-23 23:27:54 -0700 · parents d5e3ad4d75ff

Claude Code usage: this commit ≈0 out / ~60.4M cached · cost unknown (no per-model trailer) · session 3 tally 2.3K in / 1.2M out / 464.1M cached · usage by commit and session

Auto-move runs and teleports; no throws over pillars; playtest fixes

Auto-move picks each move as a step, a Run or a Teleport, whichever leaves
the shortest walk (a neighbour is always a step; ties go to Teleport, then
Run), and keeps going after its own teleports. With Teleport, a tile out of
walking reach but within range is one teleport.

Throw no longer goes over a pillar or the outer wall (still over crates,
bombs and enemies). With Diagonal, tapping a diagonal neighbour steps there
(it walked round the corner), and Diagonal beats Teleport for a tile next to
the player. Drops never land on the uncovered exit. SIM_VERSION 18.

Usage: 2.3K in / 1.2M out / 464.1M cached (session tally)
rtk:   1.8M saved, 40.2% over 3350 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
 CHANGELOG.md                         |   8 +++-
 PLAN.md                              |   6 +--
 README.md                            |  20 +++++++---
 decisions.md                         |  48 +++++++++++++++++++++++
 docs/screenshots/almanac-charges.png | Bin 369609 -> 369526 bytes
 docs/screenshots/almanac-enemies.png | Bin 308834 -> 308803 bytes
 docs/screenshots/almanac.png         | Bin 429479 -> 430969 bytes
 docs/screenshots/auto-move.png       | Bin 244588 -> 244640 bytes
 docs/screenshots/charges.png         | Bin 256654 -> 256659 bytes
 docs/screenshots/countdown.png       | Bin 248448 -> 248390 bytes
 docs/screenshots/desktop.png         | Bin 583108 -> 583089 bytes
 docs/screenshots/enemies.png         | Bin 238019 -> 238025 bytes
 docs/screenshots/gem-offer.png       | Bin 196925 -> 201285 bytes
 docs/screenshots/gem.png             | Bin 244285 -> 244258 bytes
 docs/screenshots/info-mode.png       | Bin 242990 -> 242881 bytes
 docs/screenshots/lingering.png       | Bin 248488 -> 248622 bytes
 docs/screenshots/menu.png            | Bin 170784 -> 170808 bytes
 docs/screenshots/move-picked.png     | Bin 243624 -> 243723 bytes
 docs/screenshots/scrolled.png        | Bin 246872 -> 246848 bytes
 src/main.ts                          |  35 ++++++++---------
 src/sim/actions.ts                   |  11 ++++--
 src/sim/demo.ts                      |   2 +-
 src/sim/path.ts                      |  37 +++++++++++++++++-
 src/sim/picks.ts                     |   5 ++-
 src/sim/record.ts                    |   2 +-
 src/sim/turn.ts                      |  10 +++--
 src/sim/types.ts                     |   2 +-
 src/ui/info.ts                       |   4 +-
 test/enemies.test.ts                 |  46 +++++++++++++++++++++-
 test/path.test.ts                    |  72 ++++++++++++++++++++++++++++++++++-
 test/picks.test.ts                   |  10 +++--
 31 files changed, 270 insertions(+), 48 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6856adb..90073c6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -83,7 +83,7 @@ saved before the change is dropped and the next visit starts a new run.
 - Stepping onto the exit walks into the doorway before the jump (it used to happen instantly).
 - An enemy walking into flame finishes its step before it dies (it used to die on the new tile
   without moving).
-- Saved runs from before the gems can't be resumed (`SIM_VERSION` 16).
+- Saved runs from before the gems can't be resumed (`SIM_VERSION` 18).
 - Developer mode now only puts every level's gem uncovered next to the player; it no longer
   gives every ability, extra stats or unlimited charges.
 
@@ -103,6 +103,12 @@ saved before the change is dropped and the next visit starts a new run.
   wasted; and no Decoy, Freeze or Shield drops while one already lies there or is working.
 - Enemies on the same tile no longer move as one: each plans around the others, so they spread out.
 - A Freeze drop on the board crashed the game (its icon was the wrong size).
+- Drops no longer land on the uncovered exit.
+- Auto-move uses Run and Teleport: each move goes as far toward the target as it can (a
+  neighbour is still a step).
+- With Diagonal, tapping a diagonal neighbour steps there (it used to walk round the corner).
+- Throw no longer goes over a pillar or the outer wall (it still goes over crates, bombs and
+  enemies).
 - An error while drawing a frame or playing a turn no longer freezes the game; it is logged and
   play goes on. Under `npm run dev`, the game's logs and errors, from any device, show in the
   terminal.
diff --git a/PLAN.md b/PLAN.md
index 563bb3b..d559b8d 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -161,8 +161,8 @@ they first appear on).
 |---|---|---|
 | **Run** | Run pickup (called Skates until 2026-10-22; NES "Speed"), or shop | 2 tiles in a straight line. **Both** tiles must be free — no jumping over anything. Highlight: path tile + landing tile. An enemy on the path tile = death (you ran into it). *Part of Move since 2026-10-22.* |
 | **Kick** | Kick power-up (*was a charge until 2026-10-22*) | Push an adjacent bomb by walking into it; it slides until the next tile is not free, keeping its timer. Part of Move. |
-| **Throw** | Throw power-up (*was a charge*) | A bomb **exactly 2 tiles away** in a straight line, over anything. Part of Bomb. |
-| **Teleport** | Teleport power-up, rare (*was a charge*) | Move to any free tile within Manhattan distance 3, over anything. Part of Move. |
+| **Throw** | Throw power-up (*was a charge*) | A bomb **exactly 2 tiles away** in a straight line, over a crate, bomb or enemy; *since 2026-10-23 (user's choice)* not over a pillar or the outer wall (it went over anything). Part of Bomb. |
+| **Teleport** | Teleport power-up, rare (*was a charge*) | Move to any free tile within Manhattan distance 3, over anything. Part of Move. *Since 2026-10-23 (user request)* auto-move uses it: each move is a step, a Run or a Teleport, whichever leaves the walk left shortest (ties: Teleport, then Run), a neighbour always a step (`autoMoveAction` in `sim/path.ts`). |
 | **Diagonal** | Diagonal power-up (*a charge until 2026-10-23*, user request; weight 8) | Step one tile diagonally; both orthogonal neighbours may be blocked — a squeeze. Part of Move (G, then Q E Z C). |
 | **Detonate** | Detonator pickup (NES "Detonator"), or shop | Explode one chosen own bomb now. *Since 2026-10-22:* tap the bomb (all of them pulse while you have it); no button. |
 
@@ -441,7 +441,7 @@ contact. Intent shown every turn. Adapted from the NES eight:
   second Decoy beside the first); Pierce, Power and Nuke add up, so they always can be. 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. *Built (2026-10-21, user
-  request):* drops never stack — a drop whose tile is taken (by a drop, a bomb or the uncovered
+  request):* drops never stack — a drop whose tile is taken (by a drop, a bomb, *since 2026-10-23* the uncovered exit, 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`.
diff --git a/README.md b/README.md
index 3af9ad0..1891e0e 100644
--- a/README.md
+++ b/README.md
@@ -52,12 +52,13 @@ they make Move and Bomb do more.
 | Action | Phone | Keyboard | What it does |
 |---|---|---|---|
 | **Move** | Tap a tile next to you, or tap **Move** and then any highlighted tile | W A S D or arrow keys | Step one tile up, down, left or right onto an empty tile. **With the Run power-up:** or 2 tiles in a straight line. **With Teleport:** or any empty tile up to 3 steps away, over anything. **With Kick:** walking into a bomb kicks it. **With Diagonal:** or a diagonal step. |
-| **Bomb** | Tap **Bomb**, then a highlighted tile — or **press and hold** a tile next to you | **B**, then a direction | Place a bomb on an empty tile next to you. You can have 1 bomb on the board at a time. **With Throw:** or 2 tiles away in a straight line, over anything. Pierce, Power and Nuke charges change your next bombs by themselves (see Charges). |
+| **Bomb** | Tap **Bomb**, then a highlighted tile — or **press and hold** a tile next to you | **B**, then a direction | Place a bomb on an empty tile next to you. You can have 1 bomb on the board at a time. **With Throw:** or 2 tiles away in a straight line, over a crate, bomb or enemy (not a pillar). Pierce, Power and Nuke charges change your next bombs by themselves (see Charges). |
 | **Wait** | Tap **Wait**, or tap **yourself** | Space | Do nothing; the world takes its turn. |
 
 When Move or Bomb can do several things, the tile you tap decides: next to you is a step (or a
 kick, into a bomb), 2 straight is a run, further is a teleport; next to you is a bomb, 2 away a
-throw. A diagonal step is only taken when nothing else reaches that tile.
+throw. With Diagonal, a diagonal neighbour is a step too — tap it, picked or not — and Teleport is
+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
@@ -73,8 +74,15 @@ It **won't take a deadly step**: onto an enemy, onto a tile an enemy's arrow poi
 flame this turn. The next step then shows red, and Go / the destination wait for you to deal with it
 (Go falls back to picking Move, so you can step aside yourself).
 **Once every enemy is gone**, the way needs no more presses: you walk there by yourself, one
-step a second (still one turn per step). **With the Run power-up**, a step of auto-move where the way goes
-straight on for 2 tiles is a Run instead (one turn for both), if both tiles are safe.
+step a second (still one turn per step).
+
+**How far one move of auto-move goes** (and the first one, when you tap a far tile): a tile next
+to you is always a step. Otherwise it's whichever of a step, a **Run** (2 tiles straight along the
+way) or a **Teleport** (anywhere within 3) leaves you closest — so a tile 2 away is two steps
+without either, one run with Run, one teleport with Teleport (with or without Run); further away,
+it runs or teleports as far toward the target as it can, then works it out again next turn. It
+only lands on safe tiles. With Teleport, tapping a tile you can't walk to but can teleport to
+(behind a pillar, say) teleports you there.
 
 Tapping an enemy, a bomb, burning flame, a wall or a crate does nothing (apart from ending
 auto-move, for a wall or crate; and with Kick, tapping a bomb next to you kicks it) — a tap
@@ -180,7 +188,7 @@ A slain enemy sometimes leaves **coins** (bronze = 1, silver = 2, gold = 5) or a
 left after it) is always one that lasts into the next level — Pierce, Power, Nuke or Shield —
 never a Freeze or Decoy, which would have nothing to work on. And no enemy drops a Decoy, Freeze
 or Shield while one already lies on the board or is working (a decoy out, enemies frozen, a
-shield up): a second one would be wasted. Pierce, Power and Nuke add up, so they always can. Drops never share a tile: if one is already there, the new one rolls to the
+shield up): a second one would be wasted. Pierce, Power and Nuke add up, so they always can. Drops never share a tile, and never land on the uncovered exit: 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).
 
@@ -218,7 +226,7 @@ before moving on.
 | **Fuse +1** | bomb with a timer badge | Your bombs start one higher (more time to get away). |
 | **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 anything in between (or **T**, then a direction). |
+| **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). |
 | **Teleport** (rare) | the Move figure with an outbox badge | **Move** can take you to any empty tile within 3 steps (counting across and down), over anything (or **P**, then tap a tile). |
 | **Diagonal** | the Move figure with a diagonal-arrows badge | **Move** can also step diagonally, even squeezing between two blocked tiles (or **G**, then **Q E Z C**: up-left, up-right, down-left, down-right). A charge until 2026-10-23. |
 | **Detonator** | bomb with a ! badge | Your bombs pulse with a red glow: **tap one** and it goes off this turn (or **X**, then tap it). No button. |
diff --git a/decisions.md b/decisions.md
index 846863a..56552aa 100644
--- a/decisions.md
+++ b/decisions.md
@@ -892,3 +892,51 @@ naturally when the player walks out through the open door.
   the demo. Tests: every kind found on 6 seeds, the player alive throughout and dead at the end
   of a death scene, and `showsAtEnd` confirms the moment. In a browser: 12 scenes in 70 s,
   alternating, no errors.
+
+### 2026-10-23 — No drops on the uncovered exit
+
+User report: an enemy died and its charge landed on the closed (uncovered, still locked) exit.
+That hides the door, and once the last enemy dies the drop can only be taken by stepping on the
+exit — which ends the level — while auto-move won't cross the open exit at all. `dropSpot` now
+counts the uncovered exit as taken, like a drop, a bomb or the uncovered gem: a drop there rolls
+to the nearest free floor tile (up to 2 steps). An exit still under its crate isn't floor, so
+drops never reached it. Test: a Blob dying on the uncovered exit (in lingering flame — a new
+blast there would let enemies out) never leaves a drop on it over 500 rolls; it fails without
+the rule. `SIM_VERSION` 17.
+
+### 2026-10-23 — No throwing over a pillar
+
+User question: "a bomb throw shouldn't be able to go through a column/pillar, right?" Throw had
+been "exactly 2 tiles away, over anything" since the first plan (as in the later Bomberman
+games, where a thrown bomb sails over blocks) — the plan's wording, not a user choice. Of the
+options (not over pillars or walls; only over open floor; keep over anything) the user chose
+the first: a pillar or the outer wall on the tile in between blocks a throw; a crate, a bomb or
+an enemy there can still be thrown over. Pillars look like tall solid columns, and crates stay
+a reason to have Throw. Teleport still goes "over anything" (the question was about throws).
+Tests: a pillar blocks, a crate, bomb or enemy doesn't. `SIM_VERSION` 18.
+
+### 2026-10-23 — A tap on a diagonal neighbour steps there
+
+User report: with Diagonal, a plain tap on a free diagonal tile walked one tile sideways first,
+as auto-move does for a far tile — a tap on the board took a direct step only for the four
+straight neighbours, and auto-move's way uses straight steps. Now a plain tap on a diagonal
+neighbour, with Diagonal, is a diagonal step, as a straight neighbour is a step. And with Move
+picked, Diagonal now comes before Teleport in Move's order (`GROUPS` in picks.ts), so a tile
+next to the player is always a step, and Teleport is for tiles further away (it used to win a
+diagonal neighbour too). Auto-move's way still uses straight steps only. Checked in a browser: a
+tap on a free diagonal neighbour moves there in one turn. No rule changed, so no new
+`SIM_VERSION`.
+
+### 2026-10-23 — Auto-move runs and teleports
+
+User report: with Teleport, tapping a far tile still walked there one tile at a time. The
+user's rule: a tile 1 step away is always a step; 2 steps away is two steps, or one run with
+Run, or one teleport with Teleport (or both); further away, run 2 or teleport as far as possible,
+then reassess. Built as `autoMoveAction` (sim/path.ts), used for every move of auto-move,
+including the first one on the tap: a neighbour is a step; otherwise every option — a step along
+the way, a Run along it, each Teleport landing — is scored by the walk left from where it lands,
+and the shortest wins, ties going to Teleport, then Run, then the step. Only tiles auto-move
+calls safe are landed on, and never the open exit unless it is the destination. Auto-move's own
+teleport doesn't end auto-move (a move off the way normally means "changed my mind"). With
+Teleport, a tile out of walking reach but within Teleport's (behind a pillar) is one teleport.
+Tests cover each case; in a browser, a tile 7 steps away took 3 turns (teleport, teleport, step).
diff --git a/docs/screenshots/almanac-charges.png b/docs/screenshots/almanac-charges.png
index 4d03396..accbbd8 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 0723588..22de4c0 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 7b2e903..9d32667 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 bf11d50..37829e5 100644
Binary files a/docs/screenshots/auto-move.png and b/docs/screenshots/auto-move.png differ
diff --git a/docs/screenshots/charges.png b/docs/screenshots/charges.png
index 4b4c9d6..0475bc3 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 c5609b1..a815c54 100644
Binary files a/docs/screenshots/countdown.png and b/docs/screenshots/countdown.png differ
diff --git a/docs/screenshots/desktop.png b/docs/screenshots/desktop.png
index 83428b3..c0cfb0c 100644
Binary files a/docs/screenshots/desktop.png and b/docs/screenshots/desktop.png differ
diff --git a/docs/screenshots/enemies.png b/docs/screenshots/enemies.png
index b6eadfa..a6a187c 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 685532d..1da850a 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 5d3c9e6..f428aec 100644
Binary files a/docs/screenshots/gem.png and b/docs/screenshots/gem.png differ
diff --git a/docs/screenshots/info-mode.png b/docs/screenshots/info-mode.png
index c8c2e8a..3ba678f 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 43d66c1..c1a0e4b 100644
Binary files a/docs/screenshots/lingering.png and b/docs/screenshots/lingering.png differ
diff --git a/docs/screenshots/menu.png b/docs/screenshots/menu.png
index 008af24..f955d74 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 f26d04a..d938008 100644
Binary files a/docs/screenshots/move-picked.png and b/docs/screenshots/move-picked.png differ
diff --git a/docs/screenshots/scrolled.png b/docs/screenshots/scrolled.png
index 177a4b7..6b1ede5 100644
Binary files a/docs/screenshots/scrolled.png and b/docs/screenshots/scrolled.png differ
diff --git a/src/main.ts b/src/main.ts
index 4561117..b0e6247 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -18,7 +18,7 @@ import { CHARGE_ICONS, MONO_ICONS, POWER_UP_ICONS, TEXTURES } from './render/spr
 import { isAvailable, isValidTarget, ACTIONS } from './sim/actions'
 import { canPick, resolvePick } from './sim/picks'
 import { enemyAt } from './sim/enemies'
-import { playerPath } from './sim/path'
+import { autoMoveAction, playerPath } from './sim/path'
 import { predictDanger, type Danger } from './sim/danger'
 import { newRecord, replay, SIM_VERSION, type RunRecord } from './sim/record'
 import { bombAt, cellAt, eq, flameSafe, idx, inBounds, isBurning, newRun } from './sim/state'
@@ -166,11 +166,12 @@ function randomSeed(): number {
 /** The actions that move the player. One that doesn't land on the auto-move path means "changed my mind". */
 const MOVES: ReadonlySet<Action['id']> = new Set(['move', 'run', 'diagonal', 'teleport'])
 
-function act(action: Action): void {
+/** `auto`: auto-move's own move, which may leave the walking way (a Teleport) without ending it. */
+function act(action: Action, auto = false): void {
   // A gem's offer is answered before anything else (its dialog opens a moment after the pickup).
   if (state.offer && action.id !== 'choose') return
   lastActionAt = time
-  if (MOVES.has(action.id) && 'target' in action && !path?.some((t) => eq(t, action.target!))) destination = null
+  if (!auto && MOVES.has(action.id) && 'target' in action && !path?.some((t) => eq(t, action.target!))) destination = null
   // step() throws on an action the rules don't allow; then nothing happens, and it is reported.
   let result: ReturnType<typeof step>
   try {
@@ -244,8 +245,11 @@ function afterChange(): void {
  * With nothing picked:
  * - the player: Wait;
  * - the auto-move flag: one step toward it (like Go), with auto-move's safety check;
- * - a tile next to the player: move there;
- * - any other tile you can walk to: set it as the auto-move destination, and take the first step;
+ * - a tile next to the player: move there; with Diagonal, a diagonal neighbour too (user report,
+ *   2026-10-23: it was walked to round the corner, as auto-move);
+ * - any other tile you can walk to: set it as the auto-move destination, and take the first move
+ *   (a step, a Run or a Teleport, whichever gets closest: `autoMoveAction`);
+ * - with Teleport, a tile you can't walk to but can teleport to: teleport there;
  * - a wall or crate, or any floor you can't walk to: clears the flag if there is one;
  * - one of the player's bombs, with the Detonator: set it off (user request, 2026-10-22);
  * - else a bomb next to the player, with Kick: kick it (walking into it);
@@ -272,7 +276,10 @@ function tapTile(p: Vec): void {
   else if (enemyAt(state, p) || bombAt(state, p) || (isBurning(state, p) && !flameSafe(state))) return
   else if (destination && eq(destination, p)) stepTowardDestination()
   else if (isValidTarget(state, 'move', p)) act({ id: 'move', target: p })
+  else if (isAvailable(state, 'diagonal') && isValidTarget(state, 'diagonal', p)) act({ id: 'diagonal', target: p })
   else if (cellAt(state, p) === 'floor' && playerPath(state, p)) setDestination(p)
+  // Out of walking reach (behind a pillar, say) but within Teleport's: one teleport there.
+  else if (isAvailable(state, 'teleport') && isValidTarget(state, 'teleport', p)) act({ id: 'teleport', target: p })
   else if (destination) {
     destination = null
     afterChange()
@@ -386,20 +393,14 @@ function safeStep(p: Vec): boolean {
 }
 
 /**
- * One step of auto-move, if the next step is safe. True if it stepped. With Run, a path
- * going straight on for 2 tiles is taken as one Run, if both tiles are safe.
+ * One move of auto-move: a step, a Run or a Teleport, whichever gets closest (`autoMoveAction`),
+ * only onto safe tiles. True if it moved.
  */
 function stepTowardDestination(): boolean {
-  const next = path?.[0]
-  if (!next || !safeStep(next)) return false
-  const after = path?.[1]
-  const straight = after && after.x - next.x === next.x - state.player.x && after.y - next.y === next.y - state.player.y
-  if (straight && isAvailable(state, 'run') && isValidTarget(state, 'run', after) && safeStep(after)) {
-    act({ id: 'run', target: after })
-    return true
-  }
-  if (!isValidTarget(state, 'move', next)) return false
-  act({ id: 'move', target: next })
+  if (!destination) return false
+  const action = autoMoveAction(state, destination, safeStep)
+  if (!action) return false
+  act(action, true)
   return true
 }
 
diff --git a/src/sim/actions.ts b/src/sim/actions.ts
index 84d839b..fe2b2dc 100644
--- a/src/sim/actions.ts
+++ b/src/sim/actions.ts
@@ -1,5 +1,5 @@
 import { enemyAt } from './enemies'
-import { add, bombAt, eq, isBurning, isFree, playerCanEnter } from './state'
+import { add, bombAt, cellAt, eq, isBurning, isFree, playerCanEnter } from './state'
 import { DIRS, DIR_LIST, type ActionId, type Bomb, type ChargeId, type GameEvent, type GameState, type Powers, type Vec } from './types'
 
 /**
@@ -178,11 +178,16 @@ export const ACTIONS: Record<ActionId, ActionDef> = {
     label: 'Throw',
     needsTarget: true,
     ability: 'throw',
-    // Exactly 2 tiles away in a straight line, over anything in between.
+    // 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 (user's choice, 2026-10-23: it went over anything).
     validTargets: (s) =>
       !hasCapacity(s)
         ? []
-        : DIR_LIST.map((d) => add(s.player, { x: 2 * DIRS[d].x, y: 2 * DIRS[d].y })).filter((p) => canHoldBomb(s, p)),
+        : DIR_LIST.flatMap((d) => {
+            const mid = add(s.player, DIRS[d])
+            const land = add(mid, DIRS[d])
+            return cellAt(s, mid) !== 'wall' && canHoldBomb(s, land) ? [land] : []
+          }),
     apply: (s, target, events) => placeBomb(s, target!, events),
   },
   diagonal: {
diff --git a/src/sim/demo.ts b/src/sim/demo.ts
index 896896b..6121b0d 100644
--- a/src/sim/demo.ts
+++ b/src/sim/demo.ts
@@ -165,7 +165,7 @@ export const SCENES: Record<SceneKind, SceneDef> = {
     shows: (t, seen) => seen.used.has('kick') && kills(t).length > 0,
   },
   throw: {
-    caption: 'Throw: put a bomb two tiles away, over anything in between.',
+    caption: 'Throw: put a bomb two tiles away, over a crate, a bomb or an enemy.',
     enemies: ['blob', 'seeker'],
     prepare: (s) => (s.powers.throw = true),
     shows: (t, seen) => seen.used.has('throw') && kills(t).length > 0,
diff --git a/src/sim/path.ts b/src/sim/path.ts
index a914f54..e6f1026 100644
--- a/src/sim/path.ts
+++ b/src/sim/path.ts
@@ -1,5 +1,6 @@
+import { ACTIONS, isAvailable, isValidTarget } from './actions'
 import { add, eq, exitOpen, playerCanPass } from './state'
-import { DIRS, DIR_LIST, type GameState, type Vec } from './types'
+import { DIRS, DIR_LIST, type Action, type GameState, type Vec } from './types'
 
 /**
  * The shortest walk for the player to `to`, for auto-move, over tiles the player can pass (free
@@ -32,3 +33,37 @@ export function playerPath(s: GameState, to: Vec): Vec[] | null {
   }
   return null
 }
+
+/**
+ * Auto-move's next action toward `to`, also the first one when a far tile is tapped (user
+ * request, 2026-10-23): a tile next to the player is a step; otherwise whichever of a step along
+ * the way, a Run (2 straight along it) or a Teleport (anywhere it reaches) leaves the player
+ * closest to `to`, counted as the walk still left. On a tie, Teleport before Run before a step,
+ * so 2 tiles away is one teleport with Teleport, one run with Run, two steps without. Then it is
+ * worked out again next turn. Only tiles `safe` allows are landed on, and never the open exit
+ * unless it is `to`. Null if nothing gets closer.
+ */
+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
+  if (path?.length === 1) return step
+  const options: { action: Action; left: number }[] = []
+  if (isAvailable(s, 'teleport')) {
+    for (const t of ACTIONS.teleport.validTargets(s)) {
+      if (!safe(t) || (exitOpen(s) && eq(t, s.exit) && !eq(t, to))) continue
+      options.push({ action: { id: 'teleport', target: t }, left: walkLeft(t) })
+    }
+  }
+  const [next, after] = [path?.[0], path?.[1]]
+  const straight = next && after && after.x - next.x === next.x - s.player.x && after.y - next.y === next.y - s.player.y
+  if (straight && isAvailable(s, 'run') && isValidTarget(s, 'run', after) && safe(next) && safe(after)) {
+    options.push({ action: { id: 'run', target: after }, left: path!.length - 2 })
+  }
+  if (step) options.push({ action: step, left: path!.length - 1 })
+  // The closest; the first listed wins a tie.
+  let best: { action: Action; left: number } | null = null
+  for (const o of options) if (!best || o.left < best.left) best = o
+  const now = path?.length ?? Infinity
+  return best && best.left < now ? best.action : null
+}
diff --git a/src/sim/picks.ts b/src/sim/picks.ts
index fd92230..30f3629 100644
--- a/src/sim/picks.ts
+++ b/src/sim/picks.ts
@@ -10,10 +10,11 @@ import type { ActionId, GameState, Vec } from './types'
  *
  * Members are in order of preference: a tile two of them can reach goes to the earlier one, so
  * a kick beats walking onto the bomb with Bomb Pass (walking into a bomb kicks it, if it can
- * slide), a plain step beats a run, and anything free beats spending a charge (Diagonal last).
+ * slide), a plain step beats a run, and a diagonal step beats a teleport to the same tile (a
+ * tile next to you is a step; user report, 2026-10-23).
  */
 const GROUPS: Partial<Record<ActionId, readonly ActionId[]>> = {
-  move: ['kick', 'move', 'run', 'teleport', 'diagonal'],
+  move: ['kick', 'move', 'run', 'diagonal', 'teleport'],
   bomb: ['bomb', 'throw', 'detonate'],
 }
 
diff --git a/src/sim/record.ts b/src/sim/record.ts
index f01f96e..9555632 100644
--- a/src/sim/record.ts
+++ b/src/sim/record.ts
@@ -6,7 +6,7 @@ import { DEFAULT_OPTIONS, type Action, type GameEvent, type GameState, type RunO
  * 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 = 16
+export const SIM_VERSION = 18
 
 /** Everything needed to reproduce a run exactly: mid-run saves, replays, certificates. */
 export interface RunRecord {
diff --git a/src/sim/turn.ts b/src/sim/turn.ts
index f679458..c232cb3 100644
--- a/src/sim/turn.ts
+++ b/src/sim/turn.ts
@@ -179,12 +179,16 @@ 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.
+ * bomb, the uncovered power-up or the uncovered exit — so drops never pile up on one tile, and
+ * never lie on the door, where taking one would mean leaving the level (user report,
+ * 2026-10-23). 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')
+    s.drops.some((d) => eq(d.pos, p)) ||
+    !!bombAt(s, p) ||
+    (!!s.powerUp && eq(s.powerUp.pos, p) && cellAt(s, p) === 'floor') ||
+    (eq(s.exit, 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++) {
diff --git a/src/sim/types.ts b/src/sim/types.ts
index 963ba88..6bbd6d9 100644
--- a/src/sim/types.ts
+++ b/src/sim/types.ts
@@ -97,7 +97,7 @@ export interface Powers {
   run: boolean
   /** Kick: walk into a bomb and it slides away. */
   kick: boolean
-  /** Throw: a bomb 2 tiles away in a straight line, over anything. */
+  /** Throw: a bomb 2 tiles away in a straight line, over a crate, bomb or enemy (not a pillar or wall). */
   throw: boolean
   /** Teleport: move to any free tile up to TELEPORT_RANGE steps away, over anything. */
   teleport: boolean
diff --git a/src/ui/info.ts b/src/ui/info.ts
index c7466ea..284f475 100644
--- a/src/ui/info.ts
+++ b/src/ui/info.ts
@@ -37,7 +37,7 @@ export const POWER_UP_TEXT: Record<PowerUpKind, { name: string; text: string }>
   coinBag: { name: 'Coin bag', text: `${COIN_BAG} coins.` },
   heart: { name: 'Heart', text: 'One more life.' },
   kick: { name: 'Kick', text: 'Walk into a bomb next to you and it slides away until something stops it, keeping its number.' },
-  throw: { name: 'Throw', text: 'Bomb can also put a bomb 2 tiles away in a straight line, over anything in between.' },
+  throw: { name: 'Throw', text: 'Bomb can also put a bomb 2 tiles away in a straight line, over a crate, bomb or enemy in between (not a pillar).' },
   teleport: { name: 'Teleport', text: `Move can also take you to any empty tile up to ${TELEPORT_RANGE} steps away, over anything.` },
   diagonal: { name: 'Diagonal', text: 'Move can also step diagonally, even squeezing between two blocked tiles.' },
 }
@@ -293,7 +293,7 @@ export function actionInfo(s: GameState, picked: ActionId): Info {
       const body = [
         `Tap a highlighted tile next to you. The bomb goes off after ${turns(s.stats.fuse)} of yours; its blast reaches ${s.stats.fire} ${s.stats.fire === 1 ? 'tile' : 'tiles'} each way. Up to ${s.stats.capacity} on the board at once.`,
       ]
-      if (s.powers.throw) body.push('Throw: or 2 tiles away in a straight line, over anything in between.')
+      if (s.powers.throw) body.push('Throw: or 2 tiles away in a straight line, over a crate, bomb or enemy (not a pillar).')
       if (s.powers.detonate) body.push('Detonator: tap one of your bombs (they pulse) and it goes off this turn. No need to pick Bomb first.')
       for (const k of ['pierce', 'power', 'nuke'] as const) {
         if (s.boosts[k] > 0) body.push(`${CHARGE_TEXT[k].name}: your next ${s.boosts[k]} ${s.boosts[k] === 1 ? 'bomb' : 'bombs'}.`)
diff --git a/test/enemies.test.ts b/test/enemies.test.ts
index a955280..11403a6 100644
--- a/test/enemies.test.ts
+++ b/test/enemies.test.ts
@@ -326,6 +326,24 @@ describe('drops never share a tile', () => {
     }
     expect(found).toBe(true)
   })
+  it('a drop never lands on the uncovered exit: it rolls off it', () => {
+    let found = false
+    for (let r = 1; r < 500 && !found; r++) {
+      // A Blob on the uncovered exit dies in lingering flame (a new blast there would let enemies out).
+      const s = board(`
+        #########
+        #P...E..#
+        #########`)
+      s.enemies.push({ id: 9, kind: 'blob', pos: v(5, 1), dir: 'left', plan: [], spawned: false, sheltered: false })
+      s.burning = [1 * s.width + 5]
+      s.dropRng = r
+      const { state } = step(s, { id: 'wait' })
+      if (!state.drops.length) continue
+      found = true
+      for (const d of state.drops) expect(d.pos).not.toEqual(v(5, 1))
+    }
+    expect(found).toBe(true)
+  })
 })
 
 describe('the exit', () => {
@@ -459,10 +477,10 @@ describe('action charges and abilities', () => {
     expect(ACTIONS.kick.validTargets(s)).toEqual([])
   })
 
-  it('throw: exactly 2 tiles away, over a wall', () => {
+  it('throw: exactly 2 tiles away, over a crate', () => {
     const s = board(`
       ######
-      #P#..#
+      #P+..#
       #.####
       #.####
       ######`)
@@ -472,6 +490,30 @@ describe('action charges and abilities', () => {
     expect(state.bombs).toEqual([{ pos: v(3, 1), timer: 3, fire: 1, placedTurn: 5 }])
   })
 
+  it('throw: not over a pillar or the outer wall (user\'s choice, 2026-10-23)', () => {
+    const s = board(`
+      #######
+      #P#..##
+      #.#####
+      #.....#
+      #######`)
+    s.powers.throw = true
+    // Right: the pillar at (2, 1) is in between. Down: (1, 2) is floor, so (1, 3) is fine.
+    expect(ACTIONS.throw.validTargets(s)).toEqual([v(1, 3)])
+  })
+
+  it('throw: over a bomb or an enemy in between', () => {
+    const s = board(`
+      ######
+      #P3..#
+      #B####
+      #.####
+      ######`)
+    s.powers.throw = true
+    s.stats.capacity = 2
+    expect(ACTIONS.throw.validTargets(s)).toEqual([v(1, 3), v(3, 1)])
+  })
+
   it('throw: needs bomb capacity', () => {
     const s = board(`
       #######
diff --git a/test/path.test.ts b/test/path.test.ts
index 8a72715..5f83aeb 100644
--- a/test/path.test.ts
+++ b/test/path.test.ts
@@ -1,5 +1,5 @@
 import { describe, expect, it } from 'vitest'
-import { playerPath } from '../src/sim/path'
+import { autoMoveAction, playerPath } from '../src/sim/path'
 import { board, v } from './helpers'
 
 describe('auto-move path', () => {
@@ -69,3 +69,73 @@ describe('auto-move path', () => {
     expect(playerPath(s, v(4, 1))).toEqual([v(2, 1), v(3, 1), v(4, 1)])
   })
 })
+
+describe('auto-move: a step, a Run or a Teleport (user request, 2026-10-23)', () => {
+  const always = () => true
+  const room = () =>
+    board(`
+      ##########
+      #P.......#
+      #.#.#.#.##
+      #........#
+      ##########`)
+
+  it('a tile next to you is a step, even with Run and Teleport', () => {
+    const s = room()
+    s.powers.run = true
+    s.powers.teleport = true
+    expect(autoMoveAction(s, v(2, 1), always)).toEqual({ id: 'move', target: v(2, 1) })
+  })
+
+  it('2 tiles away: two steps without either, one run with Run, one teleport with Teleport (or both)', () => {
+    const s = room()
+    expect(autoMoveAction(s, v(3, 1), always)).toEqual({ id: 'move', target: v(2, 1) })
+    s.powers.run = true
+    expect(autoMoveAction(s, v(3, 1), always)).toEqual({ id: 'run', target: v(3, 1) })
+    s.powers.teleport = true
+    expect(autoMoveAction(s, v(3, 1), always)).toEqual({ id: 'teleport', target: v(3, 1) })
+    s.powers.run = false
+    expect(autoMoveAction(s, v(3, 1), always)).toEqual({ id: 'teleport', target: v(3, 1) })
+  })
+
+  it('2 tiles away round a corner, Run alone is no help: a step', () => {
+    const s = board(`
+      #####
+      #P..#
+      #...#
+      #####`)
+    s.powers.run = true
+    expect(autoMoveAction(s, v(2, 2), always)?.id).toBe('move')
+  })
+
+  it('further: Teleport as far toward it as it reaches, then again', () => {
+    const s = room()
+    s.powers.teleport = true
+    const first = autoMoveAction(s, v(8, 1), always)!
+    expect(first).toEqual({ id: 'teleport', target: v(4, 1) })
+    const again = autoMoveAction({ ...s, player: v(4, 1) }, v(8, 1), always)!
+    expect(again).toEqual({ id: 'teleport', target: v(7, 1) })
+  })
+
+  it('further with Run only: runs where the way goes straight', () => {
+    const s = room()
+    s.powers.run = true
+    expect(autoMoveAction(s, v(8, 1), always)).toEqual({ id: 'run', target: v(3, 1) })
+  })
+
+  it('never lands on an unsafe tile: picks the next best', () => {
+    const s = room()
+    s.powers.teleport = true
+    const unsafe = (p: { x: number; y: number }) => !(p.x === 4 && p.y === 1)
+    expect(autoMoveAction(s, v(8, 1), unsafe)).toEqual({ id: 'teleport', target: v(3, 1) })
+  })
+
+  it('teleports to a tile it can reach but not walk to', () => {
+    const s = board(`
+      ######
+      #P#..#
+      ######`)
+    s.powers.teleport = true
+    expect(autoMoveAction(s, v(3, 1), always)).toEqual({ id: 'teleport', target: v(3, 1) })
+  })
+})
diff --git a/test/picks.test.ts b/test/picks.test.ts
index 18b65b8..f67d10a 100644
--- a/test/picks.test.ts
+++ b/test/picks.test.ts
@@ -58,7 +58,7 @@ describe('Move stands for every way of moving', () => {
     expect(resolvePick(s, 'move', v(5, 1))).toBeNull()
   })
 
-  it('a diagonal step is taken only when nothing else reaches the tile', () => {
+  it('a diagonal neighbour is a diagonal step, even with Teleport (a tile next to you is a step)', () => {
     const s = board(`
       #####
       #P#.#
@@ -67,15 +67,17 @@ describe('Move stands for every way of moving', () => {
     s.powers.diagonal = true
     expect(resolvePick(s, 'move', v(2, 2))).toBe('diagonal')
     s.powers.teleport = true
-    expect(resolvePick(s, 'move', v(2, 2))).toBe('teleport')
+    expect(resolvePick(s, 'move', v(2, 2))).toBe('diagonal')
+    // Further away, Teleport still does it.
+    expect(resolvePick(s, 'move', v(3, 2))).toBe('teleport')
   })
 })
 
 describe('Bomb stands for placing and throwing', () => {
-  it('next to you is a plain bomb; with Throw, 2 tiles away over anything is a throw', () => {
+  it('next to you is a plain bomb; with Throw, 2 tiles away over a crate is a throw', () => {
     const s = board(`
       ######
-      #P#..#
+      #P+..#
       ######`)
     expect(pickTargets(s, 'bomb')).toEqual([])
     expect(canPick(s, 'bomb')).toBe(false)