fuzzyworld

explodorama / commit

High-score board, death summaries, same/new dungeon, seed entry

0580e85e239c9288dca3f9a83e83036fcdcefb91 · Pavel Safronov · 2026-10-24 00:20:07 -0700 · parents 00ad7d0a8e75

Claude Code usage: this commit ≈0 out / ~89.3M cached · cost unknown (no per-model trailer) · session 3 tally 2.8K in / 1.4M out / 675.6M cached · usage by commit and session

High-score board, death summaries, same/new dungeon, seed entry

Deaths: the death and game-over banners say how and where the player died
("Level 3, turn 12: you walked into a Blob."), recorded in the state
(GameState.death): walked into an enemy, an enemy walked in, a blast, a
chain reaction, or lingering flame.

Game over offers Same dungeon (same seed and modes) or New dungeon (random
seed). The New run dialog has an optional seed field: a number is used as
is, other text is hashed into a seed.

A local high-score board: every finished run, ranked by a win, then the
level, then coins, then fewer turns; the best 25 kept in this browser. Each
entry keeps the facts shown (enemies killed, turns, lives lost, how it
ended, power-ups, seed) and the whole run record with format and rules
versions, so a global board can verify scores by replaying them. Developer
runs count with a red border; Immortal runs don't. Reachable from the menu
and from the end-of-run banner. The global board and a daily dungeon are
planned in PLAN.md 12b (the global board would be the first backend).
SIM_VERSION 22.

Usage: 2.8K in / 1.4M out / 675.6M cached (session tally)
rtk:   1.8M saved, 39.8% over 3494 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
 CHANGELOG.md                     |   9 +++-
 PLAN.md                          |  56 +++++++++++++++++++-
 README.md                        |  19 ++++++-
 decisions.md                     |  55 +++++++++++++++++++
 docs/screenshots/auto-move.png   | Bin 186278 -> 186317 bytes
 docs/screenshots/bomb-picked.png | Bin 186269 -> 186347 bytes
 docs/screenshots/death.png       | Bin 156855 -> 174974 bytes
 docs/screenshots/demo.png        | Bin 113542 -> 113560 bytes
 docs/screenshots/dev.png         | Bin 188476 -> 188427 bytes
 docs/screenshots/enemies.png     | Bin 189779 -> 189773 bytes
 docs/screenshots/info-enemy.png  | Bin 125480 -> 125422 bytes
 docs/screenshots/info-hud.png    | Bin 126838 -> 126858 bytes
 docs/screenshots/info-mode.png   | Bin 185399 -> 185411 bytes
 docs/screenshots/lingering.png   | Bin 189020 -> 189098 bytes
 docs/screenshots/long-press.png  | Bin 185568 -> 185538 bytes
 docs/screenshots/menu.png        | Bin 125870 -> 124197 bytes
 docs/screenshots/new-run.png     | Bin 148597 -> 172427 bytes
 index.html                       |   2 +
 src/main.ts                      |  32 +++++++++--
 src/sim/demo.ts                  |   2 +-
 src/sim/record.ts                |   2 +-
 src/sim/scores.ts                |  77 +++++++++++++++++++++++++++
 src/sim/state.ts                 |   7 +--
 src/sim/turn.ts                  |  23 ++++++--
 src/sim/types.ts                 |  17 ++++++
 src/style.css                    | 111 +++++++++++++++++++++++++++++++++++++++
 src/ui/controls.ts               |  38 +++++++++++---
 src/ui/info.ts                   |  27 +++++++++-
 src/ui/scores.ts                 |  62 ++++++++++++++++++++++
 test/helpers.ts                  |   2 +-
 test/scores.test.ts              |  70 ++++++++++++++++++++++++
 test/turn.test.ts                |  48 +++++++++++++++++
 32 files changed, 630 insertions(+), 29 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0b1257a..bc91ad3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,13 +12,18 @@ saved before the change is dropped and the next visit starts a new run.
 
 ### Added
 
+- **A high-score board** in this browser: every finished run, ranked by how far it got, then
+  coins, with enemies killed, turns, how it ended, the power-ups held and the seed. A global
+  board and a daily dungeon are planned.
+- **Game over** offers the same dungeon again or a new one, and the New run dialog has an
+  optional seed field, to share or replay a dungeon.
 - **The board and the turn.** A 9 × 11 board with a stone outer wall, indestructible pillars
   (their colour changes from level to level) and wooden crates; the whole board fits the screen. Each turn the player picks one action, then enemies step, bombs count down and
   explode, and enemies show their next step.
 - **Bombs** whose fuses count down with the player's moves, chain reactions, and a danger
   preview that shows what will burn before an action is committed. Flame lingers for one turn.
 - **Lives.** A death restarts the level with the same layout; with no lives left the run ends
-  and shows its seed.
+  and shows its seed. The banner says how and where you died.
 - **Seeded levels.** Every random choice comes from the seed, and every run is recorded as its
   seed plus its actions, so it replays exactly.
 - **The exit door**, hidden under a crate and locked until every enemy is dead. Flaming it lets
