fuzzyworld

explodorama / commit

WebGL renderer again; Developer mode's gem is a rainbow gem offering all

6e94f995cfec181e9ef1e5c26fa6c8205ff8b7f1 · Pavel Safronov · 2026-10-24 07:48:22 -0700 · parents 0b25f1ef71ac

Claude Code usage: this commit ~57.9K out / ~10.3M cached · cost unknown (no per-model trailer) · session 4 tally 200 in / 82.3K out / 12.7M cached · usage by commit and session

WebGL renderer again; Developer mode's gem is a rainbow gem offering all

The board is drawn with WebGL again. The black strip that made us switch
to Canvas 2D came from Playwright's default headless shell, not the game:
the full Chromium in new headless mode, headed Chromium, SwiftShader and
Metal all draw it correctly. tools/screenshots.mjs now launches
channel 'chromium'. The frozen enemies' sparkles add white with zero
alpha, since WebGL adds an added colour's alpha to the sprite's.

Developer mode's extra gem is a rainbow gem (gem_rainbow, built by
tools/compose-icons.sh from the green gem): it turns like a coin,
flashes as it faces you, and colour-cycling sparkles circle it. It
offers every power-up that would change something (abilities and passes
already held are left out) instead of drawing 5. New dev-offer
screenshot. SIM_VERSION 23.

Usage: 0.2K in / 82.3K out / 12.7M cached (session tally)
rtk:   1.8M saved, 39.6% over 3611 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
 CHANGELOG.md                         |   4 ++++
 PLAN.md                              |   8 ++++----
 README.md                            |   6 +++---
 assets/composed/gem_rainbow.png      | Bin 0 -> 3436 bytes
 decisions.md                         |  29 +++++++++++++++++++++++++++++
 docs/screenshots/almanac-charges.png | Bin 266721 -> 268407 bytes
 docs/screenshots/almanac-enemies.png | Bin 193330 -> 196087 bytes
 docs/screenshots/almanac.png         | Bin 376988 -> 380266 bytes
 docs/screenshots/auto-move.png       | Bin 186317 -> 216468 bytes
 docs/screenshots/bomb-picked.png     | Bin 186347 -> 216338 bytes
 docs/screenshots/charges.png         | Bin 199175 -> 227866 bytes
 docs/screenshots/countdown.png       | Bin 190124 -> 221002 bytes
 docs/screenshots/death.png           | Bin 174974 -> 187808 bytes
 docs/screenshots/demo.png            | Bin 113560 -> 130427 bytes
 docs/screenshots/desktop.png         | Bin 276016 -> 313907 bytes
 docs/screenshots/dev-offer.png       | Bin 0 -> 209714 bytes
 docs/screenshots/dev.png             | Bin 188427 -> 219651 bytes
 docs/screenshots/enemies.png         | Bin 189773 -> 219900 bytes
 docs/screenshots/gem-offer.png       | Bin 184236 -> 214049 bytes
 docs/screenshots/gem.png             | Bin 184092 -> 213818 bytes
 docs/screenshots/info-enemy.png      | Bin 125422 -> 134376 bytes
 docs/screenshots/info-hud.png        | Bin 126858 -> 135321 bytes
 docs/screenshots/info-mode.png       | Bin 185411 -> 215488 bytes
 docs/screenshots/lingering.png       | Bin 189098 -> 219430 bytes
 docs/screenshots/long-press.png      | Bin 185538 -> 215678 bytes
 docs/screenshots/menu.png            | Bin 124197 -> 136917 bytes
 docs/screenshots/move-picked.png     | Bin 186317 -> 216468 bytes
 docs/screenshots/new-run.png         | Bin 172427 -> 184947 bytes
 docs/screenshots/nuke.png            | Bin 209394 -> 239964 bytes
 docs/screenshots/start.png           | Bin 185572 -> 215700 bytes
 src/main.ts                          |  10 +++++-----
 src/render/board.ts                  |  29 ++++++++++++++++++++++++++---
 src/render/sprites.ts                |   7 ++++++-
 src/sim/record.ts                    |   2 +-
 src/sim/state.ts                     |  14 ++++++--------
 src/sim/turn.ts                      |   7 ++++---
 src/sim/types.ts                     |  17 +++++++++++++----
 src/ui/info.ts                       |  11 ++++++++---
 test/options.test.ts                 |  30 ++++++++++++++++++++----------
 tools/compose-icons.sh               |  17 +++++++++++++++++
 tools/screenshots.mjs                |   8 ++++++--
 41 files changed, 152 insertions(+), 47 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4093cbc..f60545a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -101,6 +101,10 @@ saved before the change is dropped and the next visit starts a new run.
 - Saved runs from before the gems can't be resumed (`SIM_VERSION` 22).
 - 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.
