fuzzyworld

explodorama / commit

Diagonal no longer squeezes between two blocked tiles

a313eb7dd389caa03a2cfa9e24a52602ce6835d7 · Pavel Safronov · 2026-10-25 16:22:42 -0700 · parents fcf44f32e349

Claude Code usage: this commit ≈0 out / ~17.5M cached · cost unknown (no per-model trailer) · session 4 tally 4.0K in / 1.8M out / 958.5M cached · usage by commit and session

Diagonal no longer squeezes between two blocked tiles

The balance lab found the squeeze was most of Diagonal's strength: +7.7
levels for the average bot with it, +3.4 without (about Kick's +2.6);
+1.0 and +0.3 for the careful bot. A diagonal step now needs one of the
two tiles beside it passable, for the action and auto-move alike. The
lab keeps the old rule as a variant (options.lab.squeeze) and a
"diagonal" experiment to compare. SIM_VERSION 36.

Usage: 4.0K in / 1.8M out / 958.5M cached (session tally)
rtk:   2.0M saved, 38.7% over 4679 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
 CHANGELOG.md                     |   1 +
 PLAN.md                          |  14 ++++++++++++++
 README.md                        |   4 ++--
 decisions.md                     |  12 ++++++++++++
 docs/balance/diagonal-average.md |  40 +++++++++++++++++++++++++++++++++++++++
 docs/balance/diagonal-careful.md |  40 +++++++++++++++++++++++++++++++++++++++
 docs/screenshots/almanac.png     | Bin 514897 -> 521865 bytes
 docs/screenshots/auto-move.png   | Bin 216796 -> 216771 bytes
 docs/screenshots/bomb-picked.png | Bin 215828 -> 215850 bytes
 docs/screenshots/charges.png     | Bin 224644 -> 224630 bytes
 docs/screenshots/countdown.png   | Bin 219691 -> 219721 bytes
 docs/screenshots/death.png       | Bin 233745 -> 233723 bytes
 docs/screenshots/demo.png        | Bin 958036 -> 952035 bytes
 docs/screenshots/desktop.png     | Bin 270193 -> 270235 bytes
 docs/screenshots/dev-offer.png   | Bin 198900 -> 218864 bytes
 docs/screenshots/enemies.png     | Bin 246259 -> 246353 bytes
 docs/screenshots/gem-offer.png   | Bin 207918 -> 207227 bytes
 docs/screenshots/gem.png         | Bin 200124 -> 200172 bytes
 docs/screenshots/info-hud.png    | Bin 182283 -> 178628 bytes
 docs/screenshots/info-mode.png   | Bin 214142 -> 214113 bytes
 docs/screenshots/lingering.png   | Bin 219247 -> 219165 bytes
 docs/screenshots/long-press.png  | Bin 219450 -> 219398 bytes
 docs/screenshots/menu.png        | Bin 151057 -> 150195 bytes
 docs/screenshots/move-picked.png | Bin 216082 -> 216088 bytes
 docs/screenshots/nuke.png        | Bin 231959 -> 231990 bytes
 docs/screenshots/shop.png        | Bin 209118 -> 209166 bytes
 docs/screenshots/start.png       | Bin 215447 -> 215464 bytes
 src/sim/actions.ts               |  16 ++++++++++++++--
 src/sim/path.ts                  |   5 +++--
 src/sim/record.ts                |   2 +-
 src/sim/types.ts                 |   5 +++++
 src/ui/info.ts                   |   2 +-
 test/charges.test.ts             |   4 ++--
 test/new-powers.test.ts          |  17 +++++++++++++++++
 test/picks.test.ts               |   2 +-
 tools/balance/lab.ts             |  19 ++++++++++++++++++-
 36 files changed, 171 insertions(+), 12 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8454cd7..f0e5501 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -93,6 +93,7 @@ saved before the change is dropped and the next visit starts a new run.
 
 ### Changed
 
+- **Diagonal no longer squeezes** between two blocked tiles: one of the two tiles beside a diagonal step must be open. The squeeze was most of what made Diagonal so strong. Version 36.
 - **Flame Pass and Teleport are charges now**, not power-ups: Flame Pass, raised with the charge button, takes the next 3 fire hits; Teleport gives 3 jumps from the charge button. Each alone used to win the game.
 - **A death drops the charge you carry** where you fell, to pick up again on the retry.
 - **Board sizes fitted:** up to 13 × 17, so late levels take about as long as early ones; slightly more enemies on late levels. Version 35.
diff --git a/PLAN.md b/PLAN.md
index 7fe5c4a..5ca043f 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -1192,6 +1192,20 @@ version 35),** 150 seeds, now also each charge carried from level 1:
   bots use charges crudely (whenever their scoring likes it), so these say little about a person.
 - The careful bot: 91% of runs won (82% under rules 28), about 84 turns a level, stalls 3%.
 