@@ -90,7 +95,7 @@ saved before the change is dropped and the next visit starts a new run.
   move on screen now comes from the change of state, so nothing can skip it.
 - 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` 20).
+- 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.
 
diff --git a/PLAN.md b/PLAN.md
index 8bc343d..614b713 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -383,6 +383,10 @@ Like *Everspace*, where you spend between runs on more weapon slots or stronger
 
 ## 9. Death: Keep or Fresh
 
+*Built 2026-10-24 (user request):* the death banner (and the game-over one) says how and where
+the player died — level, turn, and the cause (`GameState.death`): walked into an enemy, an
+enemy walked in, a bomb's blast, a chain reaction's second bomb, or last turn's flame.
+
 When the player dies with lives left, the level will restart from its seed (same layout,
 enemies reset). Before that, a choice screen with two buttons, **both showing exact numbers**:
 
@@ -506,6 +510,11 @@ itself, because the sim is deterministic:
 
 ## 12. Seeds and randomness
 
+*Built 2026-10-24 (user request):* the New run dialog has an optional **seed** field (empty:
+random; a whole number: that seed; other text: hashed into one), so a dungeon can be shared or
+replayed as a real, saved run (a `?seed` link is a test board). **Game over** offers **Same
+dungeon** (same seed and modes, from level 1) and **New dungeon** (a random seed).
+
 - One **run seed** (shown on the run summary and certificate; can be entered to replay a seed).
 - **Separate RNG streams** derived from the run seed by purpose and level, e.g.
   `hash(seed, "levelgen", level)`, `hash(seed, "ai", level)`, `hash(seed, "drops", level)`,
@@ -515,6 +524,50 @@ itself, because the sim is deterministic:
 
 ---
 
+## 12b. High scores, a global board, and the daily dungeon (user request, 2026-10-24)
+
+**Built: the local board.** Every finished run (game over or the win) goes on a board kept in
+this browser (`ui/scores.ts`, `localStorage`), the best `BOARD_SIZE` (25), shown from the menu
+and from the end-of-run banner (which also says "New high score: #N!"). **Ranked** (user's
+choice) by a win first, then the level reached, then coins, then fewer turns. Each entry
+(`ScoreEntry`, `sim/scores.ts`) keeps: date, seed, modes, won, level, coins, enemies killed
+(`GameState.runKills`, new), turns, lives lost, how the run ended (the death: cause, level,
+turn), the abilities and passes held and the stats — and **the whole run record** (seed and
+actions) with `format` and `simVersion`. Immortal runs don't count; Developer runs count, shown
+with a red border (§13b); test URLs (`?seed` and the like) never record.
+
+**Planned: a global board.** *Not built.* It would be the app's first backend — so far there is
+none, on purpose (§9's note on measuring balance) — so it needs a decision before it is built.
+The entries are already shaped for it:
+
+- **Verification by replay.** The sim is pure, deterministic TypeScript, so a server can run the
+  same code on a submitted record (seed + options + actions) and recompute the level, coins,
+  kills and turns; it accepts the entry only if they match. Faking a score then means playing it.
+  Only records with the server's current `SIM_VERSION` (or one it still has the code for).
+- **Identity:** no accounts at first — a chosen display name and a random install id. Submitting
+  is opt-in, per run or as a setting; nothing leaves the browser otherwise.
+- **Hosting:** GitLab Pages is static, so a small function + storage (a serverless function and a
+  key-value store, or a tiny server). Rate limits per install id; record size capped (compressed).
+- **Boards:** all-time (per rules version), per seed, and the daily one below. Developer runs
+  marked as on the local board; Immortal never submitted.
+
+**Planned: the daily dungeon.** *Not built; to discuss.* One shared dungeon a day:
+
+- **Seed from the date:** `hashSeed('daily', 'YYYY-MM-DD')`, the date in **UTC**, so everyone
+  everywhere plays the same dungeon on the same day (local dates would give the world two or
+  three dungeons at once; the cost is a rollover at a local hour that isn't midnight).
+- **Fixed modes:** no Developer or Immortal, so results compare.
+- **Attempts:** open question — only the first finished run of the day counts (Wordle-style, the
+  most tension), or the best of the day (more play). Practice runs could follow the counted one.
+- **Its own board** per day (locally: a history of daily results and a streak; globally: the
+  day's board). The rules version is part of it: if the rules change during a day, that day's
+  board splits by `SIM_VERSION`.
+- **Sharing:** a short result text to paste ("Explodorama daily 2026-10-24: level 12, 84 coins,
+  37 enemies"), and the seed to try it later.
+- Started from the menu (Daily dungeon); the top bar shows a Daily tag.
+
+---
+
 ## 13. Look and icons
 
 *Changed 2026-10-20 at the user's request.* The game uses **Kenney's New Platformer Pack 1.1**
@@ -663,7 +716,8 @@ URL test parameters `?small`, `?dev`, `?god`, `?crumble` set them for a test boa
 5. **Level width ramp:** start narrower (e.g. 15 columns) and grow to the full 31 by ~level 10,
    so early levels are short phone sessions? *Recommendation:* yes.
 6. **Shared / daily seeds:** seeds already make runs reproducible. Add a "daily seed" everyone
-   plays the same day? *Recommendation:* later milestone, cheap to add.
+   plays the same day? *Recommendation:* later milestone, cheap to add. *2026-10-24:* seeds can be
+   typed in (§12); the daily dungeon is planned in §12b.
 7. **Certificate strength:** replay-verifiable (§11, recommended; nearly free since the sim is
    deterministic) or a simple checksum?
 8. **Board theme:** ~~light "paper" board or dark board?~~ Settled by the Kenney art
diff --git a/README.md b/README.md
index 2cb11e2..93166f1 100644
--- a/README.md
+++ b/README.md
@@ -247,12 +247,13 @@ and pass you hold.
 You have **3 lives**. Caught in a blast or touched by an enemy, you lose one and the level starts
 again with the same layout and enemies. **You keep everything else**: coins, stats, every
 power-up (abilities and passes included) and what your charges still give. (The original took
-the passes and the Detonator away; since 2026-10-23 a death costs only the life.) With none left, the run is over and the seed is shown, so the same board can be
+the passes and the Detonator away; since 2026-10-23 a death costs only the life.) The banner says how and where you died — "Level 3, turn 12: you walked into a Blob." — and so
+does the game-over banner. With none left, the run is over and the seed is shown, so the same board can be
 replayed.
 
 ### New run: modes
 
-**Menu → New run** (or the button after game over / victory) starts a new run, with two optional
+**Menu → New run** (or the button after the victory) starts a new run, with two optional
 modes (there is one board size, and crates always crumble once the enemies are gone):
 
 | Mode | What it does |
@@ -260,6 +261,20 @@ modes (there is one board size, and crates always crumble once the enemies are g
 | **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. |
 | **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
+dungeon; type a seed (shown in the menu and at the end of a run) to play that dungeon — to share
+one with a friend or try it again. Any other text, a word say, is made into a seed too.
+
+**High scores:** every finished run — game over or the win — goes on a board kept in this
+browser (Menu → High scores, or the button on the end-of-run banner, which also says when a run
+makes it). Runs are ranked by how far they got (a win first, then the level), then coins, then
+fewer turns; the best 25 are kept. Each shows the enemies killed, turns, lives lost, how it
+ended, the power-ups held, the date and the seed. Developer runs count, with a red border;
+Immortal runs don't. A global board and a daily dungeon are planned (PLAN.md §12b).
+
+**Game over** offers two buttons: **Same dungeon** (the same seed and modes again, from level 1)
+and **New dungeon** (a random seed, same modes).
+
 The choice is remembered for next time, is fixed for the run, and shows as small yellow tags at the start of
 the top bar's last row (tap them for details) and in the menu. Starting a new run while one is
 going warns that it will be lost.
diff --git a/decisions.md b/decisions.md
index 50ad0dc..45d0d60 100644
--- a/decisions.md
+++ b/decisions.md
@@ -1018,3 +1018,58 @@ User request: auto-move "at least 50% faster". Once every enemy is gone, the wal
 destination now takes a step every 0.5 s (`AUTO_STEP_SECONDS`, was 1 s) — twice as fast. A
 step's slide takes 0.16 s, so each one still finishes before the next. Each step is still one
 turn, and a Run or Teleport still counts as one move.
+
+### 2026-10-24 — The death banner says how and where
+
+User request: when the player dies, a short text says how and on which level, then play goes on
+as before. The sim now records the death in the state (`GameState.death`: the cause, the enemy
+for an enemy, and the turn), since the banner is drawn from the state and a reloaded game
+replays to it. Causes: walked into an enemy (first contact check), an enemy walked into the
+player (after the enemies move), caught in a blast of a bomb that was due (or set off by the
+Detonator), caught only by a bomb a chain set off (it wasn't due), or by last turn's lingering
+flame. The banner: "Ouch! Level 3, turn 12: you walked into a Blob. 2 lives left."; game over
+puts the same line before the seed. Nothing is recorded when a death is prevented (Shield,
+Star, Flame Pass, Immortal). Tests cover each cause. `SIM_VERSION` 21.
+
+### 2026-10-24 — Same dungeon or a new one at game over; an optional seed
+
+User request: when dying, choose between the same seed (dungeon) again and a new one from a
+random seed — and should a player be able to type a seed? There was no reason not to (the seed
+is already shown, and sharing or replaying a dungeon, races and a daily seed all need it; the
+victory certificate is built on the seed), so, with the user's choices:
+
+- **At game over only** (over "every death"): the banner's buttons are **Same dungeon** (the same
+  seed and modes, from level 1) and **New dungeon** (a random seed, same modes). A death with
+  lives left still has the one Try again. After the victory it is still New run (the dialog).
+- **An optional seed field** in the New run dialog: empty for a random seed; a whole number is
+  used as it is; any other text (a word to share) is hashed into a seed. Unlike a `?seed` link
+  it is a real, saved run. A seed gives the same dungeon only with the same rules version.
+
+Checked in a browser: "1234" gives seed 1234, "pavel" a hashed one, empty a random one; at game
+over both buttons show, and Same dungeon restarts the same seed at level 1.
+
+### 2026-10-24 — A local high-score board, planned for a global one; the daily dungeon planned
+
+User request: a local high-score board, based on coins or level reached, with the power-ups in
+use, enemies killed, how the run died; kept local but planned for a global board; seeds kept; and
+start thinking about a daily dungeon with a seed from the date.
+
+- **Ranking** (user's choice of "level, then coins"): a win first, then the level, then coins,
+  then fewer turns. The best 25 finished runs are kept in `localStorage`.
+- **Entries** (`sim/scores.ts`, pure and tested) hold the board's facts and the whole run record
+  with format and rules versions, so a global board can verify a score by replaying it (the sim
+  is deterministic TypeScript that a server can run). Enemies killed is a new run-wide count
+  (`runKills`), so `SIM_VERSION` 22.
+- **Which runs:** finished ones only (game over, or the win); not Immortal (no achievements);
+  Developer with a red border, as decided; never from a test URL.
+- **Where:** Menu → High scores, and a "High scores" button on the game-over and victory banners —
+  those banners cover the menu, which found that the menu couldn't be reached at game over. The
+  banner says "New high score: #N!" when a run makes the board.
+- **Planned, not built** (PLAN.md §12b): the global board — verification by replay, no accounts
+  (a name and an install id), opt-in submission, a small backend — and the daily dungeon (seed
+  `hashSeed('daily', UTC date)`, fixed modes, its own board, a shareable result). **This would be
+  the app's first backend**; PLAN.md recorded "no backend or telemetry, on purpose", so building
+  it needs the user's go-ahead. Open: whether only the first daily run counts, or the best.
+
+Checked in a browser: two game overs (the second a Developer run with more coins) each said "New
+high score: #1!"; the board showed both, the Developer one first, red-bordered.
diff --git a/docs/screenshots/auto-move.png b/docs/screenshots/auto-move.png
index fd9041f..07b8e88 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 bea517f..61534f9 100644
Binary files a/docs/screenshots/bomb-picked.png and b/docs/screenshots/bomb-picked.png differ
diff --git a/docs/screenshots/death.png b/docs/screenshots/death.png
index cdda149..e0de79f 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 7c9d5a0..c46ef65 100644
Binary files a/docs/screenshots/demo.png and b/docs/screenshots/demo.png differ
diff --git a/docs/screenshots/dev.png b/docs/screenshots/dev.png
index 2d19f2b..a4a4977 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 a5ba5c2..e24d98b 100644
Binary files a/docs/screenshots/enemies.png and b/docs/screenshots/enemies.png differ
diff --git a/docs/screenshots/info-enemy.png b/docs/screenshots/info-enemy.png
index da2c0dc..4fb42e9 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 34ea3a6..a38c79c 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 023bd29..1e548e7 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 8d03a9a..ed540c6 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 96d8794..a16dfd9 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 299f47d..7fc5cf0 100644
Binary files a/docs/screenshots/menu.png and b/docs/screenshots/menu.png differ
diff --git a/docs/screenshots/new-run.png b/docs/screenshots/new-run.png
index a383c33..5f35fec 100644
Binary files a/docs/screenshots/new-run.png and b/docs/screenshots/new-run.png differ
diff --git a/index.html b/index.html
index 645a737..1606858 100644
--- a/index.html
+++ b/index.html
@@ -42,6 +42,8 @@
   <img id="banner-icon" alt="" />
   <p id="banner-text"></p>
   <button id="banner-button" type="button"></button>
+  <button id="banner-button-2" type="button" class="secondary" hidden></button>
+  <button id="banner-scores" type="button" class="link" hidden>High scores</button>
 </div>
 <dialog id="dialog" aria-labelledby="dialog-title">
   <div class="panel" tabindex="-1">
diff --git a/src/main.ts b/src/main.ts
index 9f4acbd..4a9424b 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -29,7 +29,10 @@ import { createControls } from './ui/controls'
 import { dialogOpen, openDialog, type DialogButton } from './ui/dialog'
 import { actionInfo, chargeInfo, hudInfo, modeLines, tileInfo, type Info } from './ui/info'
 import { openOffer } from './ui/offer'
+import { openScores, recordScore } from './ui/scores'
+import { countsForScores, scoreEntry } from './sim/scores'
 import { reportError } from './devlog'
+import { hashSeed } from './sim/rng'
 import { DYING_KINDS, generateScene, LIVING_KINDS, SCENE_KINDS, SCENES, type Scene, type SceneKind } from './sim/demo'
 import { spriteUrl } from './render/sprites'
 import playIcon from '../assets/kenney-ui/Extra/icon_play_light.png'
@@ -187,8 +190,15 @@ function act(action: Action, auto = false): void {
     reportError(`turn ${state.turn}, ${JSON.stringify(action)}`, error)
     return
   }
+  const wasGoing = state.status !== 'gameOver' && state.status !== 'won'
   animateTurn(result, action)
   record.actions.push(action)
+  // A run that has just ended goes on the high-score board (not from a test URL); its banner
+  // says if it made it.
+  if (wasGoing && !testing && countsForScores(state)) {
+    const rank = recordScore(scoreEntry(state, record, new Date()))
+    controls.setBannerNote(rank ? `New high score: #${rank}!` : '')
+  }
   for (const e of result.events) {
     // A gem taken: its choice opens once the gem has risen away (render/effects.ts).
     if (e.type === 'powerUpTaken') window.setTimeout(showOffer, PICKUP_DIALOG_MS)
@@ -344,6 +354,7 @@ function showMenu(): void {
     buttons: [
       { label: 'Resume', kind: 'primary', icon: playIcon },
       { label: 'Almanac', kind: 'plain', onClick: () => openAlmanac() },
+      { label: 'High scores', kind: 'plain', onClick: openScores },
       { label: 'New run', kind: 'danger', icon: newRunIcon, onClick: newRunDialog },
     ],
   })