+- Developer mode's extra gem is a **rainbow gem** that turns and glitters, so it can't be taken
+  for the level's own, and it offers **every power-up you don't have yet** instead of 5
+  (`SIM_VERSION` 23).
+- The board is drawn with **WebGL** again (it was Canvas 2D).
 
 ### Removed
 
diff --git a/PLAN.md b/PLAN.md
index 614b713..8983246 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -584,8 +584,8 @@ plan of black Icongeek26 outline icons from Flaticon over a plain drawn board.
   pack. All are 128 × 128: tiles and enemies from the pack's `Double` folders, characters from
   `Default` (which are already 128 px).
 - **Loading:** no atlas. `src/render/sprites.ts` lists the board's sprites; each is its own LittleJS
-  texture (`engineInit`'s image list) and is drawn with `drawTile`. With the Canvas 2D renderer
-  separate images cost nothing measurable. `setTilesPixelated(false)`, since the art is smooth.
+  texture (`engineInit`'s image list) and is drawn with `drawTile`. Separate images cost nothing
+  measurable (with Canvas 2D at first, and with WebGL since 2026-10-24). `setTilesPixelated(false)`, since the art is smooth.
   The HTML HUD and buttons use the same PNGs as `<img>`. Vite keeps every sprite a separate file
   (`assetsInlineLimit: 0`), so art for things not in the game yet is never downloaded.
 - **Board look:** sprites everywhere. Colour still shows meaning, but **shape alone must already
@@ -694,7 +694,7 @@ parameters: `?dev`, `?immortal`.
 |---|---|
 | ~~**Board: Normal**~~ | *Removed 2026-10-23.* 31 × 13, density 35%, 50 levels, enemies 3 + 1 per 3 levels (max 10), par 50 + 10/enemy. |
 | ~~**Board: Small**~~ | *Now the only board (no longer an option).* 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. 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:* 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). Achievements count, with a red border (the victory banner is framed in red). (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`; since 2026-10-24 a turning, glittering rainbow gem offering every power-up not held yet) 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). Achievements count, with a red border (the victory banner is framed in red). (Until then: every ability and pass, 5 bombs, blast 5, unlimited charges shown as ∞; **killable**, split from God mode by the user.) |
 | **Immortal** (called God until 2026-10-23) | The player can't be hurt at all, so the run can be played to the end — but **an Immortal run earns no achievements** (the victory certificate, the Workshop's rewards, when they exist). |
 | ~~**Crumbling crates**~~ | *Always on since 2026-10-23 (no longer an option).* 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. |
 
@@ -740,7 +740,7 @@ URL test parameters `?small`, `?dev`, `?god`, `?crumble` set them for a test boa
   `drawTile(… color, angle, mirror, additiveColor …)`, `drawTextScreen`,
   `setTouchInputEnable`, `drawRect`, `drawCircle`, `drawText`, `screenToWorld`,
   `setCanvasPixelRatio`, `setGLEnable`. Still check: `tile()`, `TileLayer`, `zzfx`.
-- **The game uses LittleJS's Canvas 2D renderer** (`setGLEnable(false)`), and handles taps with
+- **The game uses LittleJS's WebGL renderer** (Canvas 2D from 2026-10-19 to 2026-10-24), and handles taps with
   pointer events on the canvas — see `decisions.md` for why.
 - **LittleJS world space is y-up**; the sim grid is row 0 = top. Convert in exactly one place
   (`render/board.ts`), e.g. `worldY = (rows - 1) - row`.
diff --git a/README.md b/README.md
index 3cb681b..241237f 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@ seven charges that work when picked up, a hidden gem on every level that offers
 mid-run work. The shop, the Keep / Fresh choice on death, curses and more enemies are planned —
 see [PLAN.md](PLAN.md).
 
-Version: **22**, shown at the bottom of the menu with the commit the game was built from (a link
+Version: **23**, 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
@@ -265,7 +265,7 @@ modes (there is one board size, and crates always crumble once the enemies are g
 
 | Mode | What it does |
 |---|---|
-| **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. Achievements still count, but with a red border (the victory banner is framed in red), like the red quarters some bars kept for their own jukebox plays. |
+| **Developer** (checkbox) | **An extra, rainbow gem lies right next to you** at the start of every level (and after a death), turning and glittering so it can't be mistaken for the level's own. It offers **every power-up you don't have yet** (not the usual 5), to try any of them at once. The level's own gem is still hidden under a crate as usual. Achievements still count, but with a red border (the victory banner is framed in red), like the red quarters some bars kept for their own jukebox plays. |
 | **Immortal** (checkbox) | Nothing can kill you, so you can play through to the end — but an Immortal run earns no achievements. (It was called God.) |
 
 **Seed:** the New run dialog also has an optional seed field. Empty means a new, random
@@ -342,7 +342,7 @@ latest version.
   new state plus a list of events. The danger overlay runs the same code as a real turn, so it
   cannot disagree with what happens.
 - **`src/render/`** — draws the board with [LittleJS](https://github.com/KilledByAPixel/LittleJS)
-  (pinned to an exact version, using its Canvas 2D renderer).
+  (pinned to an exact version, using its WebGL renderer).
 - **`src/render/sprites.ts`** — the sprite list. The art lives in `assets/kenney/` under the
   pack's own file names.
 - **`src/ui/`** — the HUD, action bar and banners, as plain HTML, using the same sprites as icons.
diff --git a/assets/composed/gem_rainbow.png b/assets/composed/gem_rainbow.png
new file mode 100644
index 0000000..a64fd34
Binary files /dev/null and b/assets/composed/gem_rainbow.png differ
diff --git a/decisions.md b/decisions.md
index 3a25af2..86cc9f1 100644
--- a/decisions.md
+++ b/decisions.md
@@ -32,6 +32,8 @@ Exact version, the newest release that was at least two weeks old at install tim
 
 ### 2026-10-19 — Canvas 2D renderer, not WebGL
 
+*Superseded 2026-10-24: WebGL is back (see "WebGL again; Developer mode's gem is a rainbow gem").*
+
 `setGLEnable(false)`. Headless Chromium's full-viewport screenshots of LittleJS's WebGL canvas
 showed a black strip below the HUD that is not in the drawing (a clipped screenshot of the same
 page didn't show it, and Canvas 2D doesn't show it at all). Screenshots are how changes are
@@ -1090,3 +1092,30 @@ User request: a version number in the menu and the README, using the `SIM_VERSIO
   is read from git when Vite starts (`vite.config.ts`, as `VITE_COMMIT`), so the deployed build
   shows the commit CI built. A build with uncommitted changes says "plus local changes", since
   the hash alone would claim code it doesn't contain; with no git, the commit is left out.
+
+### 2026-10-24 — WebGL again; Developer mode's gem is a rainbow gem offering everything
+
+User request: try the WebGL renderer again, and make Developer mode's extra gem "some sort of
+rainbow/glittering/strange gem", clearly a very special one, offering every possible choice
+(minus what the player already has) instead of the usual 5.
+
+- **WebGL is back** (`setGLEnable(true)`; supersedes "Canvas 2D renderer, not WebGL"). The black
+  strip in the screenshots came from Playwright's default *headless shell*: with the same build,
+  the full Chromium in its new headless mode (`channel: 'chromium'`), headed Chromium, and the
+  SwiftShader and Metal back ends all draw it correctly. `tools/screenshots.mjs` now launches
+  `channel: 'chromium'`, so the screenshots stay trustworthy. Still separate textures, no atlas:
+  a few hundred sprites a frame don't need batching across textures.
+- **One WebGL difference fixed:** an added colour's alpha is added to the sprite's alpha, so the
+  frozen enemies' sparkles (added white, alpha 1) would have drawn as white squares; they add
+  `C.glint` (white, alpha 0) instead.
+- **The rainbow gem** (`GemColor` `'rainbow'`, only the extra gem; the level's gems keep the four
+  colours): `assets/composed/gem_rainbow.png`, built by `tools/compose-icons.sh` from the green
+  gem — its body in a red-to-violet rainbow over its own shading, the outline unchanged (Kenney
+  art from the packs already in use). On the board it turns like a coin, flashes as it faces you,
+  and five sparkles circle it, each a flat colour cycling through the hues. Its info title is
+  "Rainbow gem (Developer mode)".
+- **Its offer is every power-up that would do anything**: abilities and passes already held
+  are left out; stats, Star, Heart and Coin bag are always in. Listed in the usual order, with
+  nothing drawn (so no random stream). The dialog scrolls (15 at most). `SIM_VERSION` 23, since a
+  Developer run saved before would replay differently.
+- Screenshots: `dev` shows the rainbow gem; new `dev-offer` shows its choice.
diff --git a/docs/screenshots/almanac-charges.png b/docs/screenshots/almanac-charges.png
index 072fc4a..7100d87 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 77dedb8..2641663 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 0e591e1..3bc7dfe 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 07b8e88..45d9cde 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 61534f9..507d23a 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 41ea440..a0ce9e1 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 13a9562..9c841a1 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 e0de79f..8a80595 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 c46ef65..5ae57d2 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 4b04e59..bee5ea1 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
new file mode 100644
index 0000000..2df73ef
Binary files /dev/null and b/docs/screenshots/dev-offer.png differ
diff --git a/docs/screenshots/dev.png b/docs/screenshots/dev.png
index a4a4977..acc1a76 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 e24d98b..09026d0 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 4b17d69..1dc2be7 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 143b47c..0a7b88c 100644
Binary files a/docs/screenshots/gem.png and b/docs/screenshots/gem.png differ
diff --git a/docs/screenshots/info-enemy.png b/docs/screenshots/info-enemy.png
index 4fb42e9..9160775 100644
Binary files a/docs/screenshots/info-enemy.png and b/docs/screenshots/info-enemy.png differ
diff --git a/docs/screenshots/info-hud.png b/docs/screenshots/info-hud.png
index a38c79c..a1f1c40 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 1e548e7..24f1907 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 ed540c6..f06c35f 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 a16dfd9..b9527f6 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 7fc5cf0..b525d58 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 07b8e88..45d9cde 100644
Binary files a/docs/screenshots/move-picked.png and b/docs/screenshots/move-picked.png differ
diff --git a/docs/screenshots/new-run.png b/docs/screenshots/new-run.png
index 5f35fec..9ec2aad 100644
Binary files a/docs/screenshots/new-run.png and b/docs/screenshots/new-run.png differ
diff --git a/docs/screenshots/nuke.png b/docs/screenshots/nuke.png
index 0fc6f6a..fc10290 100644
Binary files a/docs/screenshots/nuke.png and b/docs/screenshots/nuke.png differ
diff --git a/docs/screenshots/start.png b/docs/screenshots/start.png
index 28b18f5..83cf85f 100644
Binary files a/docs/screenshots/start.png and b/docs/screenshots/start.png differ
diff --git a/src/main.ts b/src/main.ts
index 36a42f1..281d9aa 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -390,7 +390,7 @@ function newRunDialog(): void {
     body:
       warning +
       `<fieldset class="choices"><legend>Modes</legend>` +
-      `<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). Achievements still count, marked with a red border.</span></label>` +
+      `<label><input type="checkbox" name="dev"${checked(o.dev)}><span><b>Developer</b> An extra, rainbow gem lies right next to you at the start of every level, offering every power-up you don’t have yet (the level’s own is still hidden). Achievements still count, marked with a red border.</span></label>` +
       `<label><input type="checkbox" name="immortal"${checked(o.immortal)}><span><b>Immortal</b> Nothing can kill you. You can play through to the end, but an Immortal run earns no achievements.</span></label>` +
       `</fieldset><fieldset class="choices"><legend>Seed</legend>` +
       `<label class="seed"><input type="text" name="seed" inputmode="numeric" autocomplete="off" maxlength="40" placeholder="random"><span>Leave it empty for a new dungeon, or type the seed of one to play it (shown in the menu and at the end of a run).</span></label>` +
@@ -698,10 +698,10 @@ if (!testing || params.has('demo')) startDemo()
 setDebugWatermark(false)
 // Sharp on high-density screens, capped so phones don't pay for 3×.
 setCanvasPixelRatio(Math.min(devicePixelRatio, 2))
-// Canvas 2D, not WebGL: a board of a few hundred sprites doesn't need it, and headless
-// Chromium's screenshots of the WebGL canvas showed a black strip that isn't in the drawing,
-// which made the screenshots untrustworthy for review (decisions.md).
-setGLEnable(false)
+// WebGL. It was Canvas 2D from 2026-10-19 to 2026-10-24, because screenshots of the WebGL canvas
+// showed a black strip that isn't in the drawing; that was Playwright's headless shell, and the
+// screenshots now use the full Chromium (decisions.md).
+setGLEnable(true)
 // The sprites are smooth vector-style art drawn smaller than their 128 px, not pixel art.
 setTilesPixelated(false)
 
diff --git a/src/render/board.ts b/src/render/board.ts
index fbcb724..668e92e 100644
--- a/src/render/board.ts
+++ b/src/render/board.ts
@@ -1,4 +1,4 @@
-import { drawCircle, drawLine, drawPoly, drawRect, drawTile, rgb, time, vec2, type Color, type Vector2 } from 'littlejsengine'
+import { drawCircle, drawLine, drawPoly, drawRect, drawTile, hsl, rgb, time, vec2, type Color, type Vector2 } from 'littlejsengine'
 import { ACTIONS, isAvailable } from '../sim/actions'
 import { pickTargets, resolvePick } from '../sim/picks'
 import type { Danger } from '../sim/danger'
@@ -77,6 +77,9 @@ const C = {
   preview: rgb(1, 0.55, 0.1, 0.4),
   flame: rgb(1, 0.6, 0.1),
   white: rgb(1, 1, 1),
+  black: rgb(0, 0, 0),
+  /** Added to a sprite to turn it white. No alpha: with WebGL an added alpha fills its whole square. */
+  glint: rgb(1, 1, 1, 0),
   ghost: rgb(1, 1, 1, 0.45),
   intent: rgb(0.85, 0.2, 0.2),
   shield: rgb(0.25, 0.62, 0.87, 0.9),
@@ -145,7 +148,9 @@ export function drawBoard(view: View): void {
     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]))
+    const at = pos.add(vec2(0, 0.04 * Math.sin(time * 3)))
+    if (gem.color === 'rainbow') drawRainbowGem(at, 0.7 * k)
+    else drawTile(at, 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)
@@ -390,7 +395,25 @@ function drawIce(pos: Vector2, size: number, frost: number, seed: number): void
     const a = seed * 1.7 + i * 2.1
     const twinkle = Math.max(0, Math.sin(time * 3 + a * 3))
     const p = pos.add(vec2(Math.cos(a) * h * 0.85, Math.sin(a) * h * 0.85))
-    drawTile(p, vec2(0.18 * twinkle * frost + 0.01), sprite('star'), C.white.scale(1, frost), time + a, false, C.white)
+    drawTile(p, vec2(0.18 * twinkle * frost + 0.01), sprite('star'), C.white.scale(1, frost), time + a, false, C.glint)
+  }
+}
+
+/**
+ * Developer mode's extra gem (user request, 2026-10-24: a very special gem, unlike the level's
+ * own): the rainbow gem turning like a coin, flashing as it faces you, with sparkles in every
+ * colour twinkling as they circle it.
+ */
+function drawRainbowGem(pos: Vector2, size: number): void {
+  const turn = Math.cos(time * 2.2)
+  const flash = Math.max(0, Math.abs(turn) - 0.85) / 0.15
+  drawTile(pos, vec2(size * Math.max(0.12, Math.abs(turn)), size), sprite('gem_rainbow'), C.white, 0, turn < 0, rgb(0.4, 0.4, 0.4, 0).scale(flash, 0))
+  for (let i = 0; i < 5; i++) {
+    const a = time * 0.9 + (i * 2 * Math.PI) / 5
+    const twinkle = Math.max(0, Math.sin(time * 4 + i * 2.3))
+    const p = pos.add(vec2(Math.cos(a), Math.sin(a)).scale(size * 0.62))
+    // The star's shape in a flat colour: none of its own yellow, all of the added hue.
+    drawTile(p, vec2(size * 0.4 * twinkle + 0.01), sprite('star'), C.black, -time * 2 + i, false, hsl((time * 0.3 + i / 5) % 1, 1, 0.6, 0))
   }
 }
 
