fuzzyworld

explodorama / commit

Developer mode adds an extra gem; levels hold a list of gems

39c2681377644b744076edfb4d023c6adb419a0f · Pavel Safronov · 2026-10-23 23:34:54 -0700 · parents 28670c3e55d7

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

Developer mode adds an extra gem; levels hold a list of gems

Developer mode's gem beside the start is now an extra one, in another
colour with its own offer; the level keeps its own gem hidden under its
crate as usual (it used to be that gem, moved). The state's single gem
(powerUp) becomes a list (gems), and burning, revealing, taking and drop
placement work per gem. SIM_VERSION 19.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
 CHANGELOG.md                         |   6 +++---
 PLAN.md                              |   2 +-
 README.md                            |   2 +-
 decisions.md                         |  12 ++++++++++++
 docs/screenshots/almanac-charges.png | Bin 369526 -> 369587 bytes
 docs/screenshots/almanac.png         | Bin 430969 -> 430979 bytes
 docs/screenshots/countdown.png       | Bin 248390 -> 248448 bytes
 docs/screenshots/death.png           | Bin 192485 -> 192554 bytes
 docs/screenshots/demo.png            | Bin 108596 -> 108596 bytes
 docs/screenshots/desktop.png         | Bin 583089 -> 583108 bytes
 docs/screenshots/dev.png             | Bin 245842 -> 245795 bytes
 docs/screenshots/enemies.png         | Bin 238025 -> 238019 bytes
 docs/screenshots/lingering.png       | Bin 248622 -> 248488 bytes
 docs/screenshots/long-press.png      | Bin 247145 -> 247115 bytes
 docs/screenshots/menu.png            | Bin 170808 -> 170738 bytes
 docs/screenshots/move-picked.png     | Bin 243723 -> 243624 bytes
 docs/screenshots/scrolled.png        | Bin 246848 -> 246763 bytes
 docs/screenshots/small-desktop.png   | Bin 271648 -> 271684 bytes
 src/main.ts                          |   2 +-
 src/render/board.ts                  |  11 ++++++-----
 src/render/effects.ts                |   4 ++--
 src/sim/demo.ts                      |   4 ++--
 src/sim/levelgen.ts                  |   2 +-
 src/sim/record.ts                    |   2 +-
 src/sim/state.ts                     |  14 ++++++++-----
 src/sim/turn.ts                      |  37 ++++++++++++++++++-----------------
 src/sim/types.ts                     |  11 ++++++++---
 src/ui/info.ts                       |   9 +++++----
 test/helpers.ts                      |   2 +-
 test/options.test.ts                 |  37 ++++++++++++++++++++++++++++-------
 test/powerups.test.ts                |  12 ++++++------
 31 files changed, 108 insertions(+), 61 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 90073c6..d32601a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -83,9 +83,9 @@ 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` 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.
+- Saved runs from before the gems can't be resumed (`SIM_VERSION` 19).
+- Developer mode now only adds an extra gem next to the player on every level (the level's own
+  gem stays hidden); it no longer gives every ability, extra stats or unlimited charges.
 
 ### Removed
 
diff --git a/PLAN.md b/PLAN.md
index d559b8d..7bf8b6b 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -626,7 +626,7 @@ and state (`GameState.options`), so replays are exact. Shown as tags in the top
 |---|---|
 | **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 Run rule. Levelgen tops up crates if fewer than 2 are far enough for the exit and power-up. |
-| **Developer** | *Since 2026-10-23 (user request), only this:* every level's gem lies uncovered right of the start, every level and after every death, to try the choice at once. (Until then: every ability and pass, 5 bombs, blast 5, unlimited charges shown as ∞; **killable**, split from God mode by the user.) |
+| **Developer** | *Since 2026-10-23 (user request), only this:* an **extra** gem (`PowerUp.extra`, another colour, its own offer) lies right of the start, every level and after every death, to try the choice at once; the level keeps its own gem, hidden as usual (at first it was that gem, moved). (Until then: every ability and pass, 5 bombs, blast 5, unlimited charges shown as ∞; **killable**, split from God mode by the user.) |
 | **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. |
 
diff --git a/README.md b/README.md
index 1891e0e..9231af9 100644
--- a/README.md
+++ b/README.md
@@ -258,7 +258,7 @@ replayed.
 |---|---|
 | **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 level's gem lies uncovered right next to you** at the start (and after a death), to try the power-up choice at once. Nothing else changes. |
+| **Developer** (checkbox) | **An extra gem lies right next to you** at the start of every level (and after a death), in another colour, to try the power-up choice at once. The level's own gem is still hidden under a crate as usual. Nothing else changes. |
 | **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 gem. Not flame: nothing is punished. |
 
diff --git a/decisions.md b/decisions.md
index 56552aa..8e0f7c4 100644
--- a/decisions.md
+++ b/decisions.md
@@ -940,3 +940,15 @@ calls safe are landed on, and never the open exit unless it is the destination.
 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).
+
+### 2026-10-23 — Developer mode's gem is an extra one
+
+User request: the gem next to the player in Developer mode is not the level's gem but an extra
+one, added for the developer. It used to be the level's own gem, moved beside the start, which
+left the level without its hidden gem. A level can now hold several gems (`state.gems`, which
+replaces `powerUp`); Developer mode adds one marked `extra` right of the start, in the next
+colour after the level gem's so the two look different, and it draws its own offer (`'offer-extra'`
+stream) so it doesn't offer the same five as the level's gem. The level's own gem stays hidden
+under its crate exactly as in a normal run. Burning, revealing, taking and drops' "never on a gem"
+work per gem. Tests: the level's gem is unchanged and hidden; the extra one is another colour
+and its offer differs on most seeds; a normal run has one gem. `SIM_VERSION` 19.
diff --git a/docs/screenshots/almanac-charges.png b/docs/screenshots/almanac-charges.png
index accbbd8..d460d53 100644
Binary files a/docs/screenshots/almanac-charges.png and b/docs/screenshots/almanac-charges.png differ
diff --git a/docs/screenshots/almanac.png b/docs/screenshots/almanac.png
index 9d32667..f6f88a5 100644
Binary files a/docs/screenshots/almanac.png and b/docs/screenshots/almanac.png differ
diff --git a/docs/screenshots/countdown.png b/docs/screenshots/countdown.png
index a815c54..c5609b1 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 913089d..0d4e780 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 21197d3..2b171e0 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 c0cfb0c..83428b3 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
index 4d41bde..bd1801e 100644
Binary files a/docs/screenshots/dev.png and b/docs/screenshots/dev.png differ
diff --git a/docs/screenshots/enemies.png b/docs/screenshots/enemies.png
index a6a187c..b6eadfa 100644
Binary files a/docs/screenshots/enemies.png and b/docs/screenshots/enemies.png differ
diff --git a/docs/screenshots/lingering.png b/docs/screenshots/lingering.png
index c1a0e4b..43d66c1 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 174ec4e..032bd2e 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 f955d74..99d0710 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 d938008..f26d04a 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 6b1ede5..19ebf69 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
index a899fe6..35e6590 100644
Binary files a/docs/screenshots/small-desktop.png and b/docs/screenshots/small-desktop.png differ
diff --git a/src/main.ts b/src/main.ts
index b0e6247..4ff134e 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -358,7 +358,7 @@ function newRunDialog(): void {
       `<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 level’s gem lies uncovered right next to you at the start.</span></label>` +
+      `<label><input type="checkbox" name="dev"${checked(o.dev)}><span><b>Developer</b> An extra gem lies right next to you at the start of every level (the level’s own is still hidden).</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>`,
diff --git a/src/render/board.ts b/src/render/board.ts
index a33240c..f9036be 100644
--- a/src/render/board.ts
+++ b/src/render/board.ts
@@ -144,11 +144,12 @@ export function drawBoard(view: View): void {
   if (cellAt(s, s.exit) === 'floor') {
     drawExit(toWorld(s, s.exit), exitOpen(s), appearScale(fx, key.exit, time) * pulseScale(fx, key.exit, time, 0.25))
   }
-  // The level's gem, once its crate is gone, bobbing gently (no glow behind it: user request, 2026-10-23).
-  if (s.powerUp && cellAt(s, s.powerUp.pos) === 'floor') {
-    const pos = toWorld(s, s.powerUp.pos)
-    const k = appearScale(fx, key.powerUp, time)
-    drawTile(pos.add(vec2(0, 0.04 * Math.sin(time * 3))), vec2(0.7 * k), sprite(GEM_SPRITES[s.powerUp.color]))
+  // The gems, once their crate is gone, bobbing gently (no glow behind them: user request, 2026-10-23).
+  for (const gem of s.gems) {
+    if (cellAt(s, gem.pos) !== 'floor') continue
+    const pos = toWorld(s, gem.pos)
+    const k = appearScale(fx, key.gem(gem.pos), time)
+    drawTile(pos.add(vec2(0, 0.04 * Math.sin(time * 3))), vec2(0.7 * k), sprite(GEM_SPRITES[gem.color]))
   }
   // A destroyed crate bursts over whatever it uncovered.
   for (const g of fx.gone) if (g.kind === 'crate') drawGone(s, g)
diff --git a/src/render/effects.ts b/src/render/effects.ts
index 53abf4a..b68e87b 100644
--- a/src/render/effects.ts
+++ b/src/render/effects.ts
@@ -73,7 +73,7 @@ export const key = {
   drop: (p: Vec) => `drop:${p.x},${p.y}`,
   enemy: (id: number) => `enemy:${id}`,
   decoy: 'decoy',
-  powerUp: 'powerUp',
+  gem: (p: Vec) => `gem:${p.x},${p.y}`,
   exit: 'exit',
   shield: 'shield',
 }
@@ -145,7 +145,7 @@ export function startEffects(fx: Effects, prev: GameState, next: GameState, even
         fx.gone.push({ kind: 'burned', sprite: dropSprite(e.drop), pos: e.drop.pos, size: 0.62, at: now })
         break
       case 'powerUpRevealed':
-        fx.appeared.set(key.powerUp, now)
+        fx.appeared.set(key.gem(e.powerUp.pos), now)
         break
       case 'powerUpTaken':
         fx.gone.push({ kind: 'taken', sprite: GEM_SPRITES[e.powerUp.color], pos: e.powerUp.pos, size: 0.7, at: now + TAKE_DELAY })
diff --git a/src/sim/demo.ts b/src/sim/demo.ts
index 6121b0d..fdfa01c 100644
--- a/src/sim/demo.ts
+++ b/src/sim/demo.ts
@@ -386,7 +386,7 @@ function makeRoom(kind: SceneKind, rng: Rng): GameState {
     cells, player: { ...player }, lives: START_LIVES, stats: { ...START_STATS }, bombs: [], status: 'playing',
     enemies: [], nextEnemyId: 0, drops: [], exit: { x: -1, y: -1 }, par: 99, coins: 0,
     boosts: noBoosts(), shield: false, frozen: 0, decoy: null, burning: [],
-    powers: noPowers(), star: 0, powerUp: null, offer: null, runTurns: 0, deaths: 0, crumbling: [],
+    powers: noPowers(), star: 0, gems: [], offer: null, runTurns: 0, deaths: 0, crumbling: [],
     aiRng: rng.int(2 ** 31), dropRng: rng.int(2 ** 31),
   }
   const open = floor().filter((p) => steps(p, player) >= 3)
@@ -436,7 +436,7 @@ function hideGem(s: GameState, rng: Rng): void {
   if (!near.length) return
   const pos = near[rng.int(near.length)]!
   s.cells[idx(s, pos)] = 'block'
-  s.powerUp = { pos, color: (['blue', 'green', 'red', 'yellow'] as const)[rng.int(4)]! }
+  s.gems.push({ pos, color: (['blue', 'green', 'red', 'yellow'] as const)[rng.int(4)]! })
 }
 
 function remember(t: Turn, seen: Seen): void {
diff --git a/src/sim/levelgen.ts b/src/sim/levelgen.ts
index c9371e0..ec50b80 100644
--- a/src/sim/levelgen.ts
+++ b/src/sim/levelgen.ts
@@ -63,7 +63,7 @@ export function lastLevel(size: BoardSize): number {
 }
 
 /** A gem's colour is only how it looks. */
-const GEM_COLORS: readonly GemColor[] = ['blue', 'green', 'red', 'yellow']
+export const GEM_COLORS: readonly GemColor[] = ['blue', 'green', 'red', 'yellow']
 
 export interface EnemyStart {
   kind: EnemyKind
diff --git a/src/sim/record.ts b/src/sim/record.ts
index 9555632..0ba0b39 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 = 18
+export const SIM_VERSION = 19
 
 /** Everything needed to reproduce a run exactly: mid-run saves, replays, certificates. */
 export interface RunRecord {
diff --git a/src/sim/state.ts b/src/sim/state.ts
index 8337bab..521b1aa 100644
--- a/src/sim/state.ts
+++ b/src/sim/state.ts
@@ -1,5 +1,5 @@
 import { planIntents } from './enemies'
-import { generateLevel } from './levelgen'
+import { GEM_COLORS, generateLevel } from './levelgen'
 import { hashSeed } from './rng'
 import { BOMB_BOOST } from './actions'
 import { DEFAULT_OPTIONS, type Bomb, type Boosts, type Cell, type GameState, type Powers, type RunOptions, type Stats, type Vec } from './types'
@@ -58,13 +58,17 @@ export function createLevel(seed: number, level: number, options: RunOptions, ca
     enemies: enemies.map((e, id) => ({ id, kind: e.kind, pos: e.pos, dir: e.dir, plan: [], spawned: false, sheltered: false })),
     nextEnemyId: enemies.length,
     drops: [], exit, par, shield: carry.shield, frozen: 0, decoy: null, burning: [],
-    powers: { ...carry.powers }, star: carry.star, powerUp, offer: null, runTurns: carry.runTurns, deaths: carry.deaths, crumbling: [],
+    powers: { ...carry.powers }, star: carry.star, gems: [powerUp], offer: null, runTurns: carry.runTurns, deaths: carry.deaths, crumbling: [],
     aiRng: hashSeed(seed, 'ai', level), dropRng: hashSeed(seed, 'drops', level),
   }
   // Developer mode: only this (user request, 2026-10-23; it used to give every ability, stats and
-  // unlimited charges). The level's gem lies uncovered right of the start (always open floor),
-  // every level and after every death, to try the choice at once.
-  if (options.dev) s.powerUp = { pos: { x: start.x + 1, y: start.y }, color: powerUp.color }
+  // unlimited charges). An extra gem, in another colour, lies uncovered right of the start (always
+  // open floor), every level and after every death, to try the choice at once; the level keeps
+  // its own gem, hidden as usual (user request: it used to be that gem, moved).
+  if (options.dev) {
+    const color = GEM_COLORS[(GEM_COLORS.indexOf(powerUp.color) + 1) % GEM_COLORS.length]!
+    s.gems.push({ pos: { x: start.x + 1, y: start.y }, color, extra: true })
+  }
   planIntents(s)
   return s
 }
diff --git a/src/sim/turn.ts b/src/sim/turn.ts
index c232cb3..45d7314 100644
--- a/src/sim/turn.ts
+++ b/src/sim/turn.ts
@@ -103,7 +103,7 @@ export function step(prev: GameState, action: Action): StepResult {
   // Phase 3: bombs tick; those at 0 explode, in chains. Last turn's flame is still burning too.
   const lingering = new Set(s.burning)
   const exitWasOpen = cellAt(s, s.exit) === 'floor'
-  const powerUpWasOpen = !!s.powerUp && cellAt(s, s.powerUp.pos) === 'floor'
+  const gemsOpen = s.gems.filter((g) => cellAt(s, g.pos) === 'floor')
   const enemiesBefore = s.enemies.length
   tickBombs(s, events)
   const burned = detonate(s, events)
@@ -138,15 +138,14 @@ export function step(prev: GameState, action: Action): StepResult {
   if (!exitWasOpen && cellAt(s, s.exit) === 'floor') events.push({ type: 'exitRevealed', pos: s.exit })
   // Flaming an uncovered exit lets out a group of the level's toughest type (NES rule, PLAN.md §5).
   if (exitWasOpen && burned.has(idx(s, s.exit))) spawnPunishment(s, s.exit, events)
-  // The same for the level's power-up: flame on it once uncovered destroys it and lets enemies out.
-  if (s.powerUp) {
-    const at = idx(s, s.powerUp.pos)
-    if (powerUpWasOpen && burned.has(at)) {
-      events.push({ type: 'powerUpBurned', powerUp: s.powerUp })
-      spawnPunishment(s, s.powerUp.pos, events)
-      s.powerUp = null
-    } else if (!powerUpWasOpen && cellAt(s, s.powerUp.pos) === 'floor') {
-      events.push({ type: 'powerUpRevealed', powerUp: s.powerUp })
+  // The same for a gem: flame on it once uncovered destroys it and lets enemies out.
+  for (const gem of [...s.gems]) {
+    if (gemsOpen.includes(gem) && burned.has(idx(s, gem.pos))) {
+      events.push({ type: 'powerUpBurned', powerUp: gem })
+      spawnPunishment(s, gem.pos, events)
+      s.gems.splice(s.gems.indexOf(gem), 1)
+    } else if (!gemsOpen.includes(gem) && cellAt(s, gem.pos) === 'floor') {
+      events.push({ type: 'powerUpRevealed', powerUp: gem })
     }
   }
   if (enemiesBefore > 0 && s.enemies.length === 0) {
@@ -187,7 +186,7 @@ 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.gems.some((g) => eq(g.pos, p) && cellAt(s, p) === 'floor') ||
     (eq(s.exit, p) && cellAt(s, p) === 'floor')
   const seen = new Set([idx(s, from)])
   let ring = [from]
@@ -240,7 +239,7 @@ function spawnPunishment(s: GameState, pos: Vec, events: GameEvent[]): void {
   events.push({ type: 'enemiesSpawned', pos: { ...pos }, count: EXIT_SPAWN })
 }
 
-/** Collect every drop at `p`, and the level's gem if it lies there uncovered (which sets up its offer). */
+/** Collect every drop at `p`, and a gem if one lies there uncovered (which sets up its offer). */
 function pickUp(s: GameState, p: Vec, events: GameEvent[]): void {
   for (const d of s.drops.filter((d) => eq(d.pos, p))) {
     s.drops.splice(s.drops.indexOf(d), 1)
@@ -248,10 +247,11 @@ function pickUp(s: GameState, p: Vec, events: GameEvent[]): void {
     else applyCharge(s, d.kind, events)
     events.push({ type: 'pickedUp', drop: d })
   }
-  if (s.powerUp && eq(s.powerUp.pos, p) && cellAt(s, p) === 'floor') {
-    s.offer = drawOffer(s)
-    events.push({ type: 'powerUpTaken', powerUp: s.powerUp, offer: s.offer })
-    s.powerUp = null
+  const gem = s.gems.find((g) => eq(g.pos, p) && cellAt(s, p) === 'floor')
+  if (gem) {
+    s.offer = drawOffer(s, gem.extra)
+    events.push({ type: 'powerUpTaken', powerUp: gem, offer: s.offer })
+    s.gems.splice(s.gems.indexOf(gem), 1)
   }
 }
 
@@ -279,8 +279,9 @@ const HELD_AS: Partial<Record<PowerUpKind, keyof Powers>> = {
  * POWER_UP_WEIGHTS and listed in its order. From the seed and level, so a retry after a death
  * offers the same.
  */
-export function drawOffer(s: GameState): PowerUpKind[] {
-  const rng = stream(s.seed, 'offer', s.level)
+export function drawOffer(s: GameState, extra = false): PowerUpKind[] {
+  // Developer mode's extra gem draws its own offer, not the level gem's.
+  const rng = stream(s.seed, extra ? 'offer-extra' : 'offer', s.level)
   const pool = POWER_UP_WEIGHTS.filter(([kind]) => {
     const held = HELD_AS[kind]
     return !held || !s.powers[held]
diff --git a/src/sim/types.ts b/src/sim/types.ts
index 6bbd6d9..dd53fc3 100644
--- a/src/sim/types.ts
+++ b/src/sim/types.ts
@@ -61,7 +61,7 @@ export type BoardSize = 'normal' | 'small'
 export interface RunOptions {
   /** normal: 31 × 13, 50 levels. small: 9 × 11, 20 levels, fewer enemies and crates. */
   size: BoardSize
-  /** Developer mode: every level's gem lies uncovered right of the start, to try the choice at once. Nothing else. */
+  /** Developer mode: an extra gem lies right of the start, to try the choice at once; the level keeps its own. Nothing else. */
   dev: boolean
   /** God mode: nothing can kill the player. */
   god: boolean
@@ -86,6 +86,8 @@ export type GemColor = 'blue' | 'green' | 'red' | 'yellow'
 export interface PowerUp {
   pos: Vec
   color: GemColor
+  /** Developer mode's extra gem, beside the start; the level keeps its own (user request, 2026-10-23). */
+  extra?: boolean
 }
 
 /**
@@ -201,8 +203,11 @@ export interface GameState {
   deaths: number
   /** Turns of Star invincibility left. */
   star: number
-  /** This level's gem: hidden while its block stands, then lying in the open until taken. Null once taken or burned. */
-  powerUp: PowerUp | null
+  /**
+   * This level's gems: hidden while its block stands, then lying in the open until taken; gone
+   * once taken or burned. One per level, and in Developer mode an extra one (since 2026-10-23).
+   */
+  gems: PowerUp[]
   /**
    * The power-ups the gem just taken offers. While it is set, the only action is `choose`; a
    * death (or game over) in the same turn drops it, and the retry has the gem back.
diff --git a/src/ui/info.ts b/src/ui/info.ts
index 284f475..3733958 100644
--- a/src/ui/info.ts
+++ b/src/ui/info.ts
@@ -80,7 +80,7 @@ export type HudItem = 'modes' | 'lives' | 'level' | 'enemies' | 'coins' | 'turn'
 /** 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 level’s gem lies uncovered right next to you at the start.')
+  if (s.options.dev) lines.push('Developer mode: an extra gem lies right next to you at the start of every level. The level’s own gem is still hidden under a crate.')
   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
@@ -222,10 +222,11 @@ export function tileInfo(s: GameState, p: Vec): Info | null {
       body: ['Flame destroys it (and stops there). On every level one crate hides the exit and another hides a gem.'],
     }
   }
-  if (s.powerUp && eq(s.powerUp.pos, p)) {
+  const gem = s.gems.find((g) => eq(g.pos, p))
+  if (gem) {
     return {
-      icon: GEM_SPRITES[s.powerUp.color],
-      title: 'Gem',
+      icon: GEM_SPRITES[gem.color],
+      title: gem.extra ? 'Gem (Developer mode’s extra one)' : 'Gem',
       body: [`Walk onto it to choose one of ${OFFER_SIZE} power-ups. The colour doesn’t matter.`, PUNISH],
     }
   }
diff --git a/test/helpers.ts b/test/helpers.ts
index b7c2ec9..5348a7e 100644
--- a/test/helpers.ts
+++ b/test/helpers.ts
@@ -21,7 +21,7 @@ export function board(picture: string, stats: Partial<Stats> = {}): GameState {
     lives: START_LIVES, stats: { ...START_STATS, ...stats }, bombs: [], status: 'playing',
     enemies: [], nextEnemyId: 0, drops: [], exit: { x: -1, y: -1 }, par: 100, coins: 0,
     boosts: noBoosts(), shield: false, frozen: 0, decoy: null, burning: [],
-    powers: noPowers(), star: 0, powerUp: null, offer: null, runTurns: 0, deaths: 0, crumbling: [], aiRng: 1, dropRng: 1,
+    powers: noPowers(), star: 0, gems: [], offer: 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
index 414d656..c6418d8 100644
--- a/test/options.test.ts
+++ b/test/options.test.ts
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
 import { enemyCount, generateLevel, lastLevel, SIZES, START } from '../src/sim/levelgen'
 import { newRecord, replay } from '../src/sim/record'
 import { newRun } from '../src/sim/state'
-import { step } from '../src/sim/turn'
+import { drawOffer, step } from '../src/sim/turn'
 import { DEFAULT_OPTIONS, type Vec } from '../src/sim/types'
 import { board, v } from './helpers'
 
@@ -79,22 +79,45 @@ describe('developer mode', () => {
     const dead = step(s, { id: 'move', target: { x: 1, y: 2 } }).state
     expect(dead.status).toBe('dead')
     const again = step(dead, { id: 'continue' }).state
-    expect(again.powerUp?.pos).toEqual({ x: START.x + 1, y: START.y })
+    expect(again.gems.find((g) => g.extra)?.pos).toEqual({ x: START.x + 1, y: START.y })
   })
 
-  it('starts every level with the gem uncovered right of the player', () => {
+  it('starts every level with an extra gem uncovered right of the player', () => {
     for (const level of [1, 7]) {
       const s = newRun(5, level, 0, dev)
-      expect(s.powerUp?.pos).toEqual({ x: START.x + 1, y: START.y })
+      const extra = s.gems.find((g) => g.extra)!
+      expect(extra.pos).toEqual({ x: START.x + 1, y: START.y })
       expect(s.cells[START.y * s.width + START.x + 1]).toBe('floor')
-      const { state } = step(s, { id: 'move', target: s.powerUp!.pos })
+      const { state } = step(s, { id: 'move', target: extra.pos })
       expect(state.offer?.length).toBeGreaterThan(0)
     }
   })
 
-  it('a normal run has no gem in the open at the start', () => {
+  it('keeps the level\'s own gem, hidden under its crate as usual (user request, 2026-10-23)', () => {
+    for (let seed = 1; seed <= 20; seed++) {
+      const s = newRun(seed, 1, 0, dev)
+      const own = s.gems.filter((g) => !g.extra)
+      expect(own).toHaveLength(1)
+      expect(own[0]).toEqual(newRun(seed).gems[0])
+      expect(s.cells[own[0]!.pos.y * s.width + own[0]!.pos.x]).toBe('block')
+    }
+  })
+
+  it('the extra gem is another colour, and offers its own choice', () => {
+    let differ = 0
+    for (let seed = 1; seed <= 20; seed++) {
+      const s = newRun(seed, 1, 0, dev)
+      const [own, extra] = [s.gems.find((g) => !g.extra)!, s.gems.find((g) => g.extra)!]
+      expect(extra.color).not.toBe(own.color)
+      if (JSON.stringify(drawOffer(s, true)) !== JSON.stringify(drawOffer(s))) differ++
+    }
+    expect(differ).toBeGreaterThan(10)
+  })
+
+  it('a normal run has just the one gem, under a crate', () => {
     const s = newRun(5)
-    expect(s.cells[s.powerUp!.pos.y * s.width + s.powerUp!.pos.x]).toBe('block')
+    expect(s.gems).toHaveLength(1)
+    expect(s.cells[s.gems[0]!.pos.y * s.width + s.gems[0]!.pos.x]).toBe('block')
   })
 })
 
diff --git a/test/powerups.test.ts b/test/powerups.test.ts
index 22d06aa..9b99acd 100644
--- a/test/powerups.test.ts
+++ b/test/powerups.test.ts
@@ -23,7 +23,7 @@ function withGem(picture = `
   #P...#
   ######`): GameState {
   const s = board(picture)