@@ -368,6 +379,8 @@ function newRunDialog(): void {
       `<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="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>` +
       `</fieldset>`,
     buttons: [
       { label: 'Start', kind: ongoing ? 'danger' : 'primary', icon: newRunIcon, onClick: startChosenRun },
@@ -381,7 +394,18 @@ function startChosenRun(): void {
   const on = (name: string) => form.querySelector<HTMLInputElement>(`input[name=${name}]`)!.checked
   const options: RunOptions = { dev: on('dev'), immortal: on('immortal') }
   rememberOptions(options)
-  startRun(randomSeed(), options)
+  startRun(seedFrom(form.querySelector<HTMLInputElement>('input[name=seed]')!.value), options)
+}
+
+/**
+ * The seed typed in the New run dialog (user request, 2026-10-24): empty for a random one; a
+ * whole number as it is; any other text (a word to share) made into a number by hashing it.
+ */
+function seedFrom(text: string): number {
+  const t = text.trim()
+  if (!t) return randomSeed()
+  if (/^\d+$/.test(t) && Number(t) < 2 ** 31) return Number(t)
+  return hashSeed('seed', t) % 2 ** 31
 }
 
 /**
@@ -451,9 +475,11 @@ function direction(d: Dir): void {
 
 const controls = createControls({
   pick,
-  banner() {
+  banner(second) {
     if (state.status === 'dead' || state.status === 'cleared') act({ id: 'continue' })
-    else if (state.status === 'gameOver' || state.status === 'won') newRunDialog()
+    // Game over: the same dungeon again (same seed and modes, from the start), or a new one.
+    else if (state.status === 'gameOver') startRun(second ? randomSeed() : state.seed, state.options)
+    else if (state.status === 'won') newRunDialog()
   },
   info: (item) => showInfo(hudInfo(item, state)),
   inspect,
diff --git a/src/sim/demo.ts b/src/sim/demo.ts
index a4e7581..e8256de 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, gems: [], offer: null, runTurns: 0, deaths: 0, crumbling: [],
+    powers: noPowers(), star: 0, gems: [], offer: null, death: null, runTurns: 0, runKills: 0, deaths: 0, crumbling: [],
     aiRng: rng.int(2 ** 31), dropRng: rng.int(2 ** 31),
   }
   const open = floor().filter((p) => steps(p, player) >= 3)
diff --git a/src/sim/record.ts b/src/sim/record.ts
index b4dfd71..58f504b 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 = 20
+export const SIM_VERSION = 22
 
 /** Everything needed to reproduce a run exactly: mid-run saves, replays, certificates. */
 export interface RunRecord {
diff --git a/src/sim/scores.ts b/src/sim/scores.ts
new file mode 100644
index 0000000..a72ab9c
--- /dev/null
+++ b/src/sim/scores.ts
@@ -0,0 +1,77 @@
+import { SIM_VERSION, type RunRecord } from './record'
+import type { Death, GameState, Powers, RunOptions, Stats } from './types'
+
+/**
+ * The high-score board (user request, 2026-10-24): one entry per finished run (game over or
+ * the win). Kept in the browser for now, but shaped for a global board later: each entry has
+ * its format and rules version, and the whole run record (seed and actions), so a server can
+ * replay it by the same rules and check the score before accepting it. Pure; storage is in
+ * ui/scores.ts.
+ */
+export interface ScoreEntry {
+  /** This shape's version, for a global board to read old entries. */
+  format: 1
+  simVersion: number
+  /** When the run ended, ISO 8601. */
+  date: string
+  seed: number
+  options: RunOptions
+  won: boolean
+  /** The level the run ended on (the last one, for a win). */
+  level: number
+  coins: number
+  kills: number
+  turns: number
+  livesLost: number
+  /** How the run ended, for a game over: the death on the last level. */
+  death: Death | null
+  /** The abilities and passes held at the end, and the stats. */
+  powers: (keyof Powers)[]
+  stats: Stats
+  /** Everything needed to replay the run and check it. */
+  record: RunRecord
+}
+
+/** How many entries the board keeps. */
+export const BOARD_SIZE = 25
+
+/** Whether a finished run goes on the board: not an Immortal one (it earns no achievements). */
+export function countsForScores(s: GameState): boolean {
+  return (s.status === 'gameOver' || s.status === 'won') && !s.options.immortal
+}
+
+/** The entry for a run that has just ended. */
+export function scoreEntry(s: GameState, record: RunRecord, date: Date): ScoreEntry {
+  return {
+    format: 1,
+    simVersion: SIM_VERSION,
+    date: date.toISOString(),
+    seed: s.seed,
+    options: { ...s.options },
+    won: s.status === 'won',
+    level: s.level,
+    coins: s.coins,
+    kills: s.runKills,
+    turns: s.runTurns,
+    livesLost: s.deaths,
+    death: s.status === 'gameOver' ? s.death : null,
+    powers: (Object.keys(s.powers) as (keyof Powers)[]).filter((p) => s.powers[p]),
+    stats: { ...s.stats },
+    record: structuredClone(record),
+  }
+}
+
+/**
+ * Better first (user's choice, 2026-10-24: level, then coins): a win before anything else, then
+ * the level reached, then coins, then fewer turns, then the earlier run.
+ */
+export function compareEntries(a: ScoreEntry, b: ScoreEntry): number {
+  return Number(b.won) - Number(a.won) || b.level - a.level || b.coins - a.coins || a.turns - b.turns || a.date.localeCompare(b.date)
+}
+
+/** The board with `entry` added: sorted, at most BOARD_SIZE long; and where it ranks (1-based), or 0 if it didn't make it. */
+export function addEntry(board: ScoreEntry[], entry: ScoreEntry): { board: ScoreEntry[]; rank: number } {
+  const all = [...board, entry].sort(compareEntries)
+  const kept = all.slice(0, BOARD_SIZE)
+  return { board: kept, rank: kept.indexOf(entry) + 1 }
+}
diff --git a/src/sim/state.ts b/src/sim/state.ts
index ff67e0f..1dd5b7e 100644
--- a/src/sim/state.ts
+++ b/src/sim/state.ts
@@ -21,6 +21,7 @@ export interface Carry {
   powers: Powers
   star: number
   runTurns: number
+  runKills: number
   deaths: number
 }
 
@@ -41,7 +42,7 @@ export function newRun(seed: number, level = 1, n = 0, options: RunOptions = DEF
   const boosts = n > 0 ? { pierce: n * BOMB_BOOST, power: n * BOMB_BOOST, nuke: n * BOMB_BOOST } : noBoosts()
   return createLevel(seed, level, options, {
     lives: START_LIVES, stats: { ...START_STATS }, coins: 0, boosts, shield: n > 0, powers: noPowers(), star: 0,
-    runTurns: 0, deaths: 0,
+    runTurns: 0, runKills: 0, deaths: 0,
   })
 }
 
@@ -58,7 +59,7 @@ 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, gems: [powerUp], offer: null, runTurns: carry.runTurns, deaths: carry.deaths, crumbling: [],
+    powers: { ...carry.powers }, star: carry.star, gems: [powerUp], offer: null, death: null, runTurns: carry.runTurns, runKills: carry.runKills, 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
@@ -82,7 +83,7 @@ export function createLevel(seed: number, level: number, options: RunOptions, ca
 export function carryOf(s: GameState): Carry {
   return {
     lives: s.lives, stats: s.stats, coins: s.coins, boosts: s.boosts, shield: s.shield, powers: s.powers, star: s.star,
-    runTurns: s.runTurns, deaths: s.deaths,
+    runTurns: s.runTurns, runKills: s.runKills, deaths: s.deaths,
   }
 }
 
diff --git a/src/sim/turn.ts b/src/sim/turn.ts
index 5e99b17..6611228 100644
--- a/src/sim/turn.ts
+++ b/src/sim/turn.ts
@@ -4,7 +4,7 @@ import { enemyKinds, LAST_LEVEL } from './levelgen'
 import { detonate, tickBombs } from './explosions'
 import { add, bombAt, carryOf, cellAt, cloneState, createLevel, eq, exitOpen, idx, PAR_BONUS } from './state'
 import { stream } from './rng'
-import { DIRS, DIR_LIST, type Action, type GameEvent, type GameState, type Powers, type PowerUpKind, type Vec } from './types'
+import { DIRS, DIR_LIST, type Action, type Death, type GameEvent, type GameState, type Powers, type PowerUpKind, type Vec } from './types'
 
 export interface StepResult {
   state: GameState
@@ -77,7 +77,8 @@ export function step(prev: GameState, action: Action): StepResult {
   // A shield absorbs every hit for the rest of the turn it breaks in. A Star stops every hit;
   // Flame Pass stops flame.
   let shielded = false
-  const hurt = (cause: 'flame' | 'enemy'): void => {
+  const hurt = (how: Death): void => {
+    const cause = how.cause === 'walkedIntoEnemy' || how.cause === 'caughtByEnemy' ? 'enemy' : 'flame'
     if (s.options.immortal || shielded || s.star > 0 || (cause === 'flame' && s.powers.flamePass)) return
     if (s.shield) {
       s.shield = false
@@ -88,16 +89,19 @@ export function step(prev: GameState, action: Action): StepResult {
     s.lives--
     s.deaths++
     s.status = s.lives > 0 ? 'dead' : 'gameOver'
+    s.death = how
     events.push({ type: 'playerDied', pos: s.player, livesLeft: s.lives })
   }
 
   // Contact, first check: the player walked (or ran) into an enemy, or swapped places with one.
-  if (enemyAt(s, s.player) || (passed && enemyAt(s, passed))) hurt('enemy')
+  const walkedInto = enemyAt(s, s.player) ?? (passed ? enemyAt(s, passed) : undefined)
+  if (walkedInto) hurt({ cause: 'walkedIntoEnemy', enemy: walkedInto.kind, turn: s.turn + 1 })
 
   // Phase 2: enemies carry out their announced intents. Second contact check.
   if (s.status === 'playing') {
     moveEnemies(s, events)
-    if (enemyAt(s, s.player)) hurt('enemy')
+    const caughtBy = enemyAt(s, s.player)
+    if (caughtBy) hurt({ cause: 'caughtByEnemy', enemy: caughtBy.kind, turn: s.turn + 1 })
   }
 
   // Phase 3: bombs tick; those at 0 explode, in chains. Last turn's flame is still burning too.
@@ -106,6 +110,9 @@ export function step(prev: GameState, action: Action): StepResult {
   const gemsOpen = s.gems.filter((g) => cellAt(s, g.pos) === 'floor')
   const enemiesBefore = s.enemies.length
   tickBombs(s, events)
+  // The bombs going off by themselves (or by the Detonator); the rest of what explodes, a chain set off.
+  const due = new Set(s.bombs.filter((b) => b.timer <= 0).map((b) => idx(s, b.pos)))
+  const firstEvent = events.length
   const burned = detonate(s, events)
   crumble(s, events)
 
@@ -113,7 +120,12 @@ export function step(prev: GameState, action: Action): StepResult {
   // the decoy. Only new flame burns drops lying in the open; slain enemies then leave their own
   // drops, so those survive the flame that made them (and its lingering).
   const fire = new Set([...burned, ...lingering])
-  if (s.status === 'playing' && fire.has(idx(s, s.player))) hurt('flame')
+  if (s.status === 'playing' && fire.has(idx(s, s.player))) {
+    const at = idx(s, s.player)
+    const hits = events.slice(firstEvent).filter((e) => e.type === 'exploded' && e.tiles.some((t) => idx(s, t) === at))
+    const cause = !burned.has(at) ? 'lingering' : hits.every((e) => e.type === 'exploded' && !due.has(idx(s, e.pos))) ? 'chain' : 'blast'
+    hurt({ cause, turn: s.turn + 1 })
+  }
   for (const d of s.drops.filter((d) => burned.has(idx(s, d.pos)))) {
     s.drops.splice(s.drops.indexOf(d), 1)
     events.push({ type: 'dropBurned', drop: d })
@@ -121,6 +133,7 @@ export function step(prev: GameState, action: Action): StepResult {
   // Enemies let out by last turn's flame are only hurt by new explosions, not by that flame.
   const slain = s.enemies.filter((e) => (e.sheltered ? burned : fire).has(idx(s, e.pos)))
   s.enemies = s.enemies.filter((e) => !slain.includes(e))
+  s.runKills += slain.length
   for (const e of slain) {
     events.push({ type: 'enemyDied', id: e.id, pos: e.pos })
     // When these were the last, their charges are ones that last past the enemies (user's choice).
diff --git a/src/sim/types.ts b/src/sim/types.ts
index 1961b4a..a0ed249 100644
--- a/src/sim/types.ts
+++ b/src/sim/types.ts
@@ -200,6 +200,8 @@ export interface GameState {
   powers: Powers
   /** Turns played and lives lost over the whole run, for the ending. */
   runTurns: number
+  /** Enemies killed over the whole run, for the high-score board (2026-10-24). */
+  runKills: number
   deaths: number
   /** Turns of Star invincibility left. */
   star: number
@@ -219,6 +221,8 @@ export interface GameState {
    * a bomb there. It doesn't burn crates, drops or bombs a second time.
    */
   burning: number[]
+  /** How the player last died on this attempt, for the banner; null while alive. */
+  death: Death | null
   /** Crumbling crates (once the last enemy is gone): tile index and turns until it is gone. */
   crumbling: { cell: number; turns: number }[]
   decoy: Decoy | null
@@ -233,6 +237,19 @@ export type ActionId = 'move' | 'bomb' | 'wait' | 'run' | 'detonate' | 'kick' |
 /** `choose`: one of the gem's offer. It takes no turn. */
 export type Action = { id: ActionId; target?: Vec } | { id: 'continue' } | { id: 'choose'; kind: PowerUpKind }
 
+/**
+ * How the player died, for the death and game-over banners (user request, 2026-10-24): walked
+ * into an enemy, or one walked into them; caught in a blast of a bomb that was due, or only by
+ * one a chain set off; or by flame still burning from last turn.
+ */
+export interface Death {
+  cause: 'walkedIntoEnemy' | 'caughtByEnemy' | 'blast' | 'chain' | 'lingering'
+  /** The enemy, for the two enemy causes. */
+  enemy?: EnemyKind
+  /** The turn it happened on (counted from 1, as the top bar shows after it). */
+  turn: number
+}
+
 /** What `step()` reports happened, in order, for the renderer to play back. */
 export type GameEvent =
   | { type: 'moved'; from: Vec; to: Vec }
diff --git a/src/style.css b/src/style.css
index a270f1e..d31b1b1 100644
--- a/src/style.css
+++ b/src/style.css
@@ -388,6 +388,28 @@ img {
   font-weight: 800;
 }
 
+#banner button[hidden] {
+  display: none;
+}
+
+/* "High scores" under the end-of-run choices: a plain link-like button. */
+#banner button.link {
+  min-height: 40px;
+  border: 0;
+  background: none;
+  box-shadow: none;
+  color: #fff;
+  text-decoration: underline;
+}
+
+/* The second choice (a new dungeon, after game over): white, under the green one. */
+#banner button.secondary {
+  margin-top: -6px;
+  background: var(--panel);
+  box-shadow: 0 4px 0 #1e1e27;
+  color: var(--ink);
+}
+
 /* ---- Kenney UI pack: the font, the dialog, its buttons, and the menu button ---- */
 
 @font-face {
@@ -708,6 +730,28 @@ img {
   display: block;
 }
 
+/* The seed field: a text box over its explanation. */
+#dialog-body .choices label.seed {
+  flex-direction: column;
+  font-size: 13px;
+}
+
+#dialog-body .choices label.seed input {
+  width: 100%;
+  height: 40px;
+  box-sizing: border-box;
+  padding: 0 10px;
+  border: 2px solid #d9cba8;
+  border-radius: 8px;
+  background: #fff;
+  color: var(--ink);
+  font: 600 16px ui-rounded, system-ui, sans-serif;
+}
+
+#dialog-body .choices label.seed input::placeholder {
+  color: #b5aa90;
+}
+
 #dialog-body .warning {
   color: #b3122e;
   font-weight: 700;
@@ -812,3 +856,70 @@ body.demo #banner {
     animation: none;
   }
 }
+
+/* The high-score board (ui/scores.ts): a card per run, a Developer run's with a red border. */
+#dialog-body .scores-intro {
+  font-size: 13px;
+  color: var(--muted);
+}
+
+#dialog-body ol.scores {
+  display: grid;
+  gap: 8px;
+  margin: 0;
+  padding: 0;
+  list-style: none;
+  text-align: left;
+}
+
+#dialog-body .score {
+  padding: 8px 10px;
+  border: 2px solid #d9cba8;
+  border-radius: 10px;
+  background: #fff8e6;
+}
+
+#dialog-body .score.developer {
+  border: 3px solid #e8253f;
+}
+
+#dialog-body .score p {
+  margin: 2px 0 0;
+  font-size: 13px;
+}
+
+#dialog-body .score-head {
+  display: flex;
+  gap: 8px;
+  align-items: baseline;
+  font-weight: 800;
+}
+
+#dialog-body .score-head span:last-child {
+  margin-left: auto;
+}
+
+#dialog-body .score-powers {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 2px;
+}
+
+#dialog-body .score-powers img {
+  width: 22px;
+  height: 22px;
+}
+
+#dialog-body .score-powers span {
+  margin-left: 4px;
+  color: var(--muted);
+}
+
+#dialog-body .score-meta {
+  color: var(--muted);
+}
+
+#dialog-body .score-meta b {
+  color: #e8253f;
+}
diff --git a/src/ui/controls.ts b/src/ui/controls.ts
index 0c8095f..035d8ed 100644
--- a/src/ui/controls.ts
+++ b/src/ui/controls.ts
@@ -2,7 +2,8 @@ import { canPick } from '../sim/picks'
 import { PAR_BONUS, START_LIVES } from '../sim/state'
 import { CHARGE_IDS, type ActionId, type GameState, type PowerUpKind, type Powers } from '../sim/types'
 import { CHARGE_ICONS, MONO_ICONS, POWER_UP_ICONS, spriteUrl } from '../render/sprites'