diff --git a/src/render/sprites.ts b/src/render/sprites.ts
index 667a693..75bcc9b 100644
--- a/src/render/sprites.ts
+++ b/src/render/sprites.ts
@@ -93,6 +93,7 @@ const BOARD = [
   'gem_green',
   'gem_red',
   'gem_yellow',
+  'gem_rainbow',
   'charge_nuke',
   'charge_shield',
   'charge_freeze',
@@ -178,12 +179,16 @@ export function coinSprite(amount: number): BoardSprite {
   return amount >= 5 ? 'coin_gold' : amount >= 2 ? 'coin_silver' : 'coin_bronze'
 }
 
-/** A level's gem, in its colour (user request, 2026-10-23). */
+/**
+ * A level's gem, in its colour (user request, 2026-10-23). The rainbow one, Developer mode's
+ * extra gem, is the green gem recoloured by tools/compose-icons.sh (2026-10-24).
+ */
 export const GEM_SPRITES: Record<GemColor, BoardSprite> = {
   blue: 'gem_blue',
   green: 'gem_green',
   red: 'gem_red',
   yellow: 'gem_yellow',
+  rainbow: 'gem_rainbow',
 }
 
 /**
diff --git a/src/sim/record.ts b/src/sim/record.ts
index 58f504b..7b6714b 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 = 22
+export const SIM_VERSION = 23
 
 /** 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 1dd5b7e..b3a4313 100644
--- a/src/sim/state.ts
+++ b/src/sim/state.ts
@@ -1,5 +1,5 @@
 import { planIntents } from './enemies'
-import { GEM_COLORS, generateLevel } from './levelgen'
+import { 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'
@@ -63,13 +63,11 @@ export function createLevel(seed: number, level: number, options: RunOptions, ca
     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). 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 })
-  }
+  // unlimited charges). An extra gem 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). It is a rainbow gem, so it can't be
+  // taken for the level's own (user request, 2026-10-24).
+  if (options.dev) s.gems.push({ pos: { x: start.x + 1, y: start.y }, color: 'rainbow', extra: true })
   planIntents(s)
   return s
 }
diff --git a/src/sim/turn.ts b/src/sim/turn.ts
index 6611228..9eccb60 100644
--- a/src/sim/turn.ts
+++ b/src/sim/turn.ts
@@ -293,15 +293,16 @@ const HELD_AS: Partial<Record<PowerUpKind, keyof Powers>> = {
 /**
  * A gem's offer: OFFER_SIZE different power-ups (fewer if fewer would do anything), drawn by
  * POWER_UP_WEIGHTS and listed in its order. From the seed and level, so a retry after a death
- * offers the same.
+ * offers the same. Developer mode's extra gem offers every one that would do anything, nothing
+ * drawn (user request, 2026-10-24).
  */
 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]
   })