+**Diagonal without squeezing (2026-10-25, user's question; `npm run balance -- diagonal`, 300
+seeds, a lab-only rule switch `options.lab.noSqueeze`):** a diagonal step then needs one of the
+two tiles beside it open.
+
+| | Average bot | Careful bot |
+|---|---|---|
+| Diagonal as it is | +7.7 levels (wins +11%) | +1.0 levels (wins +8%) |
+| Diagonal, no squeeze | **+3.4 levels** (wins +1%) | +0.3 (not significant), but 9% of runs stall |
+
+Most of Diagonal's strength is the squeeze: without it, it is worth about what Kick is (+2.6 for
+the average bot). The careful bot's stalls rise (4% → 9%), probably its own pathing with the rule
+rather than the game; to look at. *Decided (user, 2026-10-25): no squeezing is the rule* (`SIM_VERSION`
+36); the lab's `options.lab.squeeze` brings the old rule back to compare.
+
 ### 19.2 Bigger boards for later levels
 
 **Today:** every level is 9 × 11, the whole board on screen at once (a choice made 2026-10-23). It
diff --git a/README.md b/README.md
index 543c295..bbad3e1 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@ seven charges (one carried at a time, used when you choose), a hidden gem on eve
 mid-run work. Curses and the Reaper enemy are planned —
 see [PLAN.md](PLAN.md).
 