-import { CHARGE_TEXT, chargeLeft, type HudItem } from './info'
+import { CHARGE_TEXT, chargeLeft, deathText, type HudItem } from './info'
+import { openScores } from './scores'
 import questionIcon from '../../assets/kenney-icons/question.png'
 
 /** The HTML around the board: HUD, action bar, and the banner shown between attempts and levels. */
@@ -12,8 +13,11 @@ export interface Handlers {
    * `pressed` says whether one of them is already picked (so the press cancels it).
    */
   pick(id: ActionId, pressed: boolean): void
-  /** The banner's button: continue after losing a life or clearing a level, or a new run after game over or the ending. */
-  banner(): void
+  /**
+   * The banner's buttons: continue after losing a life or clearing a level; after game over,
+   * the same dungeon again (`second` false) or a new one (`second` true); a new run after the ending.
+   */
+  banner(second: boolean): void
   /** A top-bar item was tapped: show what it means. */
   info(item: HudItem): void
   /** The "What is that?" button: the next tap on the board (or an action button) explains it. */
@@ -117,6 +121,7 @@ export function createControls(handlers: Handlers) {
   }
 
   let current: GameState | null = null
+  let bannerNote = ''
   let currentSelected: ActionId | null = null
 
   // "What is that?": a round button next to the menu, top right.
@@ -128,7 +133,12 @@ export function createControls(handlers: Handlers) {
   const bannerText = document.getElementById('banner-text')!
   const bannerIcon = document.getElementById('banner-icon') as HTMLImageElement
   const bannerButton = document.getElementById('banner-button') as HTMLButtonElement
-  bannerButton.addEventListener('click', () => handlers.banner())
+  bannerButton.addEventListener('click', () => handlers.banner(false))
+  const bannerButton2 = document.getElementById('banner-button-2') as HTMLButtonElement
+  bannerButton2.addEventListener('click', () => handlers.banner(true))
+  // At the end of a run the banner covers the menu, so the board is reachable from it too.
+  const bannerScores = document.getElementById('banner-scores') as HTMLButtonElement
+  bannerScores.addEventListener('click', () => openScores())
 
   const hudEl = document.getElementById('hud')!
   hudEl.addEventListener('click', (e) => {
@@ -204,14 +214,21 @@ export function createControls(handlers: Handlers) {
       // Their own row, which keeps its place even while empty, so the board never moves.
       show(hud.extras, passes + star + charges)
 
+      // A note for the end of a run (a new high score), cleared once a new run is playing.
+      if (s.status === 'playing') bannerNote = ''
+      const note = bannerNote ? ` ${bannerNote}` : ''
       banner.hidden = s.status === 'playing'
+      bannerButton2.hidden = s.status !== 'gameOver'
+      bannerScores.hidden = s.status !== 'gameOver' && s.status !== 'won'
       if (s.status === 'dead') {
-        bannerText.textContent = `Ouch! ${s.lives} ${s.lives === 1 ? 'life' : 'lives'} left.`
+        bannerText.textContent = `Ouch! ${deathText(s)} ${s.lives} ${s.lives === 1 ? 'life' : 'lives'} left.`
         bannerButton.textContent = 'Try again'
         bannerIcon.src = spriteUrl('character_green_hit')
       } else if (s.status === 'gameOver') {
-        bannerText.textContent = `Game over on level ${s.level}. Seed ${s.seed}.`
-        bannerButton.textContent = 'New run'
+        bannerText.textContent = `Game over. ${deathText(s)} Seed ${s.seed}.${note}`
+        // Same dungeon again, or a new one (user request, 2026-10-24).
+        bannerButton.textContent = 'Same dungeon'
+        bannerButton2.textContent = 'New dungeon'
         bannerIcon.src = spriteUrl('hud_heart_empty')
       } else if (s.status === 'cleared') {
         const bonus = s.turn <= s.par ? ` Under par: +${PAR_BONUS} coins!` : ''
@@ -221,7 +238,8 @@ export function createControls(handlers: Handlers) {
       } else if (s.status === 'won') {
         bannerText.textContent =
           `You cleared all ${s.level} levels! ${s.runTurns} ${s.runTurns === 1 ? 'turn' : 'turns'}, ${s.deaths} ${s.deaths === 1 ? 'life' : 'lives'} lost. Seed ${s.seed}.` +
-          (s.options.dev ? ' (Developer run)' : '')
+          (s.options.dev ? ' (Developer run)' : '') +
+          note
         bannerButton.textContent = 'New run'
         bannerIcon.src = spriteUrl('star')
       }
@@ -229,6 +247,10 @@ export function createControls(handlers: Handlers) {
       // bars kept for their own plays on the jukebox (user request, 2026-10-23).
       banner.classList.toggle('developer', s.status === 'won' && s.options.dev)
     },
+    /** A line added to the game-over or victory banner (a new high score), until the next run. */
+    setBannerNote(text: string): void {
+      bannerNote = text
+    },
     /** True if the banner is showing; Enter presses its button. */
     pressBanner(): boolean {
       if (banner.hidden) return false
diff --git a/src/ui/info.ts b/src/ui/info.ts
index 0ad363c..cc161eb 100644
--- a/src/ui/info.ts
+++ b/src/ui/info.ts
@@ -2,7 +2,7 @@ import { BOMB_BOOST, DECOY_TURNS, FREEZE_TURNS, NUKE_RANGE, TELEPORT_RANGE } fro
 import { LAST_LEVEL } from '../sim/levelgen'
 import { bombAt, cellAt, eq, exitOpen, isBurning, PAR_BONUS } from '../sim/state'
 import { COIN_BAG, EXIT_SPAWN, OFFER_SIZE, STAR_TURNS } from '../sim/turn'
-import type { ActionId, ChargeId, Dir, EnemyKind, GameState, Powers, PowerUpKind, Vec } from '../sim/types'
+import type { ActionId, ChargeId, Death, Dir, EnemyKind, GameState, Powers, PowerUpKind, Vec } from '../sim/types'
 import { dirBetween } from '../sim/enemies'
 import { CHARGE_ICONS, coinSprite, ENEMY_SPRITES, GEM_SPRITES, isOuterWall, POWER_UP_ICONS, spriteUrl, wallSprite } from '../render/sprites'
 
@@ -77,6 +77,28 @@ const PUNISH = `Don’t blast it once it’s uncovered: flame on it lets out ${E
 /** The top bar's items. */
 export type HudItem = 'modes' | 'lives' | 'level' | 'enemies' | 'coins' | 'turn' | 'bombs' | 'fire' | 'fuse' | 'star' | keyof Powers | ChargeId
 
+/**
+ * How and where the player died, for the death and game-over banners (user request,
+ * 2026-10-24): "Level 3, turn 12: you walked into a Blob."
+ */
+export function deathText(s: GameState): string {
+  return deathLine(s.level, s.death)
+}
+
+/** "Level 3, turn 12: you walked into a Blob." — also for the high-score board. */
+export function deathLine(level: number, d: Death | null): string {
+  if (!d) return `Level ${level}.`
+  const enemy = d.enemy ? ENEMY_TEXT[d.enemy].name : 'enemy'
+  const how = {
+    walkedIntoEnemy: `you walked into a ${enemy}`,
+    caughtByEnemy: `a ${enemy} walked into you`,
+    blast: 'you were caught in a bomb’s blast',
+    chain: 'one bomb set off another, and its blast caught you',
+    lingering: 'you were caught by flame still burning from the turn before',
+  }[d.cause]
+  return `Level ${level}, turn ${d.turn}: ${how}.`
+}
+
 /** One line per mode the run has on (none in a plain run). */
 export function modeLines(s: GameState): string[] {
   const lines: string[] = []
@@ -85,7 +107,8 @@ export function modeLines(s: GameState): string[] {
   return lines
 }
 
-const PASS_KIND: Record<keyof Powers, PowerUpKind> = {
+/** Each ability or pass, as the power-up that gives it (for its icon and name). */
+export const PASS_KIND: Record<keyof Powers, PowerUpKind> = {
   run: 'run', kick: 'kick', throw: 'throw', teleport: 'teleport', diagonal: 'diagonal', detonate: 'detonator', wallPass: 'wallPass',
   bombPass: 'bombPass', flamePass: 'flamePass',
 }
diff --git a/src/ui/scores.ts b/src/ui/scores.ts
new file mode 100644
index 0000000..1eeca26
--- /dev/null
+++ b/src/ui/scores.ts
@@ -0,0 +1,62 @@
+import { addEntry, BOARD_SIZE, type ScoreEntry } from '../sim/scores'
+import { POWER_UP_ICONS, spriteUrl } from '../render/sprites'
+import { openDialog } from './dialog'
+import { deathLine, PASS_KIND, POWER_UP_TEXT } from './info'
+import { LAST_LEVEL } from '../sim/levelgen'
+
+/**
+ * The high-score board in this browser (user request, 2026-10-24): stored in `localStorage`,
+ * shown from the menu. The entries (sim/scores.ts) are built for a global board later.
+ */
+const SCORES_KEY = 'explodorama.scores'
+
+export function loadScores(): ScoreEntry[] {
+  try {
+    const all = JSON.parse(localStorage.getItem(SCORES_KEY) ?? '[]') as ScoreEntry[]
+    return Array.isArray(all) ? all.filter((e) => e.format === 1) : []
+  } catch {
+    return []
+  }
+}
+
+/** Put a finished run on the board; its rank (1-based), or 0 if it didn't make it. */
+export function recordScore(entry: ScoreEntry): number {
+  const { board, rank } = addEntry(loadScores(), entry)
+  try {
+    localStorage.setItem(SCORES_KEY, JSON.stringify(board))
+  } catch {
+    // No storage (or full): the board just isn't kept.
+  }
+  return rank
+}
+
+function entryHtml(e: ScoreEntry, rank: number): string {
+  const result = e.won ? `★ Cleared all ${LAST_LEVEL} levels` : `Level ${e.level}`
+  const how = e.won ? 'Won!' : deathLine(e.level, e.death)
+  const powers = e.powers
+    .map((p) => `<img src="${spriteUrl(POWER_UP_ICONS[PASS_KIND[p]])}" alt="${POWER_UP_TEXT[PASS_KIND[p]].name}" title="${POWER_UP_TEXT[PASS_KIND[p]].name}">`)
+    .join('')
+  const date = new Date(e.date).toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' })
+  return (
+    // A Developer run counts, with a red border (user request, 2026-10-24).
+    `<li class="score${e.options.dev ? ' developer' : ''}">` +
+    `<div class="score-head"><b>#${rank}</b><span>${result}</span><span>${e.coins} ${e.coins === 1 ? 'coin' : 'coins'}</span></div>` +
+    `<p>${e.kills} ${e.kills === 1 ? 'enemy' : 'enemies'} · ${e.turns} ${e.turns === 1 ? 'turn' : 'turns'} · ${e.livesLost} ${e.livesLost === 1 ? 'life' : 'lives'} lost</p>` +
+    `<p>${how}</p>` +
+    `<p class="score-powers">${powers}<span>Bombs ${e.stats.capacity} · Blast ${e.stats.fire} · Fuse ${e.stats.fuse}</span></p>` +
+    `<p class="score-meta">${date} · Seed ${e.seed}${e.options.dev ? ' · <b>Developer</b>' : ''}</p>` +
+    `</li>`
+  )
+}
+
+export function openScores(): void {
+  const board = loadScores()
+  openDialog({
+    title: 'High scores',
+    body: board.length
+      ? `<p class="scores-intro">Ranked by how far the run got, then coins. The best ${BOARD_SIZE} are kept, in this browser.</p>` +
+        `<ol class="scores">${board.map((e, i) => entryHtml(e, i + 1)).join('')}</ol>`
+      : `<p>No finished runs yet. A run goes on the board when it ends: at game over or after the last level. Immortal runs don’t count.</p>`,
+    buttons: [{ label: 'Close', kind: 'plain' }],
+  })
+}
diff --git a/test/helpers.ts b/test/helpers.ts
index 5348a7e..1382991 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, gems: [], offer: null, runTurns: 0, deaths: 0, crumbling: [], aiRng: 1, dropRng: 1,
+    powers: noPowers(), star: 0, gems: [], offer: null, death: null, runTurns: 0, runKills: 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/scores.test.ts b/test/scores.test.ts
new file mode 100644
index 0000000..7b07cc8
--- /dev/null
+++ b/test/scores.test.ts
@@ -0,0 +1,70 @@
+import { describe, expect, it } from 'vitest'
+import { newRecord } from '../src/sim/record'
+import { addEntry, BOARD_SIZE, compareEntries, countsForScores, scoreEntry, type ScoreEntry } from '../src/sim/scores'
+import { step } from '../src/sim/turn'
+import { board } from './helpers'
+
+const entry = (over: Partial<ScoreEntry>): ScoreEntry => ({
+  format: 1, simVersion: 1, date: '2026-10-24T10:00:00.000Z', seed: 1, options: { dev: false, immortal: false },
+  won: false, level: 1, coins: 0, kills: 0, turns: 10, livesLost: 3, death: null, powers: [],
+  stats: { capacity: 1, fire: 1, fuse: 3 }, record: newRecord(1), ...over,
+})
+
+describe('the high-score board', () => {
+  it("ranks a win first, then the level, then coins, then fewer turns (user's choice)", () => {
+    const order = [
+      entry({ won: true, level: 20, coins: 1 }),
+      entry({ level: 9, coins: 0 }),
+      entry({ level: 5, coins: 50 }),
+      entry({ level: 5, coins: 20, turns: 100 }),
+      entry({ level: 5, coins: 20, turns: 200 }),
+    ]
+    expect([...order].reverse().sort(compareEntries)).toEqual(order)
+  })
+
+  it('keeps the best BOARD_SIZE, and says where a new one ranks', () => {
+    let b: ScoreEntry[] = []
+    for (let i = 1; i <= BOARD_SIZE; i++) b = addEntry(b, entry({ level: i })).board
+    const worse = addEntry(b, entry({ level: 0 }))
+    expect(worse.rank).toBe(0)
+    expect(worse.board).toHaveLength(BOARD_SIZE)
+    const best = addEntry(b, entry({ level: 99 }))
+    expect(best.rank).toBe(1)
+  })
+
+  it('an entry has what the board shows: level, coins, kills, how it ended, powers, seed and the record', () => {
+    const s = board(`
+      #####
+      #P1.#
+      #####`)
+    s.lives = 1
+    s.coins = 7
+    s.runKills = 4
+    s.powers.run = true
+    const over = step(s, { id: 'wait' }).state
+    expect(over.status).toBe('gameOver')
+    expect(countsForScores(over)).toBe(true)
+    const e = scoreEntry(over, newRecord(over.seed), new Date('2026-10-24T10:00:00Z'))
+    expect(e).toMatchObject({ won: false, level: 1, coins: 7, kills: 4, seed: over.seed, powers: ['run'] })
+    expect(e.death?.cause).toBe('blast')
+    expect(e.record.seed).toBe(over.seed)
+  })
+
+  it("an Immortal run doesn't count; a run still going doesn't either", () => {
+    const s = board(`
+      ###
+      #P#
+      ###`)
+    expect(countsForScores(s)).toBe(false)
+    expect(countsForScores({ ...s, status: 'gameOver', options: { dev: false, immortal: true } })).toBe(false)
+    expect(countsForScores({ ...s, status: 'won', options: { dev: true, immortal: false } })).toBe(true)
+  })
+
+  it('kills are counted over the run', () => {
+    const s = board(`
+      ######
+      #P.1B#
+      ######`)
+    expect(step(s, { id: 'wait' }).state.runKills).toBe(1)
+  })
+})
diff --git a/test/turn.test.ts b/test/turn.test.ts
index 394811c..a87f01b 100644
--- a/test/turn.test.ts
+++ b/test/turn.test.ts
@@ -109,3 +109,51 @@ describe('death', () => {
     expect(() => step(state, { id: 'continue' })).toThrow()
   })
 })
+
+describe('how the player died (for the banner, user request 2026-10-24)', () => {
+  it('walked into an enemy, or an enemy walked in', () => {
+    const into = board(`
+      #####
+      #PB.#
+      #####`)
+    expect(step(into, { id: 'move', target: v(2, 1) }).state.death).toEqual({ cause: 'walkedIntoEnemy', enemy: 'blob', turn: into.turn + 1 })
+    const caught = board(`
+      #####
+      #P.S#
+      #####`)
+    caught.enemies[0]!.plan = [v(2, 1), v(1, 1)]
+    const after = step(step(caught, { id: 'wait' }).state, { id: 'wait' }).state
+    expect(after.death).toMatchObject({ cause: 'caughtByEnemy', enemy: 'seeker' })
+  })
+
+  it('caught in a bomb\'s blast, or only by one a chain set off', () => {
+    const blast = board(`
+      #####
+      #P1.#
+      #####`)
+    expect(step(blast, { id: 'wait' }).state.death?.cause).toBe('blast')
+    // The 1 goes off and sets off the 5, whose blast reaches the player; the 1's doesn't.
+    const chain = board(`
+      ########
+      #P.5.1.#
+      ########`, { fire: 2 })
+    chain.bombs.find((b) => b.timer === 5)!.fire = 2
+    chain.bombs.find((b) => b.timer === 1)!.fire = 2
+    const r = step(chain, { id: 'wait' }).state
+    expect(r.status).toBe('dead')
+    expect(r.death?.cause).toBe('chain')
+  })
+
+  it('nothing while alive, and nothing when Immortal', () => {
+    const s = board(`
+      #####
+      #P1.#
+      #####`)
+    expect(board(`
+      ###
+      #P#
+      ###`).death).toBeNull()
+    s.options = { ...s.options, immortal: true }
+    expect(step(s, { id: 'wait' }).state.death).toBeNull()
+  })
+})