+  if (extra) return pool.map(([kind]) => kind)
+  const rng = stream(s.seed, 'offer', s.level)
   const picked = new Set<PowerUpKind>()
   while (picked.size < OFFER_SIZE && pool.length) {
     let r = rng.next() * pool.reduce((sum, [, w]) => sum + w, 0)
diff --git a/src/sim/types.ts b/src/sim/types.ts
index a0ed249..0689480 100644
--- a/src/sim/types.ts
+++ b/src/sim/types.ts
@@ -59,7 +59,10 @@ export type Status = 'playing' | 'dead' | 'gameOver' | 'cleared' | 'won'
  * crates were options too until 2026-10-23: now there is one board, and crates always crumble.)
  */
 export interface RunOptions {
-  /** Developer mode: an extra gem lies right of the start, to try the choice at once; the level keeps its own. Nothing else. */
+  /**
+   * Developer mode: an extra, rainbow gem lies right of the start, offering every power-up not
+   * held yet; the level keeps its own. Nothing else.
+   */
   dev: boolean
   /**
    * Immortal (called God until 2026-10-23, user request): nothing can kill the player, so the run
@@ -76,8 +79,11 @@ export type PowerUpKind =
   | 'bombPlus' | 'firePlus' | 'fusePlus' | 'run' | 'wallPass' | 'bombPass' | 'flamePass' | 'detonator'
   | 'star' | 'coinBag' | 'heart' | 'kick' | 'throw' | 'teleport' | 'diagonal'
 
-/** A gem's colour, from the seed. Only how it looks: every gem offers the same kind of choice. */
-export type GemColor = 'blue' | 'green' | 'red' | 'yellow'
+/**
+ * A gem's colour, from the seed. Only how it looks: every level's gem offers the same kind of
+ * choice. Rainbow is only Developer mode's extra gem (user request, 2026-10-24).
+ */
+export type GemColor = 'blue' | 'green' | 'red' | 'yellow' | 'rainbow'
 
 /**
  * The level's gem, hidden one per level under a soft block. Taking it offers OFFER_SIZE
@@ -86,7 +92,10 @@ 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). */
+  /**
+   * Developer mode's extra gem, beside the start; the level keeps its own (user request,
+   * 2026-10-23). It is the rainbow one and offers every power-up not held yet (2026-10-24).
+   */
   extra?: boolean
 }
 
