fuzzyworld

explodorama / commit

Initial commit: turn-based bomb-laying roguelike prototype

5551eb37b96f98d55b80114adfbdd9f667933600 · Pavel Safronov · 2026-10-19 23:00:40 -0700

Claude Code usage: this commit ~1.8K out / ~66.8K cached (first commit of its session: everything since it began) · cost unknown (no per-model trailer) · session 1 tally 6 in / 1.8K out / 66.8K cached · usage by commit and session

Initial commit: turn-based bomb-laying roguelike prototype

Board, movement, bombs with move-driven countdowns, chain reactions,
danger preview, and lives. Seeded level generation, LittleJS rendering
with camera scrolling, touch + keyboard controls. Vitest suite for
sim logic, Playwright screenshot tool, GitLab CI config. Enemies,
pickups, shop, and the 50-level run are planned in PLAN.md.

Usage: 6 in / 1.8K out / 66.8K cached (session tally)
rtk:   1.7M saved, 25.3% over 2752 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
 .gitignore                       |   10 +
 .gitlab-ci.yml                   |   25 +
 PLAN.md                          |  579 ++++++++++++++
 README.md                        |   89 +++
 decisions.md                     |   56 ++
 docs/screenshots/bomb-picked.png |  Bin 0 -> 35985 bytes
 docs/screenshots/countdown.png   |  Bin 0 -> 38812 bytes
 docs/screenshots/death.png       |  Bin 0 -> 38055 bytes
 docs/screenshots/desktop.png     |  Bin 0 -> 58710 bytes
 docs/screenshots/scrolled.png    |  Bin 0 -> 35250 bytes
 docs/screenshots/start.png       |  Bin 0 -> 35171 bytes
 index.html                       |   27 +
 package-lock.json                | 1542 ++++++++++++++++++++++++++++++++++++++
 package.json                     |   32 +
 src/main.ts                      |  158 ++++
 src/render/board.ts              |  113 +++
 src/render/camera.ts             |   35 +
 src/sim/actions.ts               |   66 ++
 src/sim/danger.ts                |   24 +
 src/sim/explosions.ts            |   58 ++
 src/sim/levelgen.ts              |   44 ++
 src/sim/record.ts                |   27 +
 src/sim/rng.ts                   |   41 +
 src/sim/state.ts                 |   49 ++
 src/sim/turn.ts                  |   50 ++
 src/sim/types.ts                 |   77 ++
 src/style.css                    |  118 +++
 src/ui/controls.ts               |   72 ++
 test/danger.test.ts              |   55 ++
 test/explosions.test.ts          |   52 ++
 test/helpers.ts                  |   31 +
 test/levelgen.test.ts            |   36 +
 test/turn.test.ts                |  110 +++
 tools/screenshots.mjs            |   53 ++
 tsconfig.json                    |   17 +
 vite.config.ts                   |   20 +
 36 files changed, 3666 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..3ad39ec
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,10 @@
+node_modules/
+dist/
+.DS_Store
+*.local
+
+# Playwright verification screenshots
+.playwright-mcp/
+
+# Left by the test runner.
+.vitest/
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
new file mode 100644
index 0000000..c99b0b7
--- /dev/null
+++ b/.gitlab-ci.yml
@@ -0,0 +1,25 @@
+image: node:22
+
+stages: [test, deploy]
+
+test:
+  stage: test
+  script:
+    - npm ci
+    - npm test
+    - npm run build
+
+pages:
+  stage: deploy
+  script:
+    - npm ci
+    - npm run build
+    # GitLab Pages requires the artifact to be a directory named `public`. There is no
+    # `public/` in the repository (vite.config.ts), so this is a rename, not a nest.
+    - mv dist public
+    # Fail loudly rather than publishing a 404.
+    - test -f public/index.html || (echo "no index.html at artifact root" && exit 1)
+  artifacts:
+    paths: [public]
+  rules:
+    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
diff --git a/PLAN.md b/PLAN.md
new file mode 100644
index 0000000..afe9c7a
--- /dev/null
+++ b/PLAN.md
@@ -0,0 +1,579 @@
+# Explodorama — Plan
+
+A turn-based, mobile-first, roguelike take on the 1985 NES *Bomberman*: 50 levels and an ending.
+Built with **LittleJS + TypeScript + Vite**, tested with **Vitest**, deployed to **GitLab Pages**.
+
+Status: **M0–M2 done, M3 mostly done** (see §18). This document is the design and the hand-off
+for whoever continues the implementation. Read it fully before writing code, especially §1 (the
+turn), §15 (notes for the implementer) and §18 (milestones). Reasons for decisions are in
+`decisions.md`.
+
+---
+
+## 0. Decisions already made
+
+| # | Question | Decision |
+|---|---|---|
+| 1 | Turn order | **Enemies move *before* bombs explode.** Enemies announce their move one turn ahead (intent arrows), so the player can plan traps. |
+| 2 | Run: charge or always available? | **Run is an ability the player gains** (the NES roller skates, "Speed"). Once gained, always available — no charges. |
+| 3 | Lives | **3 lives**, plus a modern roguelike layer: **coins** buy upgrades. |
+| 4 | Undo | **None.** |
+| 5 | Which actions | **All actions named in this plan** (§3), plus any agreed later. |
+| 6 | Name | **Explodorama.** ("Bomberman" is a Konami trademark — never use it in the UI, title, manifest or repo name.) |
+| 7 | Shop purchases | **Last for the current run only.** Permanent progress comes from the Workshop (§7), which is a later milestone. |
+| 8 | Permanent upgrades | **Workshop between runs** (Everspace-style): coins are deposited into upgrade tracks whose tiers cost 5, 10, 15, … coins. See §7. |
+| 9 | Death | **The player chooses** after each death: **Keep** (keep Bomb+, Fire+, Skates) or **Fresh** (lose them, get coins). Balanced so most players would pick each about half the time. See §9. |
+| 10 | Curses | **Exist, but only appear after the first shop** (level 2 onward). See §6. |
+| 11 | Reapers / turn limit | **Later** (post-MVP). The par-turn coin bonus stays. |
+| 12 | Run length | **50 levels with an ending**, like the NES. A win produces a **victory certificate with a QR code** for that seed. See §11. |
+| 13 | Art | **No final art yet.** Use one consistent icon set: **Icongeek26, "Outline" style, from Flaticon** (black line on transparent PNG), with attribution — the same artist the user's other projects use. See §13. |
+
+Still open: see §14.
+
+---
+
+## 1. The turn (the core rule — everything else follows from it)
+
+One turn, in this exact order:
+
+1. **Player acts.** The player picks one action and a target; it resolves immediately
+   (move, run, place bomb, kick, …). Picking up whatever is on the destination tile happens here.
+2. **Enemies execute their announced intents.** Each enemy moves as its intent arrow showed.
+   An enemy whose intended tile became blocked this turn (e.g. the player put a bomb there)
+   stays put — it does **not** re-plan mid-turn.
+3. **Bombs tick.** Every bomb's timer decreases by 1. Bombs reaching **0** explode.
+   Chain reactions resolve **within the same phase** (a bomb caught in a blast explodes now,
+   regardless of its timer).
+4. **Damage.** Flame kills the player (unless protected) and enemies, destroys soft blocks
+   (revealing anything under them), and destroys pickups lying in the open (see §5, NES rule).
+   An enemy sharing the player's tile kills the player.
+5. **Enemies plan.** Every surviving enemy computes and displays its next intent.
+   Curse and Star/Shield turn counters advance. Input unlocks.
+
+Rules that fall out of this:
+
+- **Bomb number = how many more player actions until it explodes.** A newly placed bomb shows
+  its fuse value (default **3**) and is **not** ticked on the turn it is placed. A bomb showing
+  **1** explodes at the end of the player's next action.
+- **Flame is instantaneous.** It exists only during phases 3–4 (and visually during the
+  animation). It does not persist on the board into the next turn.
+- **Contact is checked twice:** after phase 1 (player walked into an enemy — death) and after
+  phase 2 (enemy walked into player — death). Player and enemy swapping tiles counts as contact.
+- **Bombs block movement** for the player and for (most) enemies. Flame stops at the first hard
+  wall or soft block (destroying the soft block); a bomb in the flame's path chain-explodes.
+
+---
+
+## 2. Player stats and abilities
+
+| Stat | Start | Notes |
+|---|---|---|
+| Lives | 3 | Shop and Workshop can add more. |
+| Bomb capacity | 1 | Max own bombs on the board at once. |
+| Fire (blast radius) | 1 | Tiles in each of 4 directions. |
+| Fuse | 3 | Starting timer of the player's bombs. |
+| Abilities | none | From pickups or the shop (§3.2, §5). |
+| Action charges | none | Consumable actions dropped by enemies (§3.3). |
+| Coins | 0 | Spent in the shop (§8); leftovers go to the Workshop (§7). |
+| Curse | none | At most one active at a time (§6). |
+
+Starting values can be raised by Workshop tiers (§7).
+
+---
+
+## 3. Actions
+
+### 3.1 Always available
+
+| Action | Rule | Highlight when selected |
+|---|---|---|
+| **Move** | 1 tile up, down, left or right (WASD). Target must be free (no wall, soft block, bomb). | Up to 4 reachable tiles. |
+| **Place bomb** | On any **adjacent** free tile (orthogonal). Limited by bomb capacity. | Valid tiles. Hovering/pressing one shows the faded **blast cross** and the starting timer. |
+| **Wait** | Do nothing; the world advances one turn. | Nothing (or a subtle pulse on the player). |
+
+### 3.2 Abilities (permanent once gained — no charges)
+
+| Action | Source | Rule |
+|---|---|---|
+| **Run** | Skates pickup (NES "Speed"), or shop | 2 tiles in a straight line. **Both** tiles must be free — no jumping over anything. Highlight: path tile + landing tile. An enemy on the path tile = death (you ran into it). |
+| **Detonate** | Detonator pickup (NES "Detonator"), or shop | Explode one chosen own bomb now. |
+
+### 3.3 Action charges (consumable, dropped by slain enemies)
+
+The button shows a count, e.g. "Kick ×2". Using one spends one charge and the turn.
+
+| Action | Rule |
+|---|---|
+| **Kick** | Push an adjacent bomb; it slides until the next tile is not free. It keeps its timer. The escape hatch for boxing yourself in. |
+| **Throw** | Place a bomb **exactly 2 tiles away** in a straight line, over a wall/soft block/bomb. Target must be free. |
+| **Fuse +1 / Fuse −1** | Change any visible bomb's timer by 1 (minimum 1). One action; the direction is chosen at target time. |
+| **Shield** | Absorb the next hit (flame or enemy). Shown as a ring around the player. Arming it costs the turn. |
+| **Diagonal step** | Move 1 tile diagonally. Both orthogonal neighbours may be blocked — it's a squeeze. |
+| **Teleport** (rare) | Jump to any free tile within Manhattan distance 3. |
+| **Pierce bomb** | Place a bomb whose flame passes **through** soft blocks (destroying all in range). From later Bomberman games. |
+| **Power bomb** | Place a bomb with maximum fire (whole row/column up to hard walls). |
+| **Line bomb** | Place all remaining bomb capacity in a straight line from the player, in one action. From *Super Bomberman*. |
+| **Freeze** | All enemies skip their next move (intents greyed out). |
+| **Decoy** | Place a decoy on an adjacent tile; chasing enemies target it instead of the player for 3 turns. Flame destroys it. |
+
+All charge actions can also appear in the shop (§8).
+
+---
+
+## 4. Readability (turn-based only feels fair if everything is visible)
+
+- **Danger overlay (always on, unless cursed with Fog):** tiles that will burn **this turn** =
+  strong red; **next turn** = light red. Includes chain reactions. Computed by a pure function
+  in `sim/danger.ts` from the current state.
+- **Enemy intent arrows** on every enemy (phase 5). A 2-tile mover shows a 2-step arrow.
+- **Bomb timers:** large number on each bomb; pulses at 1.
+- **Commit preview:** after picking an action and pressing a target, show the outcome
+  (player ghost at destination, faded blast cross, slide path for Kick) before release/confirm.
+- **Pickups, curses and drops** have distinct icons (§13), readable at phone size.
+
+---
+
+## 5. Pickups (NES-style, hidden under soft blocks)
+
+The NES original had exactly 8 power-ups, one hidden per level under a soft block
+(listed from memory — inspiration only, no need to verify):
+
+| NES item | Explodorama equivalent | Kept by "Keep" on death (§9)? |
+|---|---|---|
+| Bombs | **Bomb +1** (capacity) | yes |
+| Flames | **Fire +1** | yes |
+| Speed (roller skates) | **Skates → unlocks Run** | yes |
+| Wallpass | **Wall Pass** — walk through soft blocks | no — always lost on death |
+| Detonator | **Detonator → unlocks Detonate** | no |
+| Bombpass | **Bomb Pass** — walk over bombs | no |
+| Flamepass | **Flame Pass** — immune to flame (tune later if too strong) | no |
+| Mystery | **Star** — invincible for 8 turns | no (it's temporary anyway) |
+
+Additions:
+
+- **Fuse +1** — the player's bombs start at 4. Kept by "Keep".
+- **Coin bag** — +N coins.
+- **Heart** — +1 life (rare).
+
+NES rules kept (they create the classic tension):
+
+- **One pickup per level** under a soft block (more via shop/Workshop upgrades), plus the
+  **exit door** under another soft block.
+- **Flame destroys an exposed pickup** — and spawns extra enemies, as in the NES.
+- **Flaming the exit door** spawns a group of enemies from it.
+- **The exit opens only when all enemies are dead.**
+- **Skates are guaranteed on level 1 or 2** so every player learns Run early.
+
+---
+
+## 6. Curses (from the *Super Bomberman* skull)
+
+- **Never before the first shop** — i.e. not on level 1. From level 2 onward a **skull** can
+  appear: under soft blocks (as an extra item, never replacing the level's pickup) and as a rare
+  enemy drop. Frequency rises gently with level.
+- The skull is **visible** once revealed, so the player can choose to avoid it.
+  **Flaming a skull destroys it safely** (no enemy spawn — unlike pickups), which gives bombs a
+  second use.
+- Walking onto it applies a **random curse for 10 turns** (HUD shows icon + countdown).
+  One curse at a time; a new skull replaces the current curse.
+- Curse list:
+
+| Curse | Effect |
+|---|---|
+| **Short Fuse** | Your bombs start at 2. |
+| **Leaky** | Each turn, if capacity allows, a bomb drops on a random free adjacent tile. |
+| **Butterfingers** | Your bombs land on a random free adjacent tile, not the one you chose. |
+| **Fog** | Danger overlay and enemy intent arrows are hidden. |
+| **Heavy Feet** | Run is disabled. |
+| **Damp** | Fire is 1. |
+
+- **Cures:** wait it out, a Star, or the shop's **Cleanse** charge. Workshop track
+  **Curse Ward** makes the first skull per level harmless.
+- (Open, §14: should skulls also give coins — a risk-for-reward choice?)
+
+---
+
+## 7. The Workshop (permanent upgrades between runs) — later milestone
+
+Like *Everspace*, where you spend between runs on more weapon slots or stronger shields.
+
+- **Coins left over at the end of a run** (win or lose) are banked into the Workshop.
+  (Open, §14: leftover only, or all coins earned?)
+- The Workshop has **upgrade tracks**. Each track has tiers; **tier n costs 5 × n coins**
+  (5, 10, 15, 20, …). Coins can be **deposited partially** — a track shows a progress bar toward
+  its next tier, so even a short run moves something forward.
+- Tracks (each capped; values to tune):
+
+| Track | Per tier | Cap |
+|---|---|---|
+| Extra life | +1 starting life | +2 |
+| Bomb satchel | +1 starting bomb capacity | +2 |
+| Gunpowder | +1 starting fire | +2 |
+| Shop shelf | +1 offer per shop | +2 |
+| Haggler | 1 free reroll per shop | 1 |
+| Charge pouch | Start each run with 1 random action charge | +3 |
+| Lucky | +5% drop chance | +3 |
+| Purse | Start each run with +5 coins | +3 |
+| Prospector | +1 hidden pickup per level | +1 |
+| Curse Ward | First skull per level is harmless | 1 |
+| Skates from the start | Start with Run | 1 |
+
+- Workshop state lives in `localStorage` (try/catch — it can be unavailable). Offer
+  **export/import** of the save as text, because phones clear site data.
+- The Workshop loadout at run start is part of the run record (§11) — it changes the
+  starting state, so replays and certificates must include it.
+
+---
+
+## 8. Coins and the in-run shop
+
+- **Coin sources:** some enemy types always drop coins, others sometimes; occasional coins under
+  soft blocks; Coin bag pickups; a **par bonus** for finishing a level within its turn budget.
+- **Shop after every level** (so the first shop follows level 1). A random selection, e.g.
+  4 offers, one paid reroll:
+  - Stats: Bomb +1, Fire +1, Fuse +1, Heart
+  - Abilities: Skates (Run), Detonator, Bomb Pass, Wall Pass
+  - Action charges: any from §3.3, plus **Cleanse** (removes the current curse)
+  - Run modifiers: **Magnet** (collect drops within 1 tile), **Lucky** (higher drop chance),
+    **Map** (reveals the exit and hidden pickup each level), **Coin multiplier**
+- Shop offers come from the seeded RNG (§12), so a seed always offers the same items.
+- Purchases last for this run only (decision 7).
+
+---
+
+## 9. Death: Keep or Fresh
+
+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**:
+
+| | **Keep** | **Fresh** |
+|---|---|---|
+| Bomb +, Fire +, Fuse +, Skates (Run) | kept | **reset to starting values** |
+| Wall Pass, Bomb Pass, Flame Pass, Detonator, Star | lost (NES rule) | lost |
+| Coins | kept | kept, **plus a coin payout** |
+| Action charges | kept | kept |
+| Curse | removed | removed |
+
+Example buttons: "**Keep** — 💣2 🔥3 Run" / "**Fresh** — +14 coins".
+
+**Fairness (target: most players pick each about half the time).** The trade-off is
+*power now* vs *coins*, and coins are worth more than their shop price because leftovers become
+**permanent Workshop progress** (§7). Starting formula, to tune by playtesting:
+
+    payout = round(0.75 × shop value of everything Fresh resets) + 3
+
+- The flat +3 keeps Fresh meaningful early, when there's little to give up.
+- The 0.75 factor means Fresh never fully refunds the run's power, so it's not a free win late.
+- **Measuring balance:** the app has no backend or telemetry (on purpose). Record each choice in
+  local stats (shown on the run summary and in a dev overlay `?debug=1`), and use playtests.
+- Coins collected during the failed attempt are kept either way.
+- (Open, §14: should Fresh also re-roll the level layout?)
+
+---
+
+## 10. Enemies
+
+All move on their own schedule ("speed"), are blocked by bombs unless stated, and kill on
+contact. Intent shown every turn. Adapted from the NES eight:
+
+| Name (ours) | NES inspiration | Speed | Behaviour | Drops |
+|---|---|---|---|---|
+| Blob | Balloom | 1 tile/turn | Random wander, prefers to keep its direction | rarely coins |
+| Seeker | Oneal | 1 tile/turn | 50% step toward player (BFS), else wander | coins sometimes |
+| Drifter | Doll | 1 tile/turn | Bounces straight until blocked, then turns | action charge sometimes |
+| Sprinter | Minvo | 2 tiles every other turn | Toward player 50% | action charge |
+| Ghost | Kondoria | moves every other turn | Passes through soft blocks; chases | coins |
+| Wisp | Ovapi | 1 tile/turn | Passes through soft blocks; wanders | action charge |
+| Hunter | Pass | 1 tile/turn | Always BFS shortest path to player | coins + charge |
+| Reaper | Pontan | 2 tiles/turn | Passes soft blocks, always chases. **Later milestone:** spawns only as punishment (turn limit, destroyed exit/pickup) | big coins |
+
+- Until Reapers exist, "destroyed pickup/exit" spawns a group of the level's tougher types.
+- Base drop table: ~30–40% action charge, separate coin roll, rare skull from level 2. Tune.
+- Drops land on the enemy's tile and stay until collected or flamed.
+- **Introduction schedule over 50 levels** (like the NES): Blob only at first; one new type
+  every ~6 levels; later levels mix types. Exact table to be drafted in `design.md`.
+
+---
+
+## 11. Levels, scrolling, progression, the ending
+
+- **Grid 31 × 13** like the NES: hard walls around the border, indestructible pillars at every
+  even (x, y), soft blocks placed by seeded RNG at ~30–40% density, start corner (3 tiles in an L)
+  always clear, no enemy within ~4 tiles of the start. (Open, §14: narrower early levels?)
+- **Generation must guarantee:** the exit and the pickup are under soft blocks; every free tile is
+  reachable once soft blocks are gone (assert across many seeds in tests).
+- **Camera:** all 13 rows always fit vertically (portrait phone is the primary target), about
+  7 columns visible on a 390 × 844 phone (measured), since 13 rows must fill the height. Follows the player **horizontally**, eased between turns, clamped to the
+  level edges. Landscape/desktop show more columns.
+- **Par:** each level has a turn budget; finishing under it pays a coin bonus.
+  (Turn **limit** with Reapers: later milestone.)
+- **50 levels**, difficulty rising through enemy count, types, speed and soft-block density.
+- **Game over** at 0 lives → run summary (level reached, coins banked, kills, Keep/Fresh choices,
+  seed) → Workshop → new run.
+- **Ending** after level 50: an ending screen, then the **victory certificate**.
+- **Mid-run save is required**, not optional: a 50-level run won't happen in one sitting on a
+  phone. The save is just the run record (seed + Workshop loadout + action log, §12), replayed on
+  load — or a JSON snapshot of the state plus the log so far.
+
+### Victory certificate
+
+Shows: **seed, date, turns, deaths, Keep/Fresh choices, Workshop loadout, sim version**, and a
+**QR code** linking to `https://pavelsafronov.gitlab.io/explodorama/#cert=…`.
+
+Being honest about what this can prove: the game is fully client-side and open source, so any
+code-only "signature" can be forged by someone determined. What *can* be verified is the run
+itself, because the sim is deterministic:
+
+- The certificate carries a **hash of the full run record** (seed + loadout + every action).
+- **"Save replay"** downloads the run record as a small file. A **verify page** replays it in the
+  real sim and confirms it reaches level 50's ending and matches the certificate's hash.
+- The full record (~7,500 turns × ~5 bits ≈ 5 KB) is too big for a single QR code, so the QR holds
+  the summary + hash, and the replay file is the proof.
+- Bonus: the same replay file lets anyone **watch** the winning run.
+- Replays only work with the same rules, so the record stores `SIM_VERSION`; the verify page
+  must say clearly when a replay comes from a different version.
+
+(Open, §14: is this level of proof wanted, or is a simple checksum enough?)
+
+---
+
+## 12. Seeds and randomness
+
+- 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)`,
+  `hash(seed, "shop", level)`, `hash(seed, "curses", level)`. This way an extra bomb doesn't
+  change what the next shop offers, and the same seed always gives the same 50 level layouts.
+- Never `Math.random()` in `sim/`.
+
+---
+
+## 13. Look and icons
+
+**LittleJS ships no icon set** (its npm package contains only a small built-in font image),
+so the game uses Flaticon icons:
+
+- **Artist and style:** Icongeek26, "Outline" style:
+  <https://www.flaticon.com/search?author_id=296&style_id=1001&type=standard>.
+  Black line art on a transparent background, downloaded as PNG (512 px).
+- **Licence:** Flaticon free licence, which **requires attribution**. Follow `nordic-skating`:
+  credit in the README (Attribution section) and in an in-game About/Credits dialog —
+  "Icons by [Icongeek26](https://www.flaticon.com/authors/icongeek26), from Flaticon."
+  The icons are not covered by the project's own licence; say so in the README.
+- **Who downloads them:** the user, by hand (Flaticon needs a browser session). Until a file is
+  present, the game draws a labelled placeholder shape, and a check (`npm run icons:check` or a
+  test) lists missing icons by name.
+- **Source files:** `assets/icons/<name>.png`, names from the table below.
+- **Atlas:** a `tools/atlas.mjs` script packs them into one `static/atlas.png` (e.g. 128 px
+  cells) plus a generated `src/render/atlas.ts` (name → tile index), so LittleJS loads a single
+  image. Use Playwright for image work, as `nordic-skating/tools/icons.mjs` does — no extra
+  image library.
+- **Colouring:** the icons are black. LittleJS's `drawTile(pos, size, tileInfo, color, angle,
+  mirror, additiveColor)` (confirmed in the type definitions of the pinned 1.25.0) can turn them any colour:
+  black ink + `additiveColor` = that colour, and transparency is kept. The DOM action bar uses the
+  same PNGs as a CSS `mask` over a coloured background (the `nordic-skating` technique).
+- **Board look:** walls, pillars, soft blocks and floor are plain drawn shapes, not icons.
+  Icons are for things: player, enemies, bombs, pickups, drops, curses, door, and actions.
+  Colour shows meaning (player blue, enemies red, pickups green, coins gold, curses purple,
+  danger red overlay), but **shape alone must already tell them apart** — never colour only.
+- **PWA icons and favicons:** generated from one chosen icon (bomb) by `npm run icons`, copied
+  from `nordic-skating/tools/icons.mjs`.
+
+Icon list (search terms are suggestions; any Icongeek26 Outline icon that reads clearly works):
+
+| Name (`assets/icons/<name>.png`) | Used for | Search for |
+|---|---|---|
+| player | the player | robot / astronaut / person |
+| bomb | bombs, Place bomb, app icon | bomb |
+| explosion | flame (animation) | explosion / fire |
+| door | exit (open) | door |
+| lock | exit (closed) | padlock |
+| coin | coins | coin |
+| coin-bag | Coin bag pickup | money bag |
+| heart | Heart pickup, lives in HUD | heart |
+| bomb-plus | Bomb +1 | bombs / plus |
+| fire | Fire +1 | fire / flame |
+| fuse | Fuse +1, Fuse ± action | timer / stopwatch |
+| skates | Skates, Run | roller skate |
+| wall-pass | Wall Pass | ghost / wall |
+| bomb-pass | Bomb Pass | jump / step over |
+| flame-pass | Flame Pass | fireproof / fire shield |
+| detonator | Detonator, Detonate | remote control |
+| star | Star | star |
+| skull | curses | skull |
+| move | Move | footprints |
+| wait | Wait | hourglass |
+| kick | Kick | boot / kick |
+| throw | Throw | throw / hand |
+| shield | Shield | shield |
+| diagonal | Diagonal step | diagonal arrow |
+| teleport | Teleport | portal / teleport |
+| pierce | Pierce bomb | drill |
+| power | Power bomb | lightning |
+| line | Line bomb | line / row |
+| freeze | Freeze | snowflake |
+| decoy | Decoy | scarecrow / dummy |
+| cleanse | Cleanse | potion / sparkle |
+| magnet | Magnet | magnet |
+| lucky | Lucky | clover |
+| map | Map | treasure map |
+| shop | shop | shopping cart / store |
+| workshop | Workshop | wrench / toolbox |
+| trophy | ending, certificate | trophy / medal |
+| enemy-blob | Blob | balloon / slime |
+| enemy-seeker | Seeker | eye |
+| enemy-drifter | Drifter | spinning top |
+| enemy-sprinter | Sprinter | lightning bug / rocket |
+| enemy-ghost | Ghost | ghost |
+| enemy-wisp | Wisp | flame spirit / jellyfish |
+| enemy-hunter | Hunter | spider |
+| enemy-reaper | Reaper (later) | grim reaper / scythe |
+| settings, sound-on, sound-off, info | UI | gear, speaker, speaker off, info |
+
+---
+
+## 14. Open questions
+
+1. **Workshop banking:** only coins **left over** at the end of a run, or **all** coins earned?
+   *Recommendation:* left over — it creates a real "spend now or bank it" choice in every shop.
+2. **Workshop deposits:** is "tier n costs 5 × n, deposit partially with a progress bar" the
+   right reading of "put 5/10/15 coins into a permanently-increasing slot"?
+3. **Fresh start and the level:** should Fresh also **re-roll the level layout** (a new board as
+   well as new pockets)? *Recommendation:* yes, it gives Fresh a feel of its own. It stays
+   deterministic (the attempt number goes into the levelgen stream).
+4. **Skulls with coins:** should a skull also give coins, making it a risk-for-reward choice
+   rather than only something to avoid? *Recommendation:* yes, a small amount.
+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.
+7. **Certificate strength:** replay-verifiable (§11, recommended; nearly free since the sim is
+   deterministic) or a simple checksum?
+8. **Board theme:** light "paper" board with the icons black as drawn (matches the icon style),
+   or dark board with icons recoloured light? *Recommendation:* light by default, dark as a
+   setting/OS preference — `additiveColor` makes both cheap.
+
+---
+
+## 15. Notes for the implementer
+
+- **Read the user's `CLAUDE.md` rules.** In particular: **never commit or push** until the user
+  explicitly confirms they reviewed screenshots and ran the app. Docs must be updated as part of
+  each feature (the README lists every action, pickup, curse and enemy). Run the tests often.
+- **Verify the LittleJS API against the installed `dist/littlejs.d.ts`**, not memory. Already
+  confirmed in the pinned 1.25.0: `engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender,
+  gameRenderPost, imageSources, rootElement): Promise<void>`, `cameraPos`, `cameraScale`,
+  `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
+  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`.
+- **Fit the board:** `cameraScale` = canvas height available between HUD and action bar ÷ 13 rows;
+  recompute on resize/orientation change.
+- **Keep LittleJS out of `sim/`.** If a test needs to import LittleJS, the boundary is wrong.
+- **Record the run from day one.** Every input that changes state — actions, shop purchases,
+  rerolls, Keep/Fresh, Workshop loadout at start — goes through `sim/` and is appended to the run
+  record. That one log gives mid-run save, replays and certificates.
+- **Test priorities** (Vitest): turn phase order; bomb timer semantics (not ticked on placement
+  turn); chain reactions; flame stops at walls and destroys the first soft block; Pierce/Power;
+  Run needs both tiles free; Kick slide stop; enemy blocked by a bomb placed this turn stays put;
+  contact checks including swaps; levelgen invariants across many seeds; danger overlay matches
+  what `step()` actually does (predict → step → compare); **replay determinism** (record a random
+  run, replay it, identical final state and hash); Keep/Fresh payout formula; curses never before
+  the first shop.
+- **Screenshots:** Playwright at a phone viewport (e.g. 390×844) and desktop, with URL params
+  like `?seed=123&level=1` for reproducible scenes. The user reviews them before any commit.
+- **Start simple:** labelled shapes until icons arrive; no animation polish until M7.
+
+---
+
+## 16. Architecture
+
+Golden rule: **all game rules are pure TypeScript with zero LittleJS imports**, deterministic
+given a seed, and fully unit-testable.
+
+```
+src/
+  sim/                 pure, deterministic, no DOM, no LittleJS
+    types.ts             Vec, Dir, Tile, GameState, Event, ActionId …
+    rng.ts               seeded PRNG + per-purpose streams (§12)
+    state.ts             create/clone state, grid helpers
+    levelgen.ts          seeded level generation (§11)
+    actions.ts           registry: { id, isAvailable(state), validTargets(state), apply(state, target) }
+    turn.ts              step(state, action, target) → { state, events[] } — the 5 phases of §1
+    explosions.ts        blast shapes, chain reactions
+    enemies.ts           intent planning per enemy type
+    danger.ts            predicted flame tiles this turn / next turn
+    curses.ts
+    shop.ts              offers, prices, purchase
+    death.ts             Keep / Fresh options and payout
+    workshop.ts          tracks, tiers, deposits
+    record.ts            run record, replay, hash, SIM_VERSION
+  render/              LittleJS
+    board.ts             shapes, icons, highlights, danger overlay, intents
+    atlas.ts             generated by tools/atlas.mjs
+    animate.ts           plays events[] as tweens; owns the input lock
+    camera.ts
+  ui/                  DOM: action bar, HUD, shop, death choice, Workshop, certificate, About
+  audio.ts             ZzFX sound effects (built into LittleJS — no audio files)
+  persist.ts           localStorage save/load, export/import
+  main.ts
+assets/icons/          Icongeek26 PNGs (user-downloaded)
+static/                Vite publicDir (NOT public/ — see §17)
+test/                  Vitest
+tools/                 atlas.mjs, icons.mjs, screenshots.mjs (Playwright)
+```
+
+- `step()` **returns events** (`moved`, `bombPlaced`, `bombTicked`, `exploded`, `blockDestroyed`,
+  `enemyDied`, `dropSpawned`, `pickedUp`, `cursed`, `playerDied`, …). The renderer only plays
+  events back.
+- `step()` does not mutate its input (clone, then mutate the clone) — makes previews and the
+  danger overlay safe.
+- Adding an action = one registry entry + tests + an icon.
+
+---
+
+## 17. Tooling and deploy (copy from `~/code/nordic-skating`)
+
+- `package.json` scripts: `dev` = `vite --host`, `build` = `tsc --noEmit && vite build`,
+  `test` = `vitest run`, `screenshots`, `icons`, `atlas`.
+- Dev deps as in nordic-skating: `typescript`, `vite`, `vitest`, `playwright`,
+  `vite-plugin-qrcode` (scan to open on a phone), `@types/node`. `vite-plugin-pwa` arrives with
+  the icons in M7.
+- Dependency: **`littlejsengine`**, **pinned to exactly 1.25.0** (the newest release at least two
+  weeks old on 2026-10-19). Upgrade deliberately.
+- A QR library for the certificate (e.g. `qrcode`, which `~/code/pretty` already uses).
+- `tsconfig.json`: copy nordic-skating's (strict, `noUncheckedIndexedAccess`, …).
+- `vite.config.ts`: `base: process.env.BASE || './'`, `publicDir: 'static'`, QR plugin, PWA
+  (display fullscreen, icons, offline cache incl. `atlas.png`). Read nordic-skating's file — its
+  comments explain each choice.
+- `.gitlab-ci.yml`: copy nordic-skating's (test stage, then `pages` with `mv dist public` and the
+  "fail loudly" checks; add a check that `atlas.png` exists). Project at
+  `gitlab.com/PavelSafronov/explodorama`, served at `https://pavelsafronov.gitlab.io/explodorama/`.
+- Docs to create and keep current: `README.md` (how to play; every action, pickup, curse, enemy,
+  Workshop track; Attribution), this `PLAN.md`, `decisions.md` (each decision + why — seed it
+  from §0), `design.md` (balance tables: enemy schedule, prices, drop rates, payout formula).
+
+---
+
+## 18. Milestones
+
+1. **M0 – Scaffold:** ✅ npm, Vite, TS, LittleJS, Vitest, `git init`, CI file, README with
+   Attribution section. ⏳ GitLab remote, first commit and first Pages deploy — waiting on the user.
+2. **M1 – Sim core:** ✅ types, RNG streams, grid, Move, Wait, `step()` with all 5 phases
+   (enemy phases are marked placeholders), run record + replay, tests.
+3. **M2 – Bombs:** ✅ adjacent placement, timers, blast, chains, soft blocks, death, lives.
+4. **M3 – Mobile UI:** ✅ action bar, two-tap targeting, highlights, danger overlay, commit preview
+   (press-and-hold / hover), keyboard controls, placeholder shapes. ⏳ No animation of moves yet
+   (instant), only a flame flash.
+5. **M4 – World:** ✅ levelgen (31 × 13), side-scrolling camera. ⏳ Exit door, par.
+6. **M5 – Enemies:** Blob, Seeker, Drifter first; intents, drops, charge inventory, charge actions.
+7. **M6 – Run loop:** NES pickups, coins, shop after each level, Keep/Fresh death choice, curses
+   from level 2, 50-level progression and ending, mid-run save.
+8. **M7 – Look & feel:** icon atlas + attribution, animations, ZzFX sounds, PWA icons, remaining
+   enemies and actions, screenshots tool.
+9. **M8 – Permanence:** Workshop, save export/import, victory certificate + QR, replay file and
+   verify page.
+10. **Later:** Reapers + turn limit, daily seed, final art.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..4fcfa59
--- /dev/null
+++ b/README.md
@@ -0,0 +1,89 @@
+# Explodorama
+
+A turn-based, mobile-first, roguelike take on the 1985 NES bomb-laying classic. Every bomb
+counts down with *your* moves, so nothing happens until you act — and you can see exactly
+what will burn before you commit.
+
+Status: **early development.** The board, moving, bombs, chain reactions and lives work.
+Enemies, pickups, the shop and the 50-level run are planned — see [PLAN.md](PLAN.md).
+
+![The board on a phone, with a bomb counting down](docs/screenshots/countdown.png)
+
+## How to play
+
+You are the blue circle. Each turn you pick **one** action; then the world takes its turn.
+
+| Action | Phone | Keyboard | What it does |
+|---|---|---|---|
+| **Move** | Tap a tile next to you | W A S D or arrow keys | Step one tile up, down, left or right onto an empty tile. |
+| **Bomb** | Tap **Bomb**, then a highlighted tile | **B**, then a direction | Place a bomb on an empty tile next to you. You can have 1 bomb on the board at a time. |
+| **Wait** | Tap **Wait** | Space | Do nothing; the world takes its turn. |
+
+- Picking an action highlights the tiles you can choose, in blue. Tapping the action again,
+  pressing **Esc**, or tapping anywhere else cancels it.
+- **Preview before you commit:** on a phone, press and hold a highlighted tile to see the
+  result (for a bomb, its blast in orange); slide to another tile to change your mind; let go
+  to commit. With a mouse, hovering shows the same preview.
+
+### Bombs
+
+- The number on a bomb is **how many more of your actions until it explodes**. A new bomb
+  shows **3**. A bomb showing **1** (red, pulsing) explodes at the end of your next action.
+- The blast is a cross reaching **1 tile** in each direction. It stops at walls; it destroys
+  the first soft block (brown) in each direction and stops there.
+- A bomb caught in a blast **explodes at once**, whatever its number (a chain reaction).
+- Bombs block movement.
+
+### Danger
+
+- **Strong red** tiles will burn at the end of this turn if you wait.
+- **Light red** tiles will burn at the end of the next turn.
+
+### Lives
+
+You have **3 lives**. Caught in a blast, you lose one and the level starts again with the same
+layout. With none left, the run is over and the seed is shown, so the same board can be replayed.
+
+### The board
+
+31 × 13 tiles, like the original: dark walls and pillars (indestructible), brown soft blocks
+(destructible), and the camera follows you sideways.
+
+## Running it
+
+```sh
+npm install
+npm run dev          # http://localhost:5173 — also prints a QR code to open it on a phone on the same Wi-Fi
+npm test             # Vitest
+npm run build        # type-check and build into dist/
+npm run screenshots  # build, then photograph the game into docs/screenshots/ (Playwright)
+```
+
+URL parameters: `?seed=123` plays a fixed board; `?level=4` starts at a later level.
+
+Deployed to GitLab Pages by `.gitlab-ci.yml` on every push to the default branch.
+
+## How it's built
+
+- **`src/sim/`** — all the game's rules, in plain TypeScript with no rendering. Deterministic:
+  every random choice comes from the seed. `step(state, action)` plays one turn and returns the
+  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).
+- **`src/ui/`** — the HUD, action bar and banners, as plain HTML.
+- Every run is recorded as its seed plus the list of actions, which replays to the identical
+  state. This will become mid-run saves, replays and victory certificates.
+
+Design and decisions: [PLAN.md](PLAN.md), [decisions.md](decisions.md).
+
+## Attribution
+
+No third-party artwork is used yet. The planned icons are by
+[Icongeek26](https://www.flaticon.com/authors/icongeek26), from Flaticon, under the Flaticon
+free licence, which requires attribution; they will be credited here and in the game, and will
+not be covered by this project's licence.
+
+## Licence
+
+MIT.
diff --git a/decisions.md b/decisions.md
new file mode 100644
index 0000000..dce4a51
--- /dev/null
+++ b/decisions.md
@@ -0,0 +1,56 @@
+# Decisions
+
+Each design or technical decision, why it was made, and when. Newest last. The full design is
+in [PLAN.md](PLAN.md); this file records the reasoning so it isn't lost.
+
+## Design (agreed in planning, 2026-10-19)
+
+1. **Enemies move before bombs explode.** Enemies announce their next move (intent arrows), so
+   the player can plan traps. Luck matters less than planning.
+2. **Run is an ability, not a charge** — gained like the NES roller skates.
+3. **3 lives**, plus a roguelike layer of coins and upgrades.
+4. **No undo.**
+5. **All actions in PLAN.md §3** are in scope.
+6. **Name: Explodorama.** "Bomberman" is a Konami trademark and must never appear in the game.
+7. **Shop purchases last for the current run only.**
+8. **Permanent upgrades come from a Workshop between runs** (Everspace-style tracks, tier n costs
+   5 × n coins). Later milestone.
+9. **On death the player chooses Keep or Fresh**, balanced so each is picked about half the
+   time. See PLAN.md §9.
+10. **Curses only appear after the first shop.**
+11. **Reapers and the turn limit come later.**
+12. **50 levels and an ending**, with a victory certificate and QR code.
+13. **Icons: Icongeek26 "Outline" from Flaticon**, with attribution, until there is final art.
+    LittleJS ships no icons.
+
+## Technical
+
+### 2026-10-19 — LittleJS pinned to 1.25.0
+
+Exact version, the newest release that was at least two weeks old at install time (1.27.2 was
+4 days old). Upgrade deliberately, not by range.
+
+### 2026-10-19 — Canvas 2D renderer, not WebGL
+
+`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
+reviewed before committing, so they must be trustworthy. The board is a few hundred rectangles;
+Canvas 2D is plenty. Revisit only if drawing becomes slow.
+
+### 2026-10-19 — Taps handled with pointer events on the canvas, not LittleJS's mouse input
+
+Press shows the commit preview on the tile under the finger, sliding changes it, release
+commits. That needs press, move and release positions, which pointer events give directly.
+LittleJS still handles nothing on the HTML buttons: it skips `preventDefault` for buttons.
+
+### 2026-10-19 — Bombs block flame by exploding; flame stops on a bomb
+
+A blast reaching another bomb sets it off and stops there; the second bomb's own blast covers
+the tiles beyond. Soft blocks hit during a chain are only removed once the whole chain is done,
+so every flame that reaches a block that turn stops at it.
+
+### 2026-10-19 — PWA deferred to M7
+
+`vite-plugin-pwa` needs icons, which come with the icon set in M7. The CI checks for a manifest
+and service worker will be added then.
diff --git a/docs/screenshots/bomb-picked.png b/docs/screenshots/bomb-picked.png
new file mode 100644
index 0000000..82b288a
Binary files /dev/null and b/docs/screenshots/bomb-picked.png differ
diff --git a/docs/screenshots/countdown.png b/docs/screenshots/countdown.png
new file mode 100644
index 0000000..9014a94
Binary files /dev/null and b/docs/screenshots/countdown.png differ
diff --git a/docs/screenshots/death.png b/docs/screenshots/death.png
new file mode 100644
index 0000000..e6e12b9
Binary files /dev/null and b/docs/screenshots/death.png differ
diff --git a/docs/screenshots/desktop.png b/docs/screenshots/desktop.png
new file mode 100644
index 0000000..2ba5115
Binary files /dev/null and b/docs/screenshots/desktop.png differ
diff --git a/docs/screenshots/scrolled.png b/docs/screenshots/scrolled.png
new file mode 100644
index 0000000..7b22db6
Binary files /dev/null and b/docs/screenshots/scrolled.png differ
diff --git a/docs/screenshots/start.png b/docs/screenshots/start.png
new file mode 100644
index 0000000..3145463
Binary files /dev/null and b/docs/screenshots/start.png differ
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..8002e41
--- /dev/null
+++ b/index.html
@@ -0,0 +1,27 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8" />
+<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no" />
+<title>Explodorama</title>
+<meta name="description" content="A turn-based, mobile-first, roguelike bomb-laying game. Every bomb counts down with your moves." />
+<meta name="theme-color" content="#f4efe4" />
+<meta name="mobile-web-app-capable" content="yes" />
+<meta name="apple-mobile-web-app-capable" content="yes" />
+<link rel="stylesheet" href="/src/style.css" />
+</head>
+<body>
+<header id="hud" aria-live="polite">
+  <span id="hud-lives"></span>
+  <span id="hud-level"></span>
+  <span id="hud-turn"></span>
+  <span id="hud-stats"></span>
+</header>
+<nav id="actions" aria-label="Actions"></nav>
+<div id="banner" hidden>
+  <p id="banner-text"></p>
+  <button id="banner-button" type="button"></button>
+</div>
+<script type="module" src="/src/main.ts"></script>
+</body>
+</html>
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..c9f1a48
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,1542 @@
+{
+  "name": "explodorama",
+  "version": "0.1.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "explodorama",
+      "version": "0.1.0",
+      "license": "MIT",
+      "dependencies": {
+        "littlejsengine": "1.25.0"
+      },
+      "devDependencies": {
+        "@types/node": "^26.6.5",
+        "playwright": "^1.63.0",
+        "typescript": "^7.0.2",
+        "vite": "^8.3.4",
+        "vite-plugin-qrcode": "^0.4.1",
+        "vitest": "^5.0.5"
+      }
+    },
+    "node_modules/@jridgewell/resolve-uri": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+      "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@jridgewell/sourcemap-codec": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
+      "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@jridgewell/trace-mapping": {
+      "version": "0.3.31",
+      "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+      "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/resolve-uri": "^3.1.0",
+        "@jridgewell/sourcemap-codec": "^1.4.14"
+      }
+    },
+    "node_modules/@oxc-project/types": {
+      "version": "0.154.0",
+      "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.154.0.tgz",
+      "integrity": "sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA==",
+      "dev": true,
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/oxc-project"
+      }
+    },
+    "node_modules/@rolldown/binding-android-arm-eabi": {
+      "version": "1.2.14",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.14.tgz",
+      "integrity": "sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-android-arm64": {
+      "version": "1.2.14",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.14.tgz",
+      "integrity": "sha512-QiVxHofl6A8lQU7WAtOUY4QMVZXohxwz7WJJoeh8NBEUyKfIkMRKhYyq8az/1RypNiIXzNTtiS6KwHz0erxYRQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-darwin-arm64": {
+      "version": "1.2.14",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.14.tgz",
+      "integrity": "sha512-lTQOzHuBIooh3jIr5XINQkjPGEuGy+Dsy0meXDr6Sjty0b99kYgGh4uBC/8mJX1T0kC8d6S0aPP1Y+bHkh6aBw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-darwin-x64": {
+      "version": "1.2.14",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.14.tgz",
+      "integrity": "sha512-hyMKEGatjmBYCs/Q4sbIcL/GqpFEqsX3wc+HYVQ/85A4rxc1o7rPm4DUI4GV4k/h0ITddwFSGpqQunI14GXjTA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-freebsd-x64": {
+      "version": "1.2.14",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.14.tgz",
+      "integrity": "sha512-u371GbXoVXrEgP20PwzoGNOdAOqAMhzo19JzzY+V4YmmnC+DTOoqdbMu+O+54emM59Mik/UdrtWgcuEDKWRPKA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+      "version": "1.2.14",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.14.tgz",
+      "integrity": "sha512-BqwKsdZQf6EqVWr8fv2MrDYzAvOZ7+NYFSJESRGs87fkseEhs0TU9TXgRJTkZoRRkxTizyEg1LKUXfU3/78uqg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-arm64-gnu": {
+      "version": "1.2.14",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.14.tgz",
+      "integrity": "sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-arm64-musl": {
+      "version": "1.2.14",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.14.tgz",
+      "integrity": "sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+      "version": "1.2.14",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.14.tgz",
+      "integrity": "sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-s390x-gnu": {
+      "version": "1.2.14",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.14.tgz",
+      "integrity": "sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-x64-gnu": {
+      "version": "1.2.14",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.14.tgz",
+      "integrity": "sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-x64-musl": {
+      "version": "1.2.14",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.14.tgz",
+      "integrity": "sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-openharmony-arm64": {
+      "version": "1.2.14",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.14.tgz",
+      "integrity": "sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-win32-arm64-msvc": {
+      "version": "1.2.14",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.14.tgz",
+      "integrity": "sha512-oA4o5o0S2yB199YyYXDzKUv1Z56Rc+mgOjwm7Z/vJ9Z8igiDPYvoOuDyo6g7AK/ZaF09nPhDKVnGBcYHO11GXA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-win32-x64-msvc": {
+      "version": "1.2.14",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.14.tgz",
+      "integrity": "sha512-9mE94bf2GZUaR/fUSXCMpBoWkctu421Mi0ORpm4zqfbkjKep7RxzPFa5etcGs5h+2KApoWBOA/hAJa/7saGgEQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/pluginutils": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+      "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/chai": {
+      "version": "5.2.3",
+      "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+      "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/deep-eql": "*",
+        "assertion-error": "^2.0.1"
+      }
+    },
+    "node_modules/@types/deep-eql": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+      "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/estree": {
+      "version": "1.0.9",
+      "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+      "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/node": {
+      "version": "26.6.5",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz",
+      "integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "undici-types": "~8.9.0"
+      }
+    },
+    "node_modules/@typescript/typescript-aix-ppc64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
+      "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-darwin-arm64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
+      "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-darwin-x64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
+      "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-freebsd-arm64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
+      "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-freebsd-x64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
+      "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-linux-arm": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
+      "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-linux-arm64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
+      "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-linux-loong64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
+      "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-linux-mips64el": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
+      "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-linux-ppc64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
+      "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-linux-riscv64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
+      "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-linux-s390x": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
+      "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-linux-x64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
+      "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-netbsd-arm64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
+      "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-netbsd-x64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
+      "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-openbsd-arm64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
+      "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-openbsd-x64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
+      "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-sunos-x64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
+      "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-win32-arm64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
+      "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-win32-x64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
+      "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@vitest/mocker": {
+      "version": "5.0.5",
+      "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.5.tgz",
+      "integrity": "sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/trace-mapping": "0.3.31",
+        "@vitest/spy": "5.0.5",
+        "estree-walker": "^3.0.3",
+        "magic-string": "^1.4.3"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      },
+      "peerDependencies": {
+        "msw": "^2.4.9 || ^3.0.0",
+        "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+      },
+      "peerDependenciesMeta": {
+        "msw": {
+          "optional": true
+        },
+        "vite": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@vitest/spy": {
+      "version": "5.0.5",
+      "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.5.tgz",
+      "integrity": "sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==",
+      "dev": true,
+      "license": "MIT",
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/assertion-error": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+      "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/chai": {
+      "version": "6.3.0",
+      "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz",
+      "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/detect-libc": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+      "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/es-module-lexer": {
+      "version": "2.3.2",
+      "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
+      "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/estree-walker": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+      "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "^1.0.0"
+      }
+    },
+    "node_modules/expect-type": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+      "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=12.0.0"
+      }
+    },
+    "node_modules/fdir": {
+      "version": "6.5.0",
+      "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+      "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "peerDependencies": {
+        "picomatch": "^3 || ^4"
+      },
+      "peerDependenciesMeta": {
+        "picomatch": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/lightningcss": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
+      "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
+      "dev": true,
+      "license": "MPL-2.0",
+      "dependencies": {
+        "detect-libc": "^2.0.3"
+      },
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      },
+      "optionalDependencies": {
+        "lightningcss-android-arm64": "1.33.0",
+        "lightningcss-darwin-arm64": "1.33.0",
+        "lightningcss-darwin-x64": "1.33.0",
+        "lightningcss-freebsd-x64": "1.33.0",
+        "lightningcss-linux-arm-gnueabihf": "1.33.0",
+        "lightningcss-linux-arm64-gnu": "1.33.0",
+        "lightningcss-linux-arm64-musl": "1.33.0",
+        "lightningcss-linux-x64-gnu": "1.33.0",
+        "lightningcss-linux-x64-musl": "1.33.0",
+        "lightningcss-win32-arm64-msvc": "1.33.0",
+        "lightningcss-win32-x64-msvc": "1.33.0"
+      }
+    },
+    "node_modules/lightningcss-android-arm64": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
+      "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-darwin-arm64": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
+      "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-darwin-x64": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
+      "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-freebsd-x64": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
+      "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-arm-gnueabihf": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
+      "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-arm64-gnu": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
+      "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-arm64-musl": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
+      "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-x64-gnu": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
+      "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-x64-musl": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
+      "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-win32-arm64-msvc": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
+      "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-win32-x64-msvc": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
+      "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/littlejsengine": {
+      "version": "1.25.0",
+      "resolved": "https://registry.npmjs.org/littlejsengine/-/littlejsengine-1.25.0.tgz",
+      "integrity": "sha512-qk/Vhua9IemOD+mSOGB1AbGCSoJV4AY7aC62d+7G9FDlxXVfokk39oXuVixfwxoV6n6Y4b24scMcgtTtmhQI1w==",
+      "license": "MIT",
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/magic-string": {
+      "version": "1.4.3",
+      "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz",
+      "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/sourcemap-codec": "^1.6.0"
+      }
+    },
+    "node_modules/nanoid": {
+      "version": "3.3.20",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
+      "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "bin": {
+        "nanoid": "bin/nanoid.cjs"
+      },
+      "engines": {
+        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+      }
+    },
+    "node_modules/obug": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz",
+      "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==",
+      "dev": true,
+      "funding": [
+        "https://github.com/sponsors/sxzz",
+        "https://opencollective.com/debug"
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=12.20.0"
+      }
+    },
+    "node_modules/picocolors": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/picomatch": {
+      "version": "4.0.7",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+      "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/jonschlinkert"
+      }
+    },
+    "node_modules/playwright": {
+      "version": "1.63.0",
+      "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
+      "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "playwright-core": "1.63.0"
+      },
+      "bin": {
+        "playwright": "cli.js"
+      },
+      "engines": {
+        "node": ">=20"
+      }
+    },
+    "node_modules/playwright-core": {
+      "version": "1.63.0",
+      "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
+      "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "playwright-core": "cli.js"
+      },
+      "engines": {
+        "node": ">=20"
+      }
+    },
+    "node_modules/postcss": {
+      "version": "8.5.31",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz",
+      "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/postcss"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "nanoid": "^3.3.20",
+        "picocolors": "^1.1.1",
+        "source-map-js": "^1.2.2"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      }
+    },
+    "node_modules/rolldown": {
+      "version": "1.2.14",
+      "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.14.tgz",
+      "integrity": "sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@oxc-project/types": "=0.154.0",
+        "@rolldown/pluginutils": "^1.0.0"
+      },
+      "bin": {
+        "rolldown": "bin/cli.mjs"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      },
+      "optionalDependencies": {
+        "@rolldown/binding-android-arm-eabi": "1.2.14",
+        "@rolldown/binding-android-arm64": "1.2.14",
+        "@rolldown/binding-darwin-arm64": "1.2.14",
+        "@rolldown/binding-darwin-x64": "1.2.14",
+        "@rolldown/binding-freebsd-x64": "1.2.14",
+        "@rolldown/binding-linux-arm-gnueabihf": "1.2.14",
+        "@rolldown/binding-linux-arm64-gnu": "1.2.14",
+        "@rolldown/binding-linux-arm64-musl": "1.2.14",
+        "@rolldown/binding-linux-ppc64-gnu": "1.2.14",
+        "@rolldown/binding-linux-s390x-gnu": "1.2.14",
+        "@rolldown/binding-linux-x64-gnu": "1.2.14",
+        "@rolldown/binding-linux-x64-musl": "1.2.14",
+        "@rolldown/binding-openharmony-arm64": "1.2.14",
+        "@rolldown/binding-win32-arm64-msvc": "1.2.14",
+        "@rolldown/binding-win32-x64-msvc": "1.2.14"
+      }
+    },
+    "node_modules/source-map-js": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
+      "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/std-env": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz",
+      "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/tinybench": {
+      "version": "6.2.2",
+      "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz",
+      "integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=20.0.0"
+      }
+    },
+    "node_modules/tinyexec": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz",
+      "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/tinyglobby": {
+      "version": "0.2.17",
+      "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+      "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "fdir": "^6.5.0",
+        "picomatch": "^4.0.4"
+      },
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/SuperchupuDev"
+      }
+    },
+    "node_modules/typescript": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
+      "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "tsc": "bin/tsc"
+      },
+      "engines": {
+        "node": ">=16.20.0"
+      },
+      "optionalDependencies": {
+        "@typescript/typescript-aix-ppc64": "7.0.2",
+        "@typescript/typescript-darwin-arm64": "7.0.2",
+        "@typescript/typescript-darwin-x64": "7.0.2",
+        "@typescript/typescript-freebsd-arm64": "7.0.2",
+        "@typescript/typescript-freebsd-x64": "7.0.2",
+        "@typescript/typescript-linux-arm": "7.0.2",
+        "@typescript/typescript-linux-arm64": "7.0.2",
+        "@typescript/typescript-linux-loong64": "7.0.2",
+        "@typescript/typescript-linux-mips64el": "7.0.2",
+        "@typescript/typescript-linux-ppc64": "7.0.2",
+        "@typescript/typescript-linux-riscv64": "7.0.2",
+        "@typescript/typescript-linux-s390x": "7.0.2",
+        "@typescript/typescript-linux-x64": "7.0.2",
+        "@typescript/typescript-netbsd-arm64": "7.0.2",
+        "@typescript/typescript-netbsd-x64": "7.0.2",
+        "@typescript/typescript-openbsd-arm64": "7.0.2",
+        "@typescript/typescript-openbsd-x64": "7.0.2",
+        "@typescript/typescript-sunos-x64": "7.0.2",
+        "@typescript/typescript-win32-arm64": "7.0.2",
+        "@typescript/typescript-win32-x64": "7.0.2"
+      }
+    },
+    "node_modules/undici-types": {
+      "version": "8.9.0",
+      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
+      "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/uqr": {
+      "version": "0.1.3",
+      "resolved": "https://registry.npmjs.org/uqr/-/uqr-0.1.3.tgz",
+      "integrity": "sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/vite": {
+      "version": "8.3.4",
+      "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz",
+      "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "lightningcss": "^1.33.0",
+        "picomatch": "^4.0.7",
+        "postcss": "^8.5.29",
+        "rolldown": "~1.2.12",
+        "tinyglobby": "^0.2.17"
+      },
+      "bin": {
+        "vite": "bin/vite.js"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      },
+      "funding": {
+        "url": "https://github.com/vitejs/vite?sponsor=1"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.3"
+      },
+      "peerDependencies": {
+        "@types/node": "^20.19.0 || >=22.12.0",
+        "@vitejs/devtools": "^0.7.1",
+        "esbuild": "^0.27.0 || ^0.28.0",
+        "jiti": ">=1.21.0",
+        "less": "^4.0.0",
+        "sass": "^1.70.0",
+        "sass-embedded": "^1.70.0",
+        "stylus": ">=0.54.8",
+        "sugarss": "^5.0.0",
+        "terser": "^5.16.0",
+        "tsx": "^4.8.1",
+        "yaml": "^2.4.2"
+      },
+      "peerDependenciesMeta": {
+        "@types/node": {
+          "optional": true
+        },
+        "@vitejs/devtools": {
+          "optional": true
+        },
+        "esbuild": {
+          "optional": true
+        },
+        "jiti": {
+          "optional": true
+        },
+        "less": {
+          "optional": true
+        },
+        "sass": {
+          "optional": true
+        },
+        "sass-embedded": {
+          "optional": true
+        },
+        "stylus": {
+          "optional": true
+        },
+        "sugarss": {
+          "optional": true
+        },
+        "terser": {
+          "optional": true
+        },
+        "tsx": {
+          "optional": true
+        },
+        "yaml": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vite-plugin-qrcode": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/vite-plugin-qrcode/-/vite-plugin-qrcode-0.4.1.tgz",
+      "integrity": "sha512-t3gdCbn/a2HHLK3M8bUrQ+4c+I0YQGJ/STxAL9eI6TBnfdeM/CJr3HctvOb7UYhEtWxHs5xZWpz7kYnRd5w+rA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "uqr": "^0.1.2"
+      },
+      "peerDependencies": {
+        "vite": ">=3"
+      }
+    },
+    "node_modules/vitest": {
+      "version": "5.0.5",
+      "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.5.tgz",
+      "integrity": "sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/chai": "^5.2.2",
+        "@vitest/mocker": "5.0.5",
+        "chai": "^6.3.0",
+        "es-module-lexer": "^2.3.2",
+        "expect-type": "^1.4.0",
+        "magic-string": "^1.4.3",
+        "obug": "^3.0.0",
+        "picomatch": "^4.0.7",
+        "std-env": "^4.3.0",
+        "tinybench": "^6.2.2",
+        "tinyexec": "^1.3.1",
+        "tinyglobby": "^0.2.17",
+        "why-is-node-running": "3.2.1"
+      },
+      "bin": {
+        "vitest": "vitest.mjs"
+      },
+      "engines": {
+        "node": "^22.12.0 || ^24.0.0 || >=26.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      },
+      "peerDependencies": {
+        "@edge-runtime/vm": "*",
+        "@opentelemetry/api": "^1.9.0",
+        "@types/node": "^22.0.0 || >=24.0.0",
+        "@vitest/browser-playwright": "5.0.5",
+        "@vitest/browser-preview": "5.0.5",
+        "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0",
+        "@vitest/coverage-istanbul": "5.0.5",
+        "@vitest/coverage-v8": "5.0.5",
+        "@vitest/ui": "5.0.5",
+        "happy-dom": "*",
+        "jsdom": "*",
+        "vite": "^6.4.0 || ^7.0.0 || ^8.0.0"
+      },
+      "peerDependenciesMeta": {
+        "@edge-runtime/vm": {
+          "optional": true
+        },
+        "@opentelemetry/api": {
+          "optional": true
+        },
+        "@types/node": {
+          "optional": true
+        },
+        "@vitest/browser-playwright": {
+          "optional": true
+        },
+        "@vitest/browser-preview": {
+          "optional": true
+        },
+        "@vitest/browser-webdriverio": {
+          "optional": true
+        },
+        "@vitest/coverage-istanbul": {
+          "optional": true
+        },
+        "@vitest/coverage-v8": {
+          "optional": true
+        },
+        "@vitest/ui": {
+          "optional": true
+        },
+        "happy-dom": {
+          "optional": true
+        },
+        "jsdom": {
+          "optional": true
+        },
+        "vite": {
+          "optional": false
+        }
+      }
+    },
+    "node_modules/why-is-node-running": {
+      "version": "3.2.1",
+      "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz",
+      "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "why-is-node-running": "cli.js"
+      },
+      "engines": {
+        "node": ">=20.11"
+      }
+    }
+  }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..a5c51ae
--- /dev/null
+++ b/package.json
@@ -0,0 +1,32 @@
+{
+  "name": "explodorama",
+  "private": true,
+  "version": "0.1.0",
+  "type": "module",
+  "description": "A turn-based, mobile-first, roguelike take on the 1985 NES bomb-laying classic.",
+  "scripts": {
+    "dev": "vite --host",
+    "build": "tsc --noEmit && vite build",
+    "preview": "vite preview",
+    "test": "vitest run",
+    "test:watch": "vitest",
+    "screenshots": "npm run build && node tools/screenshots.mjs"
+  },
+  "repository": {
+    "type": "git",
+    "url": "https://gitlab.com/PavelSafronov/explodorama.git"
+  },
+  "homepage": "https://pavelsafronov.gitlab.io/explodorama/",
+  "license": "MIT",
+  "dependencies": {
+    "littlejsengine": "1.25.0"
+  },
+  "devDependencies": {
+    "@types/node": "^26.6.5",
+    "playwright": "^1.63.0",
+    "typescript": "^7.0.2",
+    "vite": "^8.3.4",
+    "vite-plugin-qrcode": "^0.4.1",
+    "vitest": "^5.0.5"
+  }
+}
diff --git a/src/main.ts b/src/main.ts
new file mode 100644
index 0000000..ec5f51d
--- /dev/null
+++ b/src/main.ts
@@ -0,0 +1,158 @@
+import {
+  engineInit,
+  mainCanvas,
+  screenToWorld,
+  setCanvasClearColor,
+  setCanvasPixelRatio,
+  setGLEnable,
+  setShowSplashScreen,
+  time,
+  vec2,
+} from 'littlejsengine'
+import { BACKGROUND, drawBoard, FLAME_SECONDS, toTile, type Flame } from './render/board'
+import { updateCamera } from './render/camera'
+import { isAvailable, isValidTarget, ACTIONS } from './sim/actions'
+import { predictDanger, type Danger } from './sim/danger'
+import { newRecord, type RunRecord } from './sim/record'
+import { add, inBounds, newRun } from './sim/state'
+import { step } from './sim/turn'
+import { DIRS, type Action, type ActionId, type Dir, type GameState, type Vec } from './sim/types'
+import { createControls } from './ui/controls'
+
+// `?seed=123&level=4` gives a reproducible board (screenshots, bug reports). Otherwise a random
+// seed — chosen here, never inside the sim.
+const params = new URLSearchParams(location.search)
+const startLevel = Number(params.get('level')) || 1
+
+let record: RunRecord
+let state: GameState
+let danger: Danger
+let selected: ActionId | null = null
+let hover: Vec | null = null
+let flames: Flame[] = []
+let snapCamera = true
+
+function startRun(seed: number): void {
+  record = newRecord(seed, startLevel)
+  state = newRun(seed, startLevel)
+  afterChange()
+}
+
+function randomSeed(): number {
+  return Math.floor(Math.random() * 2 ** 31)
+}
+
+function act(action: Action): void {
+  const result = step(state, action)
+  state = result.state
+  record.actions.push(action)
+  for (const e of result.events) {
+    if (e.type === 'exploded') flames.push({ tiles: e.tiles, born: time })
+    if (e.type === 'levelRestarted') snapCamera = true
+  }
+  selected = null
+  afterChange()
+}
+
+function afterChange(): void {
+  danger = predictDanger(state)
+  controls.update(state, selected)
+}
+
+/** Two taps: pick an action, then a highlighted tile. With nothing picked, a tap next to the player moves. */
+function tapTile(p: Vec): void {
+  if (state.status !== 'playing') return
+  if (selected) {
+    if (isValidTarget(state, selected, p)) act({ id: selected, target: p })
+    else {
+      selected = null
+      afterChange()
+    }
+  } else if (isValidTarget(state, 'move', p)) act({ id: 'move', target: p })
+}
+
+function pick(id: ActionId): void {
+  if (!isAvailable(state, id)) return
+  if (!ACTIONS[id].needsTarget) act({ id })
+  else {
+    selected = selected === id ? null : id
+    afterChange()
+  }
+}
+
+/** A direction key: places a bomb that way if Bomb is picked, otherwise moves. */
+function direction(d: Dir): void {
+  if (state.status !== 'playing') return
+  const p = add(state.player, DIRS[d])
+  const id: ActionId = selected === 'bomb' ? 'bomb' : 'move'
+  if (isValidTarget(state, id, p)) act({ id, target: p })
+}
+
+const controls = createControls({
+  pick,
+  banner() {
+    if (state.status === 'dead') act({ id: 'continue' })
+    else if (state.status === 'gameOver') startRun(randomSeed())
+  },
+})
+
+const KEY_DIRS: Record<string, Dir> = {
+  KeyW: 'up', ArrowUp: 'up',
+  KeyS: 'down', ArrowDown: 'down',
+  KeyA: 'left', ArrowLeft: 'left',
+  KeyD: 'right', ArrowRight: 'right',
+}
+
+window.addEventListener('keydown', (e) => {
+  if (e.repeat) return
+  const d = KEY_DIRS[e.code]
+  if (d) direction(d)
+  else if (e.code === 'KeyB') pick('bomb')
+  else if (e.code === 'Space') pick('wait')
+  else if (e.code === 'Escape') {
+    selected = null
+    afterChange()
+  } else if (e.code === 'Enter') controls.pressBanner()
+})
+
+function tileUnder(e: PointerEvent): Vec | null {
+  const r = mainCanvas.getBoundingClientRect()
+  const p = toTile(state, screenToWorld(vec2(e.clientX - r.left, e.clientY - r.top)))
+  return inBounds(state, p) ? p : null
+}
+
+startRun(Number(params.get('seed')) || randomSeed())
+
+// 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 rectangles 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)
+
+await engineInit(
+  () => {
+    setShowSplashScreen(false)
+    setCanvasClearColor(BACKGROUND)
+    // Pressing shows the preview on the tile under the finger; releasing commits to it.
+    // Sliding to another tile before letting go changes the choice.
+    mainCanvas.addEventListener('pointerdown', (e) => (hover = tileUnder(e)))
+    mainCanvas.addEventListener('pointermove', (e) => {
+      if (e.pointerType === 'mouse' || e.buttons) hover = tileUnder(e)
+    })
+    mainCanvas.addEventListener('pointerup', (e) => {
+      const p = tileUnder(e)
+      if (e.pointerType !== 'mouse') hover = null
+      if (p) tapTile(p)
+    })
+    mainCanvas.addEventListener('pointerleave', () => (hover = null))
+  },
+  () => {
+    updateCamera(state, controls.insets(), snapCamera)
+    snapCamera = false
+    flames = flames.filter((f) => time - f.born < FLAME_SECONDS)
+  },
+  () => {},
+  () => drawBoard({ state, danger, selected, hover, flames }),
+  () => {},
+)
diff --git a/src/render/board.ts b/src/render/board.ts
new file mode 100644
index 0000000..95d0db3
--- /dev/null
+++ b/src/render/board.ts
@@ -0,0 +1,113 @@
+import { drawCircle, drawRect, drawText, rgb, time, vec2, type Color, type Vector2 } from 'littlejsengine'
+import { ACTIONS } from '../sim/actions'
+import type { Danger } from '../sim/danger'
+import { blastTiles } from '../sim/explosions'
+import { eq, idx } from '../sim/state'
+import type { ActionId, GameState, Vec } from '../sim/types'
+
+/**
+ * The one place where sim tiles meet LittleJS world space. The sim's y grows downward
+ * (row 0 at the top); LittleJS's y grows upward. A tile's centre is at (x + 0.5, y + 0.5).
+ */
+export function toWorld(s: GameState, p: Vec): Vector2 {
+  return vec2(p.x + 0.5, s.height - 1 - p.y + 0.5)
+}
+
+export function toTile(s: GameState, w: Vector2): Vec {
+  return { x: Math.floor(w.x), y: s.height - 1 - Math.floor(w.y) }
+}
+
+/** Shown for a moment after a bomb goes off. Flame has no lasting effect on the board. */
+export interface Flame {
+  tiles: Vec[]
+  born: number
+}
+
+export interface View {
+  state: GameState
+  danger: Danger
+  selected: ActionId | null
+  /** The tile under the pointer or finger, for the commit preview. */
+  hover: Vec | null
+  flames: Flame[]
+}
+
+export const FLAME_SECONDS = 0.45
+
+const C = {
+  grid: rgb(0.85, 0.82, 0.75),
+  floor: rgb(0.96, 0.94, 0.89),
+  wall: rgb(0.23, 0.21, 0.19),
+  block: rgb(0.64, 0.48, 0.3),
+  blockFace: rgb(0.72, 0.57, 0.37),
+  dangerNow: rgb(0.9, 0.15, 0.1, 0.45),
+  dangerNext: rgb(1, 0.35, 0.2, 0.18),
+  target: rgb(0.18, 0.44, 0.84, 0.4),
+  targetHover: rgb(0.18, 0.44, 0.84, 0.7),
+  preview: rgb(1, 0.55, 0.1, 0.4),
+  flame: rgb(1, 0.6, 0.1),
+  bomb: rgb(0.11, 0.1, 0.09),
+  bombHot: rgb(0.8, 0.1, 0.08),
+  white: rgb(1, 1, 1),
+  player: rgb(0.18, 0.44, 0.84),
+  playerDead: rgb(0.55, 0.55, 0.55),
+}
+
+export const BACKGROUND: Color = C.grid
+
+export function drawBoard(view: View): void {
+  const { state: s, danger, selected, hover } = view
+  // A hair over a tile, so neighbouring walls overlap and no anti-aliased seam shows between them.
+  const wallSize = vec2(1.02)
+
+  for (let y = 0; y < s.height; y++) {
+    for (let x = 0; x < s.width; x++) {
+      const p = { x, y }
+      const pos = toWorld(s, p)
+      const cell = s.cells[idx(s, p)]
+      if (cell === 'wall') drawRect(pos, wallSize, C.wall)
+      else if (cell === 'block') {
+        drawRect(pos, vec2(0.94), C.block)
+        drawRect(pos, vec2(0.62), C.blockFace)
+      } else drawRect(pos, vec2(0.94), C.floor)
+    }
+  }
+
+  for (const i of danger.next) if (!danger.now.has(i)) drawRect(toWorld(s, tileOf(s, i)), vec2(0.94), C.dangerNext)
+  for (const i of danger.now) drawRect(toWorld(s, tileOf(s, i)), vec2(0.94), C.dangerNow)
+
+  const hovered = selected && hover && ACTIONS[selected].validTargets(s).some((t) => eq(t, hover)) ? hover : null
+  if (selected) {
+    for (const t of ACTIONS[selected].validTargets(s)) {
+      drawRect(toWorld(s, t), vec2(0.94), hovered && eq(t, hovered) ? C.targetHover : C.target)
+    }
+  }
+  if (hovered && selected === 'bomb') {
+    for (const t of blastTiles(s, { pos: hovered, fire: s.stats.fire })) drawRect(toWorld(s, t), vec2(0.7), C.preview)
+  }
+
+  for (const f of view.flames) {
+    const fade = 1 - (time - f.born) / FLAME_SECONDS
+    if (fade <= 0) continue
+    for (const t of f.tiles) drawRect(toWorld(s, t), vec2(0.9), C.flame.scale(1, fade))
+  }
+
+  for (const b of s.bombs) {
+    const pos = toWorld(s, b.pos)
+    const hot = b.timer <= 1
+    const size = hot ? 0.74 + 0.06 * Math.sin(time * 10) : 0.74
+    drawCircle(pos, size, hot ? C.bombHot : C.bomb)
+    drawText(String(b.timer), pos, 0.5, C.white)
+  }
+  if (hovered && selected === 'bomb') {
+    drawCircle(toWorld(s, hovered), 0.74, C.bomb.scale(1, 0.45))
+    drawText(String(s.stats.fuse), toWorld(s, hovered), 0.5, C.white)
+  }
+
+  if (hovered && selected === 'move') drawCircle(toWorld(s, hovered), 0.72, C.player.scale(1, 0.4))
+  drawCircle(toWorld(s, s.player), 0.72, s.status === 'playing' ? C.player : C.playerDead, 0.06, C.white)
+}
+
+function tileOf(s: GameState, i: number): Vec {
+  return { x: i % s.width, y: Math.floor(i / s.width) }
+}
diff --git a/src/render/camera.ts b/src/render/camera.ts
new file mode 100644
index 0000000..ef9803c
--- /dev/null
+++ b/src/render/camera.ts
@@ -0,0 +1,35 @@
+import { mainCanvasSize, setCameraPos, setCameraScale, vec2 } from 'littlejsengine'
+import type { GameState } from '../sim/types'
+
+/** CSS pixels covered at the top (HUD) and bottom (action bar) of the canvas. */
+export interface Insets {
+  top: number
+  bottom: number
+}
+
+let x: number | undefined
+
+/**
+ * All rows always fit between the HUD and the action bar. Horizontally the camera follows the
+ * player, eased, and stops at the level's edges, as in the original (PLAN.md §11).
+ * `snap` jumps straight there, for the first frame of a level.
+ */
+export function updateCamera(s: GameState, insets: Insets, snap = false): void {
+  const width = mainCanvasSize.x
+  const height = mainCanvasSize.y
+  const available = Math.max(1, height - insets.top - insets.bottom)
+  const scale = available / s.height
+  setCameraScale(scale)
+
+  const halfView = width / 2 / scale
+  const target = s.width <= 2 * halfView ? s.width / 2 : clamp(s.player.x + 0.5, halfView, s.width - halfView)
+  x = x === undefined || snap ? target : x + (target - x) * 0.2
+
+  // The camera centres the canvas; move it so the board centres in the gap between the bars.
+  const offset = insets.top + available / 2 - height / 2
+  setCameraPos(vec2(x, s.height / 2 + offset / scale))
+}
+
+function clamp(v: number, lo: number, hi: number): number {
+  return Math.min(hi, Math.max(lo, v))
+}
diff --git a/src/sim/actions.ts b/src/sim/actions.ts
new file mode 100644
index 0000000..32d96c4
--- /dev/null
+++ b/src/sim/actions.ts
@@ -0,0 +1,66 @@
+import { add, eq, isFree } from './state'
+import { DIRS, DIR_LIST, type ActionId, type GameEvent, type GameState, type Vec } from './types'
+
+/**
+ * The board actions (PLAN.md §3). Adding one = an entry here, its tests, and a button.
+ * `apply` mutates the state it is given; `step()` hands it a clone.
+ */
+export interface ActionDef {
+  id: ActionId
+  label: string
+  /** False for actions like Wait that happen as soon as they are picked. */
+  needsTarget: boolean
+  validTargets(s: GameState): Vec[]
+  apply(s: GameState, target: Vec | undefined, events: GameEvent[]): void
+}
+
+function neighbours(p: Vec): Vec[] {
+  return DIR_LIST.map((d) => add(p, DIRS[d]))
+}
+
+export const ACTIONS: Record<ActionId, ActionDef> = {
+  move: {
+    id: 'move',
+    label: 'Move',
+    needsTarget: true,
+    validTargets: (s) => neighbours(s.player).filter((p) => isFree(s, p)),
+    apply(s, target, events) {
+      const from = s.player
+      s.player = { ...target! }
+      events.push({ type: 'moved', from, to: s.player })
+    },
+  },
+  bomb: {
+    id: 'bomb',
+    label: 'Bomb',
+    needsTarget: true,
+    validTargets: (s) => (s.bombs.length >= s.stats.capacity ? [] : neighbours(s.player).filter((p) => isFree(s, p))),
+    apply(s, target, events) {
+      const pos = { ...target! }
+      s.bombs.push({ pos, timer: s.stats.fuse, fire: s.stats.fire, placedTurn: s.turn })
+      events.push({ type: 'bombPlaced', pos, timer: s.stats.fuse })
+    },
+  },
+  wait: {
+    id: 'wait',
+    label: 'Wait',
+    needsTarget: false,
+    validTargets: () => [],
+    apply(_s, _target, events) {
+      events.push({ type: 'waited' })
+    },
+  },
+}
+
+export const ACTION_ORDER: readonly ActionId[] = ['move', 'bomb', 'wait']
+
+export function isValidTarget(s: GameState, id: ActionId, target: Vec): boolean {
+  return ACTIONS[id].validTargets(s).some((p) => eq(p, target))
+}
+
+/** Whether the action can be picked right now. */
+export function isAvailable(s: GameState, id: ActionId): boolean {
+  if (s.status !== 'playing') return false
+  const def = ACTIONS[id]
+  return !def.needsTarget || def.validTargets(s).length > 0
+}
diff --git a/src/sim/danger.ts b/src/sim/danger.ts
new file mode 100644
index 0000000..6f03643
--- /dev/null
+++ b/src/sim/danger.ts
@@ -0,0 +1,24 @@
+import { detonate, tickBombs } from './explosions'
+import { cloneState } from './state'
+import type { GameEvent, GameState } from './types'
+
+/** Tile indices that will burn at the end of this turn, and at the end of the next one. */
+export interface Danger {
+  now: Set<number>
+  next: Set<number>
+}
+
+/**
+ * The danger overlay (PLAN.md §4), assuming the player waits. It runs the same countdown and
+ * chain code as `step()`, so it can't disagree with what actually happens.
+ */
+export function predictDanger(state: GameState): Danger {
+  const s = cloneState(state)
+  const ignored: GameEvent[] = []
+  tickBombs(s, ignored)
+  const now = detonate(s, ignored)
+  s.turn++
+  tickBombs(s, ignored)
+  const next = detonate(s, ignored)
+  return { now, next }
+}
diff --git a/src/sim/explosions.ts b/src/sim/explosions.ts
new file mode 100644
index 0000000..641c5b7
--- /dev/null
+++ b/src/sim/explosions.ts
@@ -0,0 +1,58 @@
+import { add, bombAt, cellAt, idx } from './state'
+import { DIRS, DIR_LIST, type Bomb, type GameEvent, type GameState, type Vec } from './types'
+
+/**
+ * The tiles a bomb's flame reaches: its own tile, then up to `fire` tiles in each direction.
+ * Flame stops before a hard wall, and stops *on* a soft block or another bomb (burning it).
+ */
+export function blastTiles(s: GameState, bomb: Pick<Bomb, 'pos' | 'fire'>): Vec[] {
+  const tiles = [bomb.pos]
+  for (const d of DIR_LIST) {
+    let p = bomb.pos
+    for (let i = 0; i < bomb.fire; i++) {
+      p = add(p, DIRS[d])
+      const cell = cellAt(s, p)
+      if (cell === 'wall') break
+      tiles.push(p)
+      if (cell === 'block' || bombAt(s, p)) break
+    }
+  }
+  return tiles
+}
+
+/**
+ * Explode every bomb whose timer is at 0 or below, and every bomb their flame reaches, in a
+ * chain, all in this one call. Soft blocks are destroyed only once the whole chain is done, so
+ * a block stops every flame that reaches it this turn. Returns the burned tiles' indices.
+ */
+export function detonate(s: GameState, events: GameEvent[]): Set<number> {
+  const burned = new Set<number>()
+  const hitBlocks = new Set<number>()
+  let bomb: Bomb | undefined
+  while ((bomb = s.bombs.find((b) => b.timer <= 0))) {
+    s.bombs.splice(s.bombs.indexOf(bomb), 1)
+    const tiles = blastTiles(s, bomb)
+    for (const t of tiles) {
+      const i = idx(s, t)
+      burned.add(i)
+      if (s.cells[i] === 'block') hitBlocks.add(i)
+      const other = bombAt(s, t)
+      if (other) other.timer = 0
+    }
+    events.push({ type: 'exploded', pos: bomb.pos, tiles })
+  }
+  for (const i of hitBlocks) {
+    s.cells[i] = 'floor'
+    events.push({ type: 'blockDestroyed', pos: { x: i % s.width, y: Math.floor(i / s.width) } })
+  }
+  return burned
+}
+
+/** Phase 3's countdown: every bomb not placed this turn loses 1 from its timer. */
+export function tickBombs(s: GameState, events: GameEvent[]): void {
+  for (const b of s.bombs) {
+    if (b.placedTurn === s.turn) continue
+    b.timer--
+    events.push({ type: 'bombTicked', pos: b.pos, timer: b.timer })
+  }
+}
diff --git a/src/sim/levelgen.ts b/src/sim/levelgen.ts
new file mode 100644
index 0000000..324ab47
--- /dev/null
+++ b/src/sim/levelgen.ts
@@ -0,0 +1,44 @@
+import { stream } from './rng'
+import type { Cell, Vec } from './types'
+
+/** The NES layout: 31 × 13 with a wall all round and a pillar at every even (x, y). */
+export const LEVEL_WIDTH = 31
+export const LEVEL_HEIGHT = 13
+export const START: Vec = { x: 1, y: 1 }
+/** Share of the open tiles that get a soft block. */
+export const BLOCK_DENSITY = 0.35
+
+export interface Layout {
+  width: number
+  height: number
+  cells: Cell[]
+  start: Vec
+}
+
+export function generateLevel(seed: number, level: number): Layout {
+  const width = LEVEL_WIDTH
+  const height = LEVEL_HEIGHT
+  const rng = stream(seed, 'levelgen', level)
+  const cells: Cell[] = []
+  for (let y = 0; y < height; y++) {
+    for (let x = 0; x < width; x++) {
+      const border = x === 0 || y === 0 || x === width - 1 || y === height - 1
+      const pillar = x % 2 === 0 && y % 2 === 0
+      cells.push(border || pillar ? 'wall' : 'floor')
+    }
+  }
+  // The start and the two tiles beside it stay clear, so the first bomb has somewhere to go
+  // and the player somewhere to hide from it.
+  const clear = new Set([key(START), key({ x: START.x + 1, y: START.y }), key({ x: START.x, y: START.y + 1 })])
+  for (let y = 0; y < height; y++) {
+    for (let x = 0; x < width; x++) {
+      const i = y * width + x
+      if (cells[i] === 'floor' && !clear.has(key({ x, y })) && rng.next() < BLOCK_DENSITY) cells[i] = 'block'
+    }
+  }
+  return { width, height, cells, start: { ...START } }
+}
+
+function key(p: Vec): string {
+  return `${p.x},${p.y}`
+}
diff --git a/src/sim/record.ts b/src/sim/record.ts
new file mode 100644
index 0000000..3824fa5
--- /dev/null
+++ b/src/sim/record.ts
@@ -0,0 +1,27 @@
+import { newRun } from './state'
+import { step } from './turn'
+import type { Action, GameState } from './types'
+
+/**
+ * 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 = 1
+
+/** Everything needed to reproduce a run exactly: mid-run saves, replays, certificates. */
+export interface RunRecord {
+  simVersion: number
+  seed: number
+  startLevel: number
+  actions: Action[]
+}
+
+export function newRecord(seed: number, startLevel = 1): RunRecord {
+  return { simVersion: SIM_VERSION, seed, startLevel, actions: [] }
+}
+
+export function replay(record: RunRecord): GameState {
+  let s = newRun(record.seed, record.startLevel)
+  for (const a of record.actions) s = step(s, a).state
+  return s
+}
diff --git a/src/sim/rng.ts b/src/sim/rng.ts
new file mode 100644
index 0000000..c0e4878
--- /dev/null
+++ b/src/sim/rng.ts
@@ -0,0 +1,41 @@
+/**
+ * Seeded randomness. Every random choice in the game comes from a stream derived from the run
+ * seed by purpose and level (PLAN.md §12), so an extra bomb in one place never changes what
+ * happens somewhere else, and the same seed always gives the same levels.
+ */
+
+/** FNV-1a over the parts joined by `:`. A 32-bit unsigned integer. */
+export function hashSeed(...parts: (string | number)[]): number {
+  let h = 0x811c9dc5
+  for (const ch of parts.join(':')) {
+    h ^= ch.codePointAt(0)!
+    h = Math.imul(h, 0x01000193)
+  }
+  return h >>> 0
+}
+
+/**
+ * mulberry32. Its whole state is one 32-bit number, kept in `state`, so a stream can be stored
+ * in the game state and resumed exactly.
+ */
+export class Rng {
+  constructor(public state: number) {}
+
+  /** A float in [0, 1). */
+  next(): number {
+    this.state = (this.state + 0x6d2b79f5) >>> 0
+    let t = this.state
+    t = Math.imul(t ^ (t >>> 15), t | 1)
+    t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
+    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
+  }
+
+  /** An integer in [0, n). */
+  int(n: number): number {
+    return Math.floor(this.next() * n)
+  }
+}
+
+export function stream(seed: number, purpose: string, level: number): Rng {
+  return new Rng(hashSeed(seed, purpose, level))
+}
diff --git a/src/sim/state.ts b/src/sim/state.ts
new file mode 100644
index 0000000..325b6e1
--- /dev/null
+++ b/src/sim/state.ts
@@ -0,0 +1,49 @@
+import { generateLevel } from './levelgen'
+import type { Bomb, Cell, GameState, Stats, Vec } from './types'
+
+export const START_LIVES = 3
+export const START_STATS: Stats = { capacity: 1, fire: 1, fuse: 3 }
+
+export function newRun(seed: number, level = 1): GameState {
+  return createLevel(seed, level, START_LIVES, { ...START_STATS })
+}
+
+/** A fresh attempt at a level: the layout comes from the seed, the player keeps lives and stats. */
+export function createLevel(seed: number, level: number, lives: number, stats: Stats): GameState {
+  const { width, height, cells, start } = generateLevel(seed, level)
+  return { seed, level, turn: 0, width, height, cells, player: start, lives, stats, bombs: [], status: 'playing' }
+}
+
+export function cloneState(s: GameState): GameState {
+  return structuredClone(s)
+}
+
+export function add(a: Vec, b: Vec): Vec {
+  return { x: a.x + b.x, y: a.y + b.y }
+}
+
+export function eq(a: Vec, b: Vec): boolean {
+  return a.x === b.x && a.y === b.y
+}
+
+export function idx(s: GameState, p: Vec): number {
+  return p.y * s.width + p.x
+}
+
+export function inBounds(s: GameState, p: Vec): boolean {
+  return p.x >= 0 && p.y >= 0 && p.x < s.width && p.y < s.height
+}
+
+/** Outside the grid counts as wall. */
+export function cellAt(s: GameState, p: Vec): Cell {
+  return inBounds(s, p) ? s.cells[idx(s, p)]! : 'wall'
+}
+
+export function bombAt(s: GameState, p: Vec): Bomb | undefined {
+  return s.bombs.find((b) => eq(b.pos, p))
+}
+
+/** Nothing in the way: floor, and no bomb on it. */
+export function isFree(s: GameState, p: Vec): boolean {
+  return cellAt(s, p) === 'floor' && !bombAt(s, p)
+}
diff --git a/src/sim/turn.ts b/src/sim/turn.ts
new file mode 100644
index 0000000..5ae92a5
--- /dev/null
+++ b/src/sim/turn.ts
@@ -0,0 +1,50 @@
+import { ACTIONS, isValidTarget } from './actions'
+import { detonate, tickBombs } from './explosions'
+import { cloneState, createLevel, idx } from './state'
+import type { Action, GameEvent, GameState } from './types'
+
+export interface StepResult {
+  state: GameState
+  events: GameEvent[]
+}
+
+/**
+ * One turn (PLAN.md §1). Never mutates `prev`. Throws on an action that isn't allowed right
+ * now; the UI only offers allowed ones, so a throw is a bug.
+ */
+export function step(prev: GameState, action: Action): StepResult {
+  const s = cloneState(prev)
+  const events: GameEvent[] = []
+
+  if (action.id === 'continue') {
+    if (s.status !== 'dead') throw new Error(`continue while ${s.status}`)
+    events.push({ type: 'levelRestarted' })
+    return { state: createLevel(s.seed, s.level, s.lives, s.stats), events }
+  }
+  if (s.status !== 'playing') throw new Error(`${action.id} while ${s.status}`)
+
+  // Phase 1: the player acts.
+  const def = ACTIONS[action.id]
+  if (def.needsTarget && (!action.target || !isValidTarget(s, action.id, action.target))) {
+    throw new Error(`invalid target for ${action.id}: ${JSON.stringify(action.target)}`)
+  }
+  def.apply(s, action.target, events)
+
+  // Phase 2: enemies carry out their announced intents (M5).
+
+  // Phase 3: bombs tick; those at 0 explode, in chains.
+  tickBombs(s, events)
+  const burned = detonate(s, events)
+
+  // Phase 4: damage.
+  if (burned.has(idx(s, s.player))) {
+    s.lives--
+    s.status = s.lives > 0 ? 'dead' : 'gameOver'
+    events.push({ type: 'playerDied', pos: s.player, livesLeft: s.lives })
+  }
+
+  // Phase 5: enemies announce their next intents (M5).
+
+  s.turn++
+  return { state: s, events }
+}
diff --git a/src/sim/types.ts b/src/sim/types.ts
new file mode 100644
index 0000000..b6d0c66
--- /dev/null
+++ b/src/sim/types.ts
@@ -0,0 +1,77 @@
+/**
+ * The shapes of the game's state, actions and events. Everything in `sim/` is plain data and
+ * pure functions: no DOM, no LittleJS, no `Math.random()`.
+ */
+
+/** A tile position. x grows to the right, y grows downward; (0, 0) is the top-left corner. */
+export interface Vec {
+  x: number
+  y: number
+}
+
+export type Dir = 'up' | 'down' | 'left' | 'right'
+
+export const DIRS: Record<Dir, Vec> = {
+  up: { x: 0, y: -1 },
+  down: { x: 0, y: 1 },
+  left: { x: -1, y: 0 },
+  right: { x: 1, y: 0 },
+}
+
+export const DIR_LIST: readonly Dir[] = ['up', 'down', 'left', 'right']
+
+/** `wall` is a hard wall or pillar (indestructible); `block` is a soft block (flame destroys it). */
+export type Cell = 'floor' | 'wall' | 'block'
+
+export interface Bomb {
+  pos: Vec
+  /** How many more player actions until it explodes (PLAN.md §1). */
+  timer: number
+  /** Blast radius in tiles, in each of the 4 directions. */
+  fire: number
+  /** The turn it was placed on. A bomb is not ticked on the turn it is placed. */
+  placedTurn: number
+}
+
+export interface Stats {
+  /** Most of the player's own bombs on the board at once. */
+  capacity: number
+  fire: number
+  /** The timer a newly placed bomb starts at. */
+  fuse: number
+}
+
+/** `dead`: lost a life and waiting for `continue`. `gameOver`: no lives left. */
+export type Status = 'playing' | 'dead' | 'gameOver'
+
+export interface GameState {
+  seed: number
+  level: number
+  /** Turns taken on this attempt at this level, starting at 0. */
+  turn: number
+  width: number
+  height: number
+  /** Row-major: the cell at (x, y) is `cells[y * width + x]`. */
+  cells: Cell[]
+  player: Vec
+  lives: number
+  stats: Stats
+  bombs: Bomb[]
+  status: Status
+}
+
+/** The actions the player can pick on the board. */
+export type ActionId = 'move' | 'bomb' | 'wait'
+
+export type Action = { id: ActionId; target?: Vec } | { id: 'continue' }
+
+/** What `step()` reports happened, in order, for the renderer to play back. */
+export type GameEvent =
+  | { type: 'moved'; from: Vec; to: Vec }
+  | { type: 'waited' }
+  | { type: 'bombPlaced'; pos: Vec; timer: number }
+  | { type: 'bombTicked'; pos: Vec; timer: number }
+  | { type: 'exploded'; pos: Vec; tiles: Vec[] }
+  | { type: 'blockDestroyed'; pos: Vec }
+  | { type: 'playerDied'; pos: Vec; livesLeft: number }
+  | { type: 'levelRestarted' }
diff --git a/src/style.css b/src/style.css
new file mode 100644
index 0000000..f04fa06
--- /dev/null
+++ b/src/style.css
@@ -0,0 +1,118 @@
+:root {
+  --paper: #f4efe4;
+  --ink: #1d1b18;
+  --muted: #6b665c;
+  --accent: #2f6fd6;
+  --bar-bg: #fffaf0;
+  --line: #d9d1c0;
+}
+
+html,
+body {
+  margin: 0;
+  height: 100%;
+  overflow: hidden;
+  background: var(--paper);
+  color: var(--ink);
+  font: 16px/1.3 system-ui, -apple-system, sans-serif;
+  /* A game surface: no pull-to-refresh, no double-tap zoom, no text selection. */
+  touch-action: none;
+  user-select: none;
+  -webkit-user-select: none;
+  -webkit-tap-highlight-color: transparent;
+  overscroll-behavior: none;
+}
+
+#hud {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  z-index: 2;
+  display: flex;
+  justify-content: space-between;
+  gap: 8px;
+  padding: calc(env(safe-area-inset-top) + 8px) 16px 8px;
+  background: var(--bar-bg);
+  border-bottom: 1px solid var(--line);
+  font-variant-numeric: tabular-nums;
+}
+
+#actions {
+  position: fixed;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  z-index: 2;
+  display: flex;
+  gap: 8px;
+  overflow-x: auto;
+  padding: 8px 16px calc(env(safe-area-inset-bottom) + 8px);
+  background: var(--bar-bg);
+  border-top: 1px solid var(--line);
+}
+
+#actions button {
+  flex: 1 0 auto;
+  min-width: 72px;
+  min-height: 56px;
+  border: 2px solid var(--ink);
+  border-radius: 12px;
+  background: #fff;
+  color: var(--ink);
+  font: inherit;
+  font-weight: 600;
+}
+
+#actions button[aria-pressed='true'] {
+  background: var(--accent);
+  border-color: var(--accent);
+  color: #fff;
+}
+
+#actions button:disabled {
+  opacity: 0.35;
+}
+
+#actions kbd {
+  display: block;
+  font: 11px ui-monospace, monospace;
+  color: var(--muted);
+}
+
+#actions button[aria-pressed='true'] kbd {
+  color: #dce8ff;
+}
+
+#banner {
+  position: fixed;
+  inset: 0;
+  z-index: 3;
+  display: grid;
+  place-content: center;
+  gap: 16px;
+  padding: 16px;
+  text-align: center;
+  background: rgb(244 239 228 / 0.85);
+}
+
+#banner[hidden] {
+  display: none;
+}
+
+#banner p {
+  margin: 0;
+  font-size: 24px;
+  font-weight: 700;
+}
+
+#banner button {
+  min-height: 56px;
+  padding: 0 32px;
+  border: 0;
+  border-radius: 12px;
+  background: var(--accent);
+  color: #fff;
+  font: inherit;
+  font-weight: 700;
+}
diff --git a/src/ui/controls.ts b/src/ui/controls.ts
new file mode 100644
index 0000000..2cbd69e
--- /dev/null
+++ b/src/ui/controls.ts
@@ -0,0 +1,72 @@
+import { ACTIONS, ACTION_ORDER, isAvailable } from '../sim/actions'
+import type { ActionId, GameState } from '../sim/types'
+
+/** The HTML around the board: HUD, action bar, and the banner shown on death. */
+export interface Handlers {
+  /** An action button was pressed. */
+  pick(id: ActionId): void
+  /** The banner's button: continue after losing a life, or a new run after game over. */
+  banner(): void
+}
+
+const KEYS: Record<ActionId, string> = { move: 'WASD', bomb: 'B', wait: 'Space' }
+
+export function createControls(handlers: Handlers) {
+  const bar = document.getElementById('actions')!
+  const buttons = new Map<ActionId, HTMLButtonElement>()
+  for (const id of ACTION_ORDER) {
+    const b = document.createElement('button')
+    b.type = 'button'
+    b.dataset.action = id
+    b.innerHTML = `${ACTIONS[id].label}<kbd>${KEYS[id]}</kbd>`
+    b.addEventListener('click', () => handlers.pick(id))
+    bar.append(b)
+    buttons.set(id, b)
+  }
+
+  const banner = document.getElementById('banner')!
+  const bannerText = document.getElementById('banner-text')!
+  const bannerButton = document.getElementById('banner-button') as HTMLButtonElement
+  bannerButton.addEventListener('click', () => handlers.banner())
+
+  const hud = {
+    lives: document.getElementById('hud-lives')!,
+    level: document.getElementById('hud-level')!,
+    turn: document.getElementById('hud-turn')!,
+    stats: document.getElementById('hud-stats')!,
+  }
+
+  return {
+    update(s: GameState, selected: ActionId | null): void {
+      for (const [id, b] of buttons) {
+        b.disabled = !isAvailable(s, id)
+        b.setAttribute('aria-pressed', String(selected === id))
+      }
+      hud.lives.textContent = `♥ ${s.lives}`
+      hud.level.textContent = `Level ${s.level}`
+      hud.turn.textContent = `Turn ${s.turn}`
+      hud.stats.textContent = `💣${s.stats.capacity} 🔥${s.stats.fire}`
+
+      banner.hidden = s.status === 'playing'
+      if (s.status === 'dead') {
+        bannerText.textContent = `Boom. ${s.lives} ${s.lives === 1 ? 'life' : 'lives'} left.`
+        bannerButton.textContent = 'Try again'
+      } else if (s.status === 'gameOver') {
+        bannerText.textContent = `Game over on level ${s.level}. Seed ${s.seed}.`
+        bannerButton.textContent = 'New run'
+      }
+    },
+    /** True if the banner is showing; Enter presses its button. */
+    pressBanner(): boolean {
+      if (banner.hidden) return false
+      bannerButton.click()
+      return true
+    },
+    insets() {
+      return {
+        top: document.getElementById('hud')!.getBoundingClientRect().height,
+        bottom: bar.getBoundingClientRect().height,
+      }
+    },
+  }
+}
diff --git a/test/danger.test.ts b/test/danger.test.ts
new file mode 100644
index 0000000..1d0c4e3
--- /dev/null
+++ b/test/danger.test.ts
@@ -0,0 +1,55 @@
+import { describe, expect, it } from 'vitest'
+import { ACTIONS, ACTION_ORDER, isAvailable } from '../src/sim/actions'
+import { predictDanger } from '../src/sim/danger'
+import { newRecord, replay } from '../src/sim/record'
+import { idx, newRun } from '../src/sim/state'
+import { step } from '../src/sim/turn'
+import type { Action, GameState } from '../src/sim/types'
+import { Rng } from '../src/sim/rng'
+
+/** A random but legal action; `continue` when dead. */
+function randomAction(s: GameState, rng: Rng): Action {
+  if (s.status === 'dead') return { id: 'continue' }
+  const ids = ACTION_ORDER.filter((id) => isAvailable(s, id))
+  const id = ids[rng.int(ids.length)]!
+  const targets = ACTIONS[id].validTargets(s)
+  return ACTIONS[id].needsTarget ? { id, target: targets[rng.int(targets.length)]! } : { id }
+}
+
+function burnedBy(s: GameState): Set<number> {
+  const tiles = new Set<number>()
+  for (const e of step(s, { id: 'wait' }).events) if (e.type === 'exploded') e.tiles.forEach((t) => tiles.add(idx(s, t)))
+  return tiles
+}
+
+describe('danger overlay', () => {
+  it('"now" is exactly what burns if the player waits, and "next" what burns the turn after', () => {
+    const rng = new Rng(7)
+    for (let seed = 1; seed <= 20; seed++) {
+      let s = newRun(seed)
+      for (let t = 0; t < 150 && s.status !== 'gameOver'; t++) {
+        if (s.status === 'playing') {
+          const danger = predictDanger(s)
+          expect(danger.now).toEqual(burnedBy(s))
+          const waited = step(s, { id: 'wait' }).state
+          if (waited.status === 'playing') expect(danger.next).toEqual(burnedBy(waited))
+        }
+        s = step(s, randomAction(s, rng)).state
+      }
+    }
+  })
+})
+
+describe('run record', () => {
+  it('replays to the identical state', () => {
+    const rng = new Rng(99)
+    const record = newRecord(1234)
+    let s = newRun(record.seed)
+    for (let t = 0; t < 300 && s.status !== 'gameOver'; t++) {
+      const a = randomAction(s, rng)
+      record.actions.push(a)
+      s = step(s, a).state
+    }
+    expect(replay(structuredClone(record))).toEqual(s)
+  })
+})
diff --git a/test/explosions.test.ts b/test/explosions.test.ts
new file mode 100644
index 0000000..df4f663
--- /dev/null
+++ b/test/explosions.test.ts
@@ -0,0 +1,52 @@
+import { describe, expect, it } from 'vitest'
+import { blastTiles, detonate } from '../src/sim/explosions'
+import { step } from '../src/sim/turn'
+import type { GameEvent } from '../src/sim/types'
+import { board, v } from './helpers'
+
+describe('blast', () => {
+  it('is a cross of radius fire, stopped before walls', () => {
+    const s = board(`
+      #######
+      #P....#
+      #.#.#.#
+      #.....#
+      #######`)
+    expect(blastTiles(s, { pos: v(3, 1), fire: 2 })).toEqual([v(3, 1), v(3, 2), v(3, 3), v(2, 1), v(1, 1), v(4, 1), v(5, 1)])
+  })
+
+  it('stops on the first soft block and destroys only that one', () => {
+    let s = board(`
+      #######
+      #P1++.#
+      #######`, { fire: 3 })
+    s = step(s, { id: 'wait' }).state
+    expect(s.cells.slice(7, 14)).toEqual(['wall', 'floor', 'floor', 'floor', 'block', 'floor', 'wall'])
+  })
+
+  it('chains: a bomb in the flame explodes now, whatever its timer', () => {
+    const s = board(`
+      ########
+      #P1.3.+#
+      ########`, { fire: 2 })
+    const events: GameEvent[] = []
+    s.bombs[0]!.timer = 0
+    const burned = detonate(s, events)
+    expect(s.bombs).toEqual([])
+    expect(events.filter((e) => e.type === 'exploded').map((e) => e.type === 'exploded' && e.pos)).toEqual([v(2, 1), v(4, 1)])
+    expect(burned.has(1 * 8 + 6)).toBe(true) // the second bomb's flame reached the block
+    expect(s.cells[1 * 8 + 6]).toBe('floor')
+  })
+
+  it('a block hit by two flames in one chain still stops both', () => {
+    const s = board(`
+      #######
+      #P1+1.#
+      #######`, { fire: 3 })
+    s.bombs.forEach((b) => (b.timer = 0))
+    const burned = detonate(s, [])
+    expect(burned.has(1 * 7 + 5)).toBe(true) // right bomb burns rightwards
+    expect(burned.has(1 * 7 + 1)).toBe(true) // left bomb burns the player's tile
+    expect(s.cells[1 * 7 + 3]).toBe('floor')
+  })
+})
diff --git a/test/helpers.ts b/test/helpers.ts
new file mode 100644
index 0000000..ece641f
--- /dev/null
+++ b/test/helpers.ts
@@ -0,0 +1,31 @@
+import { START_LIVES, START_STATS } from '../src/sim/state'
+import type { Cell, GameState, Stats, Vec } from '../src/sim/types'
+
+/**
+ * A state from a picture: `#` wall, `+` soft block, `.` floor, `P` player (on floor),
+ * `1`–`9` a bomb with that timer (on floor, placed on an earlier turn).
+ */
+export function board(picture: string, stats: Partial<Stats> = {}): GameState {
+  const rows = picture.trim().split('\n').map((r) => r.trim())
+  const height = rows.length
+  const width = rows[0]!.length
+  const cells: Cell[] = []
+  let player: Vec | undefined
+  const s: GameState = {
+    seed: 1, level: 1, turn: 5, width, height, cells, player: { x: 0, y: 0 },
+    lives: START_LIVES, stats: { ...START_STATS, ...stats }, bombs: [], status: 'playing',
+  }
+  rows.forEach((row, y) => {
+    if (row.length !== width) throw new Error(`row ${y} is ${row.length} wide, not ${width}`)
+    ;[...row].forEach((ch, x) => {
+      cells.push(ch === '#' ? 'wall' : ch === '+' ? 'block' : 'floor')
+      if (ch === 'P') player = { x, y }
+      if (/[1-9]/.test(ch)) s.bombs.push({ pos: { x, y }, timer: Number(ch), fire: s.stats.fire, placedTurn: 0 })
+    })
+  })
+  if (!player) throw new Error('no P in the picture')
+  s.player = player
+  return s
+}
+
+export const v = (x: number, y: number): Vec => ({ x, y })
diff --git a/test/levelgen.test.ts b/test/levelgen.test.ts
new file mode 100644
index 0000000..9265806
--- /dev/null
+++ b/test/levelgen.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, it } from 'vitest'
+import { generateLevel, LEVEL_HEIGHT, LEVEL_WIDTH, START } from '../src/sim/levelgen'
+import type { Vec } from '../src/sim/types'
+
+describe('levelgen', () => {
+  it('is the same for the same seed and level, and differs between levels', () => {
+    expect(generateLevel(42, 3)).toEqual(generateLevel(42, 3))
+    expect(generateLevel(42, 3).cells).not.toEqual(generateLevel(42, 4).cells)
+  })
+
+  for (let seed = 1; seed <= 200; seed++) {
+    it(`seed ${seed}: walls, pillars, a clear start, everything reachable`, () => {
+      const { width, height, cells, start } = generateLevel(seed, 1 + (seed % 50))
+      expect([width, height]).toEqual([LEVEL_WIDTH, LEVEL_HEIGHT])
+      const at = (p: Vec) => cells[p.y * width + p.x]
+      for (let x = 0; x < width; x++) expect([at({ x, y: 0 }), at({ x, y: height - 1 })]).toEqual(['wall', 'wall'])
+      for (let y = 0; y < height; y++) expect([at({ x: 0, y }), at({ x: width - 1, y })]).toEqual(['wall', 'wall'])
+      expect(start).toEqual(START)
+      for (const p of [start, { x: 2, y: 1 }, { x: 1, y: 2 }]) expect(at(p)).toBe('floor')
+
+      // With soft blocks gone, every non-wall tile is reachable from the start.
+      const seen = new Set([`${start.x},${start.y}`])
+      const queue = [start]
+      while (queue.length) {
+        const p = queue.pop()!
+        for (const q of [{ x: p.x + 1, y: p.y }, { x: p.x - 1, y: p.y }, { x: p.x, y: p.y + 1 }, { x: p.x, y: p.y - 1 }]) {
+          if (at(q) !== 'wall' && !seen.has(`${q.x},${q.y}`)) {
+            seen.add(`${q.x},${q.y}`)
+            queue.push(q)
+          }
+        }
+      }
+      expect(seen.size).toBe(cells.filter((c) => c !== 'wall').length)
+    })
+  }
+})
diff --git a/test/turn.test.ts b/test/turn.test.ts
new file mode 100644
index 0000000..bc2aad5
--- /dev/null
+++ b/test/turn.test.ts
@@ -0,0 +1,110 @@
+import { describe, expect, it } from 'vitest'
+import { ACTIONS, isAvailable } from '../src/sim/actions'
+import { step } from '../src/sim/turn'
+import { board, v } from './helpers'
+
+describe('move', () => {
+  it('goes to a free neighbour only', () => {
+    const s = board(`
+      #####
+      #P.+#
+      #.#.#
+      #####`)
+    expect(ACTIONS.move.validTargets(s)).toEqual([v(1, 2), v(2, 1)])
+    expect(() => step(s, { id: 'move', target: v(1, 0) })).toThrow()
+    expect(step(s, { id: 'move', target: v(2, 1) }).state.player).toEqual(v(2, 1))
+  })
+
+  it('is blocked by a bomb', () => {
+    const s = board(`
+      #####
+      #P3.#
+      #####`)
+    expect(ACTIONS.move.validTargets(s)).toEqual([])
+    expect(isAvailable(s, 'move')).toBe(false)
+  })
+
+  it('does not change the state it was given', () => {
+    const s = board(`
+      ####
+      #P.#
+      ####`)
+    const before = structuredClone(s)
+    step(s, { id: 'move', target: v(2, 1) })
+    expect(s).toEqual(before)
+  })
+})
+
+describe('bomb timers', () => {
+  it('a placed bomb starts at the fuse and is not ticked on the turn it is placed', () => {
+    const s = board(`
+      #####
+      #P..#
+      #####`)
+    const { state, events } = step(s, { id: 'bomb', target: v(2, 1) })
+    expect(state.bombs).toEqual([{ pos: v(2, 1), timer: 3, fire: 1, placedTurn: 5 }])
+    expect(events.map((e) => e.type)).toEqual(['bombPlaced'])
+  })
+
+  it('the number is how many more actions until it explodes', () => {
+    let s = board(`
+      #######
+      #P....#
+      #######`, { fire: 1 })
+    s = step(s, { id: 'bomb', target: v(2, 1) }).state // shows 3
+    s = step(s, { id: 'wait' }).state
+    expect(s.bombs[0]!.timer).toBe(2)
+    s = step(s, { id: 'wait' }).state
+    expect(s.bombs[0]!.timer).toBe(1)
+    const last = step(s, { id: 'wait' })
+    expect(last.state.bombs).toEqual([])
+    expect(last.events.some((e) => e.type === 'exploded')).toBe(true)
+  })
+
+  it('respects bomb capacity', () => {
+    const s = board(`
+      #####
+      #P.3#
+      #####`)
+    expect(ACTIONS.bomb.validTargets(s)).toEqual([])
+    expect(ACTIONS.bomb.validTargets({ ...s, stats: { ...s.stats, capacity: 2 } })).toEqual([v(2, 1)])
+  })
+})
+
+describe('death', () => {
+  it('flame on the player costs a life and waits for continue', () => {
+    const s = board(`
+      #####
+      #P1.#
+      #####`)
+    const { state, events } = step(s, { id: 'wait' })
+    expect(state.status).toBe('dead')
+    expect(state.lives).toBe(2)
+    expect(events.at(-1)).toEqual({ type: 'playerDied', pos: v(1, 1), livesLeft: 2 })
+    expect(() => step(state, { id: 'wait' })).toThrow()
+  })
+
+  it('continue restarts the level from its seed, keeping lives and stats', () => {
+    const s = board(`
+      #####
+      #P1.#
+      #####`, { fire: 2 })
+    const dead = step(s, { id: 'wait' }).state
+    const again = step(dead, { id: 'continue' }).state
+    expect(again.status).toBe('playing')
+    expect(again.lives).toBe(2)
+    expect(again.stats.fire).toBe(2)
+    expect(again.turn).toBe(0)
+    expect(again.width).toBe(31)
+  })
+
+  it('the last life ends the run', () => {
+    const s = { ...board(`
+      #####
+      #P1.#
+      #####`), lives: 1 }
+    const { state } = step(s, { id: 'wait' })
+    expect(state.status).toBe('gameOver')
+    expect(() => step(state, { id: 'continue' })).toThrow()
+  })
+})
diff --git a/tools/screenshots.mjs b/tools/screenshots.mjs
new file mode 100644
index 0000000..89635cb
--- /dev/null
+++ b/tools/screenshots.mjs
@@ -0,0 +1,53 @@
+/**
+ * Photograph the built game into docs/screenshots/, for the README and for looking a change
+ * over before it is committed. Always seed 1, so the pictures are of the same board each run.
+ *
+ *   npm run screenshots
+ */
+import { mkdirSync } from 'node:fs'
+import { chromium } from 'playwright'
+import { preview } from 'vite'
+
+const OUT = 'docs/screenshots'
+const PHONE = { width: 390, height: 844 }
+const DESKTOP = { width: 1280, height: 720 }
+
+const press = (...keys) => async (page) => {
+  for (const k of keys) {
+    await page.keyboard.press(k)
+    await page.waitForTimeout(60)
+  }
+}
+
+const SHOTS = [
+  { name: 'start', act: async () => {} },
+  // Bomb picked: the tiles it can go on are highlighted.
+  { name: 'bomb-picked', act: async (page) => { await page.click('[data-action=bomb]') } },
+  // A bomb placed to the right, then a step down out of its way: its countdown and the danger overlay.
+  { name: 'countdown', act: press('KeyB', 'KeyD', 'KeyS') },
+  // Walked to the right a little way: the camera follows.
+  { name: 'scrolled', act: press('KeyD', 'KeyD', 'KeyD', 'KeyD', 'KeyD', 'KeyD', 'KeyD', 'KeyD') },
+  // Standing next to our own bomb as it goes off.
+  { name: 'death', act: press('KeyB', 'KeyD', 'Space', 'Space', 'Space') },
+  { name: 'desktop', viewport: DESKTOP, act: press('KeyB', 'KeyD', 'KeyS') },
+]
+
+mkdirSync(OUT, { recursive: true })
+const server = await preview({ preview: { port: 4179, strictPort: true } })
+const browser = await chromium.launch()
+try {
+  for (const { name, viewport = PHONE, act } of SHOTS) {
+    const page = await browser.newPage({ viewport, deviceScaleFactor: 2, hasTouch: viewport === PHONE })
+    await page.goto('http://localhost:4179/?seed=1')
+    await page.waitForSelector('canvas')
+    await page.waitForTimeout(300)
+    await act(page)
+    await page.waitForTimeout(250)
+    await page.screenshot({ path: `${OUT}/${name}.png` })
+    await page.close()
+    console.log(`  ${OUT}/${name}.png`)
+  }
+} finally {
+  await browser.close()
+  server.httpServer.close()
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..d49d58e
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,17 @@
+{
+  "compilerOptions": {
+    "target": "ES2022",
+    "module": "ESNext",
+    "moduleResolution": "bundler",
+    "lib": ["ES2022", "DOM", "DOM.Iterable"],
+    "strict": true,
+    "noUncheckedIndexedAccess": true,
+    "noUnusedLocals": true,
+    "noUnusedParameters": true,
+    "noEmit": true,
+    "isolatedModules": true,
+    "skipLibCheck": true,
+    "types": ["vite/client", "node"]
+  },
+  "include": ["src", "test", "vite.config.ts"]
+}
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..74e70f8
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,20 @@
+import { defineConfig } from 'vite'
+import { qrcode } from 'vite-plugin-qrcode'
+
+/**
+ * Relative asset paths by default, so the same build works wherever Pages puts it:
+ * at https://<user>.gitlab.io/<project>/ or at the root of a generated domain. Set BASE to
+ * override. `??` would not catch BASE="", so `||`.
+ */
+const base = process.env.BASE || './'
+
+export default defineConfig({
+  base,
+  // `npm run dev` binds to every interface (--host) so a phone on the same Wi-Fi can open the
+  // game; this prints a scannable QR of that network URL alongside it.
+  plugins: [qrcode()],
+  // Not `public/`: GitLab Pages requires its artifact to be a directory named `public`, and
+  // `mv dist public` moves dist *inside* an existing `public/` instead of renaming it.
+  publicDir: 'static',
+  build: { target: 'es2022' },
+})