-  s.powerUp = { pos: v(2, 1), color: 'red' }
+  s.gems = [{ pos: v(2, 1), color: 'red' }]
   return s
 }
 
@@ -86,7 +86,7 @@ describe('taking the gem', () => {
   it('sets up the offer and gives nothing yet; the gem is gone', () => {
     const { state, events } = step(withGem(), { id: 'move', target: v(2, 1) })
     expect(state.offer).toEqual(drawOffer(withGem()))
-    expect(state.powerUp).toBeNull()
+    expect(state.gems).toEqual([])
     expect(state.stats).toEqual(withGem().stats)
     expect(events).toContainEqual({ type: 'powerUpTaken', powerUp: { pos: v(2, 1), color: 'red' }, offer: state.offer })
   })
@@ -175,10 +175,10 @@ describe('the power-ups', () => {
       ######
       #P.1+#
       ######`)
-    s.powerUp!.pos = v(4, 1)
+    s.gems[0]!.pos = v(4, 1)
     const { state, events } = step(s, { id: 'wait' })
     expect(events).toContainEqual({ type: 'powerUpRevealed', powerUp: { pos: v(4, 1), color: 'red' } })
-    expect(state.powerUp).not.toBeNull()
+    expect(state.gems).toHaveLength(1)
   })
 
   it('flame on it once uncovered destroys it and lets enemies out', () => {
@@ -186,9 +186,9 @@ describe('the power-ups', () => {
       ######
       #P..1#
       ######`)
-    s.powerUp!.pos = v(3, 1)
+    s.gems[0]!.pos = v(3, 1)
     const { state, events } = step(s, { id: 'wait' })
-    expect(state.powerUp).toBeNull()
+    expect(state.gems).toEqual([])
     expect(events.map((e) => e.type)).toContain('powerUpBurned')
     expect(state.enemies).toHaveLength(EXIT_SPAWN)
   })