diff --git a/src/ui/info.ts b/src/ui/info.ts
index cc161eb..42b1681 100644
--- a/src/ui/info.ts
+++ b/src/ui/info.ts
@@ -102,7 +102,7 @@ export function deathLine(level: number, d: Death | null): string {
 /** One line per mode the run has on (none in a plain run). */
 export function modeLines(s: GameState): string[] {
   const lines: string[] = []
-  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. Achievements still count, marked with a red border.')
+  if (s.options.dev) lines.push('Developer mode: an extra, rainbow gem lies right next to you at the start of every level, offering every power-up you don’t have yet. The level’s own gem is still hidden under a crate. Achievements still count, marked with a red border.')
   if (s.options.immortal) lines.push('Immortal: nothing can kill you. You can play through to the end, but the run earns no achievements.')
   return lines
 }
@@ -248,8 +248,13 @@ export function tileInfo(s: GameState, p: Vec): Info | null {
   if (gem) {
     return {
       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],
+      title: gem.extra ? 'Rainbow gem (Developer mode)' : 'Gem',
+      body: [
+        gem.extra
+          ? 'Walk onto it to choose any power-up you don’t have yet: every one is offered.'
+          : `Walk onto it to choose one of ${OFFER_SIZE} power-ups. The colour doesn’t matter.`,
+        PUNISH,
+      ],
     }
   }
   if (eq(s.exit, p)) {
diff --git a/test/options.test.ts b/test/options.test.ts
index 4037fba..19e8081 100644
--- a/test/options.test.ts
+++ b/test/options.test.ts
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
 import { enemyCount, generateLevel, LAST_LEVEL, START } from '../src/sim/levelgen'
 import { newRecord, replay } from '../src/sim/record'
 import { newRun } from '../src/sim/state'
-import { drawOffer, step } from '../src/sim/turn'
+import { drawOffer, POWER_UP_WEIGHTS, step } from '../src/sim/turn'
 import { DEFAULT_OPTIONS, type Vec } from '../src/sim/types'
 import { board, v } from './helpers'
 
@@ -99,15 +99,25 @@ describe('developer mode', () => {
     }
   })
 
-  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('the extra gem is the rainbow one, and offers every power-up (user request, 2026-10-24)', () => {
+    const s = newRun(1, 1, 0, dev)
+    expect(s.gems.find((g) => g.extra)!.color).toBe('rainbow')
+    expect(s.gems.find((g) => !g.extra)!.color).not.toBe('rainbow')
+    expect(drawOffer(s, true)).toEqual(POWER_UP_WEIGHTS.map(([kind]) => kind))
+    const { state } = step(s, { id: 'move', target: { x: START.x + 1, y: START.y } })
+    expect(state.offer).toEqual(POWER_UP_WEIGHTS.map(([kind]) => kind))
+  })
+
+  it('the extra gem leaves out what the player already has', () => {
+    const s = newRun(1, 1, 0, dev)
+    s.powers.run = true
+    s.powers.flamePass = true
+    const offer = drawOffer(s, true)
+    expect(offer).not.toContain('run')
+    expect(offer).not.toContain('flamePass')
+    expect(offer).toHaveLength(POWER_UP_WEIGHTS.length - 2)
+    // Stats, a Star, a heart and coins can always be had again.
+    expect(offer).toEqual(expect.arrayContaining(['bombPlus', 'firePlus', 'fusePlus', 'star', 'heart', 'coinBag']))
   })
 
   it('a normal run has just the one gem, under a crate', () => {
diff --git a/tools/compose-icons.sh b/tools/compose-icons.sh
index 54fd6d9..6045045 100644
--- a/tools/compose-icons.sh
+++ b/tools/compose-icons.sh
@@ -63,6 +63,23 @@ magick "$B/timer_0.png" -fill '#353541' -colorize 100 \
   "$OUT/charge_shield.png"
 echo "  $OUT/charge_shield.png"
 
+# Developer mode's extra gem (user request, 2026-10-24: a very special gem, so it can't be taken
+# for the level's own): the green gem's body in a rainbow, red to violet from left to right,
+# blended over its own shading in grey (Overlay keeps the facets); the dark outline stays as it is.
+TMP=$(mktemp -d)
+magick "$K/gem_green.png" -modulate 100,0 "$TMP/grey.png"
+magick -size 24x128 xc:'#ff4848' \
+  \( -size 128x20 gradient:'#ff4848'-'#ffd23b' -rotate -90 \) \
+  \( -size 128x20 gradient:'#ffd23b'-'#3bd96b' -rotate -90 \) \
+  \( -size 128x20 gradient:'#3bd96b'-'#3ba8ff' -rotate -90 \) \
+  \( -size 128x20 gradient:'#3ba8ff'-'#c060ff' -rotate -90 \) \
+  -size 24x128 xc:'#c060ff' +append \
+  "$TMP/grey.png" -compose overlay -composite "$TMP/rainbow.png"
+magick "$K/gem_green.png" "$TMP/rainbow.png" \( "$TMP/grey.png" -alpha off -threshold 30% \) -compose over -composite \
+  \( "$K/gem_green.png" -alpha extract \) -compose copyopacity -composite PNG32:"$OUT/gem_rainbow.png"
+rm -r "$TMP"
+echo "  $OUT/gem_rainbow.png"
+
 # The app's icons for installing it (user request, 2026-10-23): the bomb on the sky blue, in
 # static/icons/. "Maskable" keeps the bomb inside the middle 60%, so any mask shape fits round it.
 mkdir -p static/icons
diff --git a/tools/screenshots.mjs b/tools/screenshots.mjs
index 0999661..05ec940 100644
--- a/tools/screenshots.mjs
+++ b/tools/screenshots.mjs
@@ -69,8 +69,10 @@ const SHOTS = [
   { name: 'gem', query: 'seed=55', act: press('KeyB', 'KeyS', 'KeyD', 'KeyD', 'Space', 'KeyA') },
   // ...then walk back and take it: the choice of power-ups.
   { name: 'gem-offer', query: 'seed=55', act: press('KeyB', 'KeyS', 'KeyD', 'KeyD', 'Space', 'KeyA', 'KeyA', 'KeyS', 'KeyS') },
-  // Developer mode: the level's gem uncovered right of the player, and the mode's tag in the top bar.
+  // Developer mode: the rainbow gem uncovered right of the player, and the mode's tag in the top bar...
   { name: 'dev', query: 'dev', act: async () => {} },
+  // ...and taking it: every power-up on offer.
+  { name: 'dev-offer', query: 'dev', act: press('KeyD') },
   // "What is that?" (the ? button beside the menu) on, then tapping an enemy explains it; the top bar's items
   // explain themselves with a plain tap.
   { name: 'info-mode', act: async (page) => { await page.click('#info-button') } },
@@ -106,7 +108,9 @@ const SHOTS = [
 
 mkdirSync(OUT, { recursive: true })
 const server = await preview({ preview: { port: 4179, strictPort: true } })
-const browser = await chromium.launch()
+// Chromium's new headless mode (the full browser), not Playwright's default headless shell: the
+// shell's screenshots of the WebGL canvas show a black strip that isn't in the drawing.
+const browser = await chromium.launch({ channel: 'chromium' })
 try {
   for (const { name, viewport = PHONE, query = '', act } of SHOTS) {
     const page = await browser.newPage({ viewport, deviceScaleFactor: 2, hasTouch: viewport === PHONE })