-Version: **35**, shown at the bottom of the menu with the commit the game was built from (a link
+Version: **36**, shown at the bottom of the menu with the commit the game was built from (a link
 to it on GitLab, plus "plus local changes" in a build with uncommitted changes) and a link to the
 [source code](https://gitlab.com/PavelSafronov/explodorama). The version is the rules version
 (`SIM_VERSION` in [src/sim/record.ts](src/sim/record.ts)): it goes up by one whenever a rule
@@ -310,7 +310,7 @@ go: **Blast 2 → 4 (max 5)**.
 | **Run** | the Move figure with a fast-forward badge | **Move** can run: 2 tiles in a straight line in one turn (or **R**, then a direction). Both tiles must be empty; an enemy on the tile in between is run into. Anything on that tile is picked up. |
 | **Kick** | bomb with a right-arrow badge | Walk into a bomb next to you and it slides away until the next tile is blocked (a wall, crate, bomb or enemy), keeping its number. |
 | **Throw** | bomb with an up-right-arrow badge | **Bomb** can also go exactly 2 tiles away in a straight line, over a crate, bomb or enemy in between — but not over a pillar or the outer wall (or **T**, then a direction). |
-| **Diagonal** | the Move figure with a diagonal-arrows badge | **Move** can also step diagonally, even squeezing between two blocked tiles (or **Q E Z C**: up-left, up-right, down-left, down-right); auto-move cuts corners with it. With the Teleport charge, it widens Teleport's reach to a square. A charge until 2026-10-23. |
+| **Diagonal** | the Move figure with a diagonal-arrows badge | **Move** can also step diagonally, where one of the two tiles beside the step is open — not between two blocked tiles (no squeezing since 2026-10-25; the lab found the squeeze was most of its strength) (or **Q E Z C**: up-left, up-right, down-left, down-right); auto-move cuts corners with it. With the Teleport charge, it widens Teleport's reach to a square. A charge until 2026-10-23. |
 | **Slide** | the Move figure with a two-way-arrow badge | **Move** can also slide in a straight line until the next tile is a wall, crate or bomb — **tap the tile where it stops** (or **L**, then a direction) — picking up whatever it passes. It can't stop early: an enemy on the way is crashed into (a life lost), and flame on the way burns (unless Flame Pass or a Star). The open exit stops it. |
 | **RPG** | bomb with a forward-arrow badge | **Bomb** can also fire a bomb in a straight line (or **F**, then a direction): it flies until the next tile is a wall, crate, bomb or enemy and lands just before it, then counts down as usual. |
 | **Detonator** | bomb with a ! badge | Your bombs pulse with a red glow: **tap one** and it goes off this turn (or **X**, then tap it). No button. |
diff --git a/decisions.md b/decisions.md
index 8e8dc42..7bb9398 100644
--- a/decisions.md
+++ b/decisions.md
@@ -2091,3 +2091,15 @@ The user's order: stages 1–3 of the plan.
    A raised Shield or Flame Pass is kept. A pending choice now carries how much is left too.
 
 `SIM_VERSION` 35. The lab now also measures each charge carried from level 1.
+
+### 2026-10-25 — Measured: Diagonal without squeezing
+
+User question: how would a no-squeeze Diagonal change things? A lab-only rule switch
+(`RunOptions.lab.noSqueeze`, never set in play; the game is unchanged) makes a diagonal step need one
+of the two tiles beside it open, for the action and for auto-move's walk alike. On 300 seeds: the
+average bot gains +3.4 levels with it instead of +7.7 (wins +1% instead of +11%); the careful bot
++0.3 instead of +1.0, with more stalled runs (9%). So the squeeze is most of Diagonal's strength.
+Left to the user to decide.
+- **Decided (user, same day): no squeezing is the rule.** A diagonal step needs one of the two
+  tiles beside it passable, for the action and auto-move alike. The lab keeps the old rule as a
+  variant (`options.lab.squeeze`) to compare against. `SIM_VERSION` 36.
diff --git a/docs/balance/diagonal-average.md b/docs/balance/diagonal-average.md
new file mode 100644
index 0000000..fcc0978
--- /dev/null
+++ b/docs/balance/diagonal-average.md
@@ -0,0 +1,40 @@
+# Balance lab: diagonal (average bot)
+
+2026-10-25 · rules version 35 · 300 seeds per arm · 79 s.
+
+Each arm plays the same seeds as the baseline; its columns are the difference from the baseline,
+per run, with a 95% interval. ✱ marks a difference the interval says is real. Sorted by levels
+gained. The bots answer a gem by taking the first thing offered and never shop.
+
+| Arm | Levels cleared | Won | Lives lost | Turns per level | Turns in danger | Stalled |
+|---|---|---|---|---|---|---|
+| **baseline** | 4.42 | 0% | 2.98 | 74.5 | 3.5% | 1% |
+| diagonal | +7.68 ± 0.62 ✱ | +11 ± 4 ✱% | −0.22 ± 0.08 ✱ | −7.8 ± 2.7 ✱ | −0.2 ± 0.2 ✱ | 1% |
+| diagonal, no squeeze | +3.42 ± 0.52 ✱ | +1 ± 1% | −0.05 ± 0.05 | −4.8 ± 3.9 ✱ | +0.2 ± 0.2 | 2% |
+
+## The baseline, level by level
+
+| Level | Runs reaching it | Lives lost there | Turns there | Within par |
+|---|---|---|---|---|
+| 1 | 100% | 0.18 | 73 | 14% |
+| 2 | 97% | 0.39 | 76 | 23% |
+| 3 | 90% | 0.56 | 67 | 27% |
+| 4 | 75% | 0.85 | 87 | 26% |
+| 5 | 54% | 0.78 | 83 | 24% |
+| 6 | 37% | 0.75 | 75 | 30% |
+| 7 | 25% | 0.66 | 89 | 29% |
+| 8 | 19% | 0.79 | 97 | 27% |
+| 9 | 14% | 0.61 | 75 | 37% |
+| 10 | 10% | 1.03 | 75 | 35% |
+| 11 | 6% | 0.47 | 56 | 53% |
+| 12 | 4% | 0.25 | 79 | 50% |
+| 13 | 3% | 0.20 | 65 | 80% |
+| 14 | 3% | 0.67 | 64 | 67% |
+| 15 | 2% | 0.17 | 76 | 83% |
+| 16 | 2% | 0.60 | 40 | 60% |
+| 17 | 1% | 1.00 | 25 | 33% |
+| 18 | 0% | 0.00 | 64 | 100% |
+| 19 | 0% | 1.00 | 73 | 100% |
+| 20 | 0% | 0.00 | 64 | 100% |
+
+Lives lost in the baseline, by cause: blast 786, chain 13, lingering 13, walkedIntoEnemy 60, caughtByEnemy 22.
diff --git a/docs/balance/diagonal-careful.md b/docs/balance/diagonal-careful.md
new file mode 100644
index 0000000..397b103
--- /dev/null
+++ b/docs/balance/diagonal-careful.md
@@ -0,0 +1,40 @@
+# Balance lab: diagonal (careful bot)
+
+2026-10-25 · rules version 35 · 300 seeds per arm · 426 s.
+
+Each arm plays the same seeds as the baseline; its columns are the difference from the baseline,
+per run, with a 95% interval. ✱ marks a difference the interval says is real. Sorted by levels
+gained. The bots answer a gem by taking the first thing offered and never shop.
+
+| Arm | Levels cleared | Won | Lives lost | Turns per level | Turns in danger | Stalled |
+|---|---|---|---|---|---|---|
+| **baseline** | 18.84 | 90% | 0.36 | 82.4 | 5.1% | 4% |
+| diagonal | +1.02 ± 0.43 ✱ | +8 ± 3 ✱% | −0.24 ± 0.10 ✱ | −11.2 ± 1.8 ✱ | −2.2 ± 0.2 ✱ | 1% |
+| diagonal, no squeeze | +0.27 ± 0.56 |  0 ± 5% | −0.24 ± 0.10 ✱ | −6.7 ± 2.2 ✱ | −2.5 ± 0.2 ✱ | 9% |
+
+## The baseline, level by level
+
+| Level | Runs reaching it | Lives lost there | Turns there | Within par |
+|---|---|---|---|---|
+| 1 | 100% | 0.01 | 68 | 18% |
+| 2 | 100% | 0.03 | 69 | 29% |
+| 3 | 99% | 0.00 | 64 | 32% |
+| 4 | 99% | 0.01 | 81 | 36% |
+| 5 | 99% | 0.04 | 82 | 39% |
+| 6 | 98% | 0.05 | 78 | 46% |
+| 7 | 97% | 0.01 | 86 | 44% |
+| 8 | 96% | 0.01 | 98 | 47% |
+| 9 | 95% | 0.03 | 91 | 51% |
+| 10 | 94% | 0.01 | 86 | 57% |
+| 11 | 94% | 0.00 | 83 | 67% |
+| 12 | 94% | 0.03 | 96 | 64% |
+| 13 | 93% | 0.02 | 87 | 81% |
+| 14 | 92% | 0.01 | 81 | 83% |
+| 15 | 92% | 0.04 | 76 | 88% |
+| 16 | 91% | 0.04 | 91 | 88% |
+| 17 | 91% | 0.00 | 87 | 90% |
+| 18 | 91% | 0.01 | 85 | 92% |
+| 19 | 91% | 0.03 | 81 | 94% |
+| 20 | 90% | 0.00 | 74 | 98% |
+
+Lives lost in the baseline, by cause: walkedIntoEnemy 63, blast 30, caughtByEnemy 13, lingering 1.
diff --git a/docs/screenshots/almanac.png b/docs/screenshots/almanac.png
index ee8922f..b036e4b 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 a7825b8..208fec1 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 6c627c9..406f055 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 f743733..bba8c13 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 f49cd68..cfc73ad 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 a4a2a8b..61aa5ab 100644
Binary files a/docs/screenshots/death.png and b/docs/screenshots/death.png differ
diff --git a/docs/screenshots/demo.png b/docs/screenshots/demo.png
index fb92286..b1be056 100644
Binary files a/docs/screenshots/demo.png and b/docs/screenshots/demo.png differ
diff --git a/docs/screenshots/desktop.png b/docs/screenshots/desktop.png
index 928815e..d3d7f06 100644
Binary files a/docs/screenshots/desktop.png and b/docs/screenshots/desktop.png differ
diff --git a/docs/screenshots/dev-offer.png b/docs/screenshots/dev-offer.png
index 7f5c2f3..b100dd2 100644
Binary files a/docs/screenshots/dev-offer.png and b/docs/screenshots/dev-offer.png differ
diff --git a/docs/screenshots/enemies.png b/docs/screenshots/enemies.png
index 0fb7762..19fcba8 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 e398cab..422cd18 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 cb161cb..610f9a3 100644
Binary files a/docs/screenshots/gem.png and b/docs/screenshots/gem.png differ
diff --git a/docs/screenshots/info-hud.png b/docs/screenshots/info-hud.png
index 25b68e7..66a61b7 100644
Binary files a/docs/screenshots/info-hud.png and b/docs/screenshots/info-hud.png differ
diff --git a/docs/screenshots/info-mode.png b/docs/screenshots/info-mode.png
index 32da675..34ce5e7 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 ce39c0b..5a9e8b8 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 2184f30..ffbd691 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 4d54205..e29d4eb 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 e05b382..b128d72 100644
Binary files a/docs/screenshots/move-picked.png and b/docs/screenshots/move-picked.png differ
diff --git a/docs/screenshots/nuke.png b/docs/screenshots/nuke.png
index 78b13c7..a1b38d3 100644
Binary files a/docs/screenshots/nuke.png and b/docs/screenshots/nuke.png differ
diff --git a/docs/screenshots/shop.png b/docs/screenshots/shop.png
index 2759f9a..59dcacc 100644
Binary files a/docs/screenshots/shop.png and b/docs/screenshots/shop.png differ
diff --git a/docs/screenshots/start.png b/docs/screenshots/start.png
index 12386de..4b4846d 100644
Binary files a/docs/screenshots/start.png and b/docs/screenshots/start.png differ
diff --git a/src/sim/actions.ts b/src/sim/actions.ts
index 05f84d4..21ae1e8 100644
--- a/src/sim/actions.ts
+++ b/src/sim/actions.ts
@@ -1,6 +1,6 @@
 import { BOMB_CHARGES, FLAME_PASS_HITS, holds, spendCharge } from './charges'
 import { enemyAt } from './enemies'
-import { add, bombAt, cellAt, eq, exitOpen, isBurning, isFree, playerCanEnter } from './state'
+import { add, bombAt, cellAt, eq, exitOpen, isBurning, isFree, playerCanEnter, playerCanPass } from './state'
 import { DIRS, DIR_LIST, type ActionId, type Bomb, type GameEvent, type GameState, type Powers, type Vec } from './types'
 
 /**
@@ -88,6 +88,17 @@ export function slidePath(s: GameState, d: Vec): Vec[] {
   return path
 }
 
+/**
+ * Whether a diagonal step from `from` by `d` may be taken: one of the two tiles beside the step
+ * must be passable — no squeezing between two blocked tiles (user's choice, 2026-10-25: the lab
+ * found the squeeze was most of Diagonal's strength, +7.7 levels for the average bot, +3.4
+ * without). The lab's `squeeze` variant brings the old rule back, to compare.
+ */
+export function diagonalAllowed(s: GameState, from: Vec, d: Vec): boolean {
+  if (s.options.lab?.squeeze) return true
+  return playerCanPass(s, add(from, { x: d.x, y: 0 })) || playerCanPass(s, add(from, { x: 0, y: d.y }))
+}
+
 /** How far Teleport reaches: TELEPORT_RANGE, one more with Run (user request, 2026-10-24). */
 export function teleportRange(s: GameState): number {
   return TELEPORT_RANGE + (s.powers.run ? 1 : 0)
@@ -228,7 +239,7 @@ export const ACTIONS: Record<ActionId, ActionDef> = {
     label: 'Diagonal',
     needsTarget: true,
     ability: 'diagonal',
-    // A squeeze: the two orthogonal tiles in between may both be blocked.
+    // Not between two blocked tiles (no squeezing, since 2026-10-25).
     validTargets: (s) =>
       [
         { x: -1, y: -1 },
@@ -236,6 +247,7 @@ export const ACTIONS: Record<ActionId, ActionDef> = {
         { x: -1, y: 1 },
         { x: 1, y: 1 },
       ]
+        .filter((d) => diagonalAllowed(s, s.player, d))
         .map((d) => add(s.player, d))
         .filter((p) => playerCanEnter(s, p)),
     apply: (s, target, events) => walkTo(s, target!, events),
diff --git a/src/sim/path.ts b/src/sim/path.ts
index deafc20..7bd32f3 100644
--- a/src/sim/path.ts
+++ b/src/sim/path.ts
@@ -1,4 +1,4 @@
-import { isAvailable, isValidTarget } from './actions'
+import { diagonalAllowed, isAvailable, isValidTarget } from './actions'
 import type { Danger } from './danger'
 import { enemyAt, moveTiles } from './enemies'
 import { add, eq, exitOpen, flameSafe, idx, playerCanPass } from './state'
@@ -15,7 +15,7 @@ const DIAGONALS: readonly Vec[] = [{ x: -1, y: -1 }, { x: 1, y: -1 }, { x: -1, y
  * the UI refuses a step onto an enemy instead.
  * An open exit is only walked onto as the destination: passing over it would end the level on
  * the way somewhere else (user report, 2026-10-23).
- * With Diagonal, a diagonal step counts as one too, squeezes included, as the action allows (user
+ * With Diagonal, a diagonal step counts as one too, where the action allows it (no squeezing; user
  * report, 2026-10-25: auto-move went round two sides of a corner it could cut).
  */
 export function playerPath(s: GameState, to: Vec): Vec[] | null {
@@ -28,6 +28,7 @@ export function playerPath(s: GameState, to: Vec): Vec[] | null {
   for (let i = 0; i < queue.length; i++) {
     const p = queue[i]!
     for (const d of steps) {
+      if (d.x && d.y && !diagonalAllowed(s, p, d)) continue
       const q = add(p, d)
       if (prev.has(key(q)) || !playerCanPass(s, q) || (avoidExit && eq(q, s.exit))) continue
       prev.set(key(q), p)
diff --git a/src/sim/record.ts b/src/sim/record.ts
index 9467df0..b261477 100644
--- a/src/sim/record.ts
+++ b/src/sim/record.ts
@@ -6,7 +6,7 @@ import { DEFAULT_OPTIONS, type Action, type ChargeId, type GameEvent, type GameS
  * Bumped whenever a rule change would make an old record replay differently. Records from
  * another version can't be trusted to replay (PLAN.md §11).
  */
-export const SIM_VERSION = 35
+export const SIM_VERSION = 36
 
 /** Everything needed to reproduce a run exactly: mid-run saves, replays, certificates. */
 export interface RunRecord {
diff --git a/src/sim/types.ts b/src/sim/types.ts
index 743b228..31c0441 100644
--- a/src/sim/types.ts
+++ b/src/sim/types.ts
@@ -72,6 +72,11 @@ export interface RunOptions {
    * certificate).
    */
   immortal: boolean
+  /**
+   * Rule variants for the balance lab to compare (PLAN.md §19.1), never set in play. `squeeze`: the
+   * old Diagonal, which could step between two blocked tiles (the rule until 2026-10-25).
+   */
+  lab?: { squeeze?: boolean }
 }
 
 export const DEFAULT_OPTIONS: RunOptions = { dev: false, immortal: false }
diff --git a/src/ui/info.ts b/src/ui/info.ts
index 062f647..4f55681 100644
--- a/src/ui/info.ts
+++ b/src/ui/info.ts
@@ -47,7 +47,7 @@ export const POWER_UP_TEXT: Record<PowerUpKind, { name: string; text: string }>
   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 a crate, bomb or enemy in between (not a pillar).' },
-  diagonal: { name: 'Diagonal', text: 'Move can also step diagonally, even squeezing between two blocked tiles. With Teleport, it reaches a square around you: a diagonal step counts as one.' },
+  diagonal: { name: 'Diagonal', text: 'Move can also step diagonally, where one of the two tiles beside the step is open (not between two blocked ones). With Teleport, it reaches a square around you: a diagonal step counts as one.' },
   rpg: { name: 'RPG', text: 'Bomb can also fire a bomb in a straight line: it flies until a wall, crate, bomb or enemy is next, and lands just before it.' },
   proximity: { name: 'Proximity alert', text: 'Your bombs go off as soon as an enemy is right next to one — wherever you are standing.' },
   slide: { name: 'Slide', text: 'Move can also slide in a straight line until a wall, crate or bomb stops you, picking up what you pass. An enemy on the way stops you the hard way; flame on the way burns.' },
diff --git a/test/charges.test.ts b/test/charges.test.ts
index 2d889d5..017e9b9 100644
--- a/test/charges.test.ts
+++ b/test/charges.test.ts
@@ -216,10 +216,10 @@ describe('charges are used on purpose', () => {
 })
 
 describe('Diagonal', () => {
-  it('is a power-up: squeezes between two blocked tiles once gained, kept through a death', () => {
+  it('is a power-up: a diagonal step once gained, kept through a death', () => {
     const s = board(`
       #####
-      #P+.#
+      #P..#
       #+..#
       #####`)
     expect(isAvailable(s, 'diagonal')).toBe(false)
diff --git a/test/new-powers.test.ts b/test/new-powers.test.ts
index 2e90c78..0b518e9 100644
--- a/test/new-powers.test.ts
+++ b/test/new-powers.test.ts
@@ -204,3 +204,20 @@ describe('Diagonal widens Teleport', () => {
     expect(ACTIONS.teleport.validTargets(s)).toContainEqual(v(7, 1))
   })
 })
+
+describe('Diagonal doesn’t squeeze between two blocked tiles (since 2026-10-25)', () => {
+  it('needs one of the two tiles beside the step open; the lab’s squeeze variant allows the old way', () => {
+    const s = board(`
+      #####
+      #P+.#
+      #+..#
+      #####`)
+    s.powers.diagonal = true
+    expect(ACTIONS.diagonal.validTargets(s)).toEqual([])
+    s.options = { ...s.options, lab: { squeeze: true } }
+    expect(ACTIONS.diagonal.validTargets(s)).toEqual([v(2, 2)])
+    s.options = { ...s.options, lab: undefined }
+    s.cells[1 * s.width + 2] = 'floor'
+    expect(ACTIONS.diagonal.validTargets(s)).toEqual([v(2, 2)])
+  })
+})
diff --git a/test/picks.test.ts b/test/picks.test.ts
index 8d549b4..2427104 100644
--- a/test/picks.test.ts
+++ b/test/picks.test.ts
@@ -63,7 +63,7 @@ describe('Move stands for every way of moving', () => {
     const s = board(`
       #####
       #P#.#
-      ##..#
+      #...#
       #####`)
     s.powers.diagonal = true
     expect(resolvePick(s, 'move', v(2, 2))).toBe('diagonal')
diff --git a/tools/balance/lab.ts b/tools/balance/lab.ts
index 2327558..6e7b04f 100644
--- a/tools/balance/lab.ts
+++ b/tools/balance/lab.ts
@@ -19,6 +19,7 @@ import { measureShape, samplePositions, type Shape } from './structure'
  * same boards. The runs are spread over the CPU's cores.
  *
  *   npm run balance -- powers [--seeds 200] [--skill careful|sloppy]   each power-up held from level 1
+ *   npm run balance -- diagonal [--seeds 200] [--skill …]              Diagonal now, and with the old squeeze
  *   npm run balance -- structure [--seeds 60]                          what each power-up changes on real positions
  *   npm run balance -- runs [--seeds 20] [--skill …]                   the baseline, run by run
  *   npm run balance -- trace <seed> <level> [turns]                    one level, board by board
@@ -39,8 +40,24 @@ function powerArms(): Arms {
   return arms
 }
 
+/**
+ * Diagonal as it is (no squeezing, since 2026-10-25) against the old rule that let it step between
+ * two blocked tiles: without it, with it, and with the squeeze back.
+ */
+function diagonalArms(): Arms {
+  return {
+    baseline: () => {},
+    diagonal: (s) => applyPowerUp(s, 'diagonal'),
+    'diagonal, with the old squeeze': (s) => {
+      applyPowerUp(s, 'diagonal')
+      s.options = { ...s.options, lab: { squeeze: true } }
+    },
+  }
+}
+
 const EXPERIMENTS: Record<string, () => Arms> = {
   powers: powerArms,
+  diagonal: diagonalArms,
   // The baseline alone, or with one power-up (`--arm <power-up>`), run by run.
   runs: () => ({ baseline: powerArms()[process.env.BALANCE_ARM ?? 'baseline'] ?? (() => {}) }),
 }
@@ -83,7 +100,7 @@ async function main(): Promise<void> {
   if (command === 'structure') return structure(Number(option(args, 'seeds', '60')))
   if (command === 'record-until') return recordUntil(args[0] ?? 'chargeOffered')
   if (!EXPERIMENTS[command]) {
-    console.log('Commands: powers, structure, runs, trace <seed> <level> [turns]. Options: --seeds N, --skill careful|average|sloppy, --levels N.')
+    console.log('Commands: powers, diagonal, structure, runs, trace <seed> <level> [turns]. Options: --seeds N, --skill careful|average|sloppy, --levels N.')
     return
   }
   const seeds = Number(option(args, 'seeds', command === 'runs' ? '20' : '200'))