fuzzyworld

explodorama / commit

Re-style with Kenney's New Platformer Pack

644cfd0b69921138b2a41c0d31b44b1fb7a5c9b6 · Pavel Safronov · 2026-10-20 09:30:58 -0700 · parents 5551eb37b96f

Claude Code usage: this commit ~78.7K out / ~7.8M cached · cost unknown (no per-model trailer) · session 1 tally 182 in / 80.5K out / 7.9M cached · usage by commit and session

Re-style with Kenney's New Platformer Pack

Replace the placeholder shapes with sprites from Kenney's New Platformer
Pack 1.1 (CC0): green astronaut player, stone outer wall, coloured metal
pillars that cycle by level, wooden crates as soft blocks, bomb sprite
with HUD-digit timers, fireball flames. HUD, action buttons and banners
use the pack's hearts, flag, digits and character poses. Kenney is
credited in the README and on the in-game banner.

Sprites for planned features (enemies, coins, doors, gems as power-ups)
are copied into assets/kenney/ with their assignments in PLAN.md §13.
Decision 13 (Flaticon icons) is replaced; see decisions.md.

Usage: 182 in / 80.5K out / 7.9M cached (session tally)
rtk:   1.7M saved, 25.2% over 2758 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
 PLAN.md                                      | 159 +++++++++++----------------
 README.md                                    |  29 +++--
 assets/kenney/License.txt                    |  28 +++++
 assets/kenney/block_blue.png                 | Bin 0 -> 803 bytes
 assets/kenney/block_green.png                | Bin 0 -> 803 bytes
 assets/kenney/block_planks.png               | Bin 0 -> 2472 bytes
 assets/kenney/block_red.png                  | Bin 0 -> 815 bytes
 assets/kenney/block_yellow.png               | Bin 0 -> 803 bytes
 assets/kenney/bomb.png                       | Bin 0 -> 1952 bytes
 assets/kenney/bomb_active.png                | Bin 0 -> 1978 bytes
 assets/kenney/character_green_climb_a.png    | Bin 0 -> 3193 bytes
 assets/kenney/character_green_climb_b.png    | Bin 0 -> 3164 bytes
 assets/kenney/character_green_duck.png       | Bin 0 -> 3136 bytes
 assets/kenney/character_green_front.png      | Bin 0 -> 3349 bytes
 assets/kenney/character_green_hit.png        | Bin 0 -> 3447 bytes
 assets/kenney/character_green_idle.png       | Bin 0 -> 3295 bytes
 assets/kenney/character_green_jump.png       | Bin 0 -> 3382 bytes
 assets/kenney/character_green_walk_a.png     | Bin 0 -> 3329 bytes
 assets/kenney/character_green_walk_b.png     | Bin 0 -> 3421 bytes
 assets/kenney/coin_bronze.png                | Bin 0 -> 2133 bytes
 assets/kenney/coin_gold.png                  | Bin 0 -> 2164 bytes
 assets/kenney/coin_silver.png                | Bin 0 -> 2008 bytes
 assets/kenney/door_closed.png                | Bin 0 -> 659 bytes
 assets/kenney/door_closed_top.png            | Bin 0 -> 2066 bytes
 assets/kenney/door_open.png                  | Bin 0 -> 141 bytes
 assets/kenney/door_open_top.png              | Bin 0 -> 1692 bytes
 assets/kenney/fireball.png                   | Bin 0 -> 2485 bytes
 assets/kenney/flag_green_a.png               | Bin 0 -> 1291 bytes
 assets/kenney/fly_a.png                      | Bin 0 -> 3376 bytes
 assets/kenney/fly_b.png                      | Bin 0 -> 3098 bytes
 assets/kenney/fly_rest.png                   | Bin 0 -> 3342 bytes
 assets/kenney/frog_idle.png                  | Bin 0 -> 3331 bytes
 assets/kenney/frog_jump.png                  | Bin 0 -> 2632 bytes
 assets/kenney/frog_rest.png                  | Bin 0 -> 2486 bytes
 assets/kenney/gem_blue.png                   | Bin 0 -> 1436 bytes
 assets/kenney/gem_green.png                  | Bin 0 -> 1421 bytes
 assets/kenney/gem_red.png                    | Bin 0 -> 1434 bytes
 assets/kenney/gem_yellow.png                 | Bin 0 -> 1394 bytes
 assets/kenney/heart.png                      | Bin 0 -> 1226 bytes
 assets/kenney/hud_character_0.png            | Bin 0 -> 1149 bytes
 assets/kenney/hud_character_1.png            | Bin 0 -> 821 bytes
 assets/kenney/hud_character_2.png            | Bin 0 -> 1110 bytes
 assets/kenney/hud_character_3.png            | Bin 0 -> 1174 bytes
 assets/kenney/hud_character_4.png            | Bin 0 -> 775 bytes
 assets/kenney/hud_character_5.png            | Bin 0 -> 1106 bytes
 assets/kenney/hud_character_6.png            | Bin 0 -> 1159 bytes
 assets/kenney/hud_character_7.png            | Bin 0 -> 1058 bytes
 assets/kenney/hud_character_8.png            | Bin 0 -> 1164 bytes
 assets/kenney/hud_character_9.png            | Bin 0 -> 1164 bytes
 assets/kenney/hud_coin.png                   | Bin 0 -> 2407 bytes
 assets/kenney/hud_heart.png                  | Bin 0 -> 1685 bytes
 assets/kenney/hud_heart_empty.png            | Bin 0 -> 1683 bytes
 assets/kenney/lock_yellow.png                | Bin 0 -> 1450 bytes
 assets/kenney/mouse_rest.png                 | Bin 0 -> 2033 bytes
 assets/kenney/mouse_walk_a.png               | Bin 0 -> 2155 bytes
 assets/kenney/mouse_walk_b.png               | Bin 0 -> 2177 bytes
 assets/kenney/saw_a.png                      | Bin 0 -> 3048 bytes
 assets/kenney/saw_b.png                      | Bin 0 -> 3324 bytes
 assets/kenney/saw_rest.png                   | Bin 0 -> 2933 bytes
 assets/kenney/slime_block_jump.png           | Bin 0 -> 1350 bytes
 assets/kenney/slime_block_rest.png           | Bin 0 -> 1218 bytes
 assets/kenney/slime_block_walk_a.png         | Bin 0 -> 1340 bytes
 assets/kenney/slime_block_walk_b.png         | Bin 0 -> 1333 bytes
 assets/kenney/slime_fire_flat.png            | Bin 0 -> 2109 bytes
 assets/kenney/slime_fire_rest.png            | Bin 0 -> 2719 bytes
 assets/kenney/slime_fire_walk_a.png          | Bin 0 -> 2830 bytes
 assets/kenney/slime_fire_walk_b.png          | Bin 0 -> 2769 bytes
 assets/kenney/slime_normal_flat.png          | Bin 0 -> 1076 bytes
 assets/kenney/slime_normal_rest.png          | Bin 0 -> 1565 bytes
 assets/kenney/slime_normal_walk_a.png        | Bin 0 -> 1678 bytes
 assets/kenney/slime_normal_walk_b.png        | Bin 0 -> 1645 bytes
 assets/kenney/star.png                       | Bin 0 -> 2369 bytes
 assets/kenney/terrain_stone_block_center.png | Bin 0 -> 920 bytes
 decisions.md                                 |  22 +++-
 docs/screenshots/bomb-picked.png             | Bin 35985 -> 173420 bytes
 docs/screenshots/countdown.png               | Bin 38812 -> 178477 bytes
 docs/screenshots/death.png                   | Bin 38055 -> 165699 bytes
 docs/screenshots/desktop.png                 | Bin 58710 -> 569343 bytes
 docs/screenshots/scrolled.png                | Bin 35250 -> 151868 bytes
 docs/screenshots/start.png                   | Bin 35171 -> 172573 bytes
 index.html                                   |   5 +-
 src/main.ts                                  |  11 +-
 src/render/board.ts                          |  72 +++++++-----
 src/render/sprites.ts                        |  57 ++++++++++
 src/style.css                                | 133 +++++++++++++++++-----
 src/ui/controls.ts                           |  30 ++++-
 vite.config.ts                               |   4 +-
 87 files changed, 382 insertions(+), 168 deletions(-)

diff --git a/PLAN.md b/PLAN.md
index afe9c7a..0c583eb 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -26,7 +26,7 @@ turn), §15 (notes for the implementer) and §18 (milestones). Reasons for decis
 | 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. |
+| 13 | Art | **Kenney's New Platformer Pack 1.1** (CC0): sprites for the player, enemies, board, pickups, HUD and buttons. *Changed 2026-10-20 at the user's request; it replaces the earlier plan of Icongeek26 Flaticon icons.* See §13. |
 
 Still open: see §14.
 
@@ -350,85 +350,59 @@ itself, because the sim is deterministic:
 
 ## 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 |
+*Changed 2026-10-20 at the user's request.* The game uses **Kenney's New Platformer Pack 1.1**
+(<https://kenney.nl/assets/new-platformer-pack>), which the user downloaded. It replaces the earlier
+plan of black Icongeek26 outline icons from Flaticon over a plain drawn board.
+
+- **Licence:** CC0, so attribution isn't required, but **the user wants Kenney credited
+  properly**: in the README's Attribution section and in the game (a credit line with a link to
+  kenney.nl on the death / game-over banner; move it to the About dialog when there is one). The
+  pack's `License.txt` is kept in `assets/kenney/`.
+- **Source files:** only the sprites the game uses (or is about to use, below) are copied, unchanged
+  and under the pack's own file names, into `assets/kenney/`, so each can be found again in the
+  pack. All are 128 × 128: tiles and enemies from the pack's `Double` folders, characters from
+  `Default` (which are already 128 px).
+- **Loading:** no atlas. `src/render/sprites.ts` lists the board's sprites; each is its own LittleJS
+  texture (`engineInit`'s image list) and is drawn with `drawTile`. With the Canvas 2D renderer
+  separate images cost nothing measurable. `setTilesPixelated(false)`, since the art is smooth.
+  The HTML HUD and buttons use the same PNGs as `<img>`. Vite keeps every sprite a separate file
+  (`assetsInlineLimit: 0`), so art for things not in the game yet is never downloaded.
+- **Board look:** sprites everywhere. Colour still shows meaning, but **shape alone must already
+  tell things apart** — never colour only.
+- **HUD:** the pack's HUD digits for numbers, hearts (full/empty) for lives.
+- **Sounds:** the pack has 10 `.ogg` effects (`sfx_bump`, `coin`, `disappear`, `gem`, `hurt`,
+  `jump`, `jump-high`, `magic`, `select`, `throw`). **Decided (user, 2026-10-20): use them** when
+  sound is added (M7), copied into `assets/kenney/` like the sprites; ZzFX only for anything the
+  pack has no sound for (e.g. the explosion).
+
+Sprite assignments (✅ = drawn now; the rest are copied and waiting for their feature):
+
+| Thing | Sprite(s) in `assets/kenney/` |
+|---|---|
+| ✅ player | `character_green_idle`; `character_green_hit` when dead; mirrored when facing left. Other green frames (walk, jump, duck, climb, front) are there for animation. |
+| ✅ outer wall | `terrain_stone_block_center` |
+| ✅ pillars | `block_blue`, `block_green`, `block_red`, `block_yellow`, cycling by level |
+| ✅ soft block | `block_planks` (wooden crate) |
+| ✅ bomb | `bomb`; flashes to `bomb_active` on its last turn; timer drawn in `hud_character_0`–`9` |
+| ✅ flame | `fireball` (spinning, fading) over an orange tile |
+| ✅ HUD | `hud_heart` / `hud_heart_empty` (lives), `flag_green_a` (level), `bomb`, `fireball`, digits |
+| ✅ action buttons | Move `character_green_walk_a`, Bomb `bomb`, Wait `character_green_duck` |
+| ✅ banners | `character_green_hit` (lost a life), `hud_heart_empty` (game over); favicon `bomb` |
+| exit (closed / open) | `door_closed` + `door_closed_top`, `door_open` + `door_open_top`; `lock_yellow` |
+| coins | `coin_gold`, `coin_silver`, `coin_bronze`; `hud_coin` in the HUD |
+| pickups (power-ups) | gems: `gem_red` Fire +1, `gem_blue` Bomb +1, `gem_yellow` Fuse +1, `gem_green` Skates (suggested; final mapping when M6 is built); `heart` Heart; `star` Star |
+| Blob | `slime_normal_rest`, `_walk_a`, `_walk_b`, `_flat` (squashed when killed) |
+| Seeker | `fly_rest`, `fly_a`, `fly_b` |
+| Drifter | `saw_rest`, `saw_a`, `saw_b` |
+| Sprinter | `mouse_rest`, `mouse_walk_a`, `mouse_walk_b` |
+| Ghost (passes soft blocks) | `slime_block_rest`, `_walk_a`, `_walk_b`, `_jump` |
+| Wisp | `slime_fire_rest`, `_walk_a`, `_walk_b`, `_flat` |
+| Hunter | `frog_rest`, `frog_idle`, `frog_jump` |
+
+The pack has more enemies (bee, ladybug, snail, worm, barnacle, fish, spiked slime, falling
+block) and more tiles (keys, locks, levers, spikes, torches, …) for later enemies, curses and
+actions; copy them in when they are used. Icons the pack doesn't have (shop, workshop, trophy,
+settings, sound, and most action charges) are still open.
 
 ---
 
@@ -449,9 +423,8 @@ Icon list (search terms are suggestions; any Icongeek26 Outline icon that reads
    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.
+8. **Board theme:** ~~light "paper" board or dark board?~~ Settled by the Kenney art
+   (2026-10-20): a pale sky-blue board under dark bars, coloured as the sprites are.
 
 ---
 
@@ -512,18 +485,18 @@ src/
     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
+    board.ts             sprites, highlights, danger overlay, intents
+    sprites.ts           which Kenney sprites the board loads (§13)
     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)
+  audio.ts             Kenney pack sound effects (§13); ZzFX for any it lacks
   persist.ts           localStorage save/load, export/import
   main.ts
-assets/icons/          Icongeek26 PNGs (user-downloaded)
+assets/kenney/         Kenney New Platformer Pack sprites + License.txt (§13)
 static/                Vite publicDir (NOT public/ — see §17)
 test/                  Vitest
-tools/                 atlas.mjs, icons.mjs, screenshots.mjs (Playwright)
+tools/                 icons.mjs (PWA icons), screenshots.mjs (Playwright)
 ```
 
 - `step()` **returns events** (`moved`, `bombPlaced`, `bombTicked`, `exploded`, `blockDestroyed`,
@@ -538,7 +511,7 @@ tools/                 atlas.mjs, icons.mjs, screenshots.mjs (Playwright)
 ## 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`.
+  `test` = `vitest run`, `screenshots`, `icons`.
 - 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.
@@ -547,10 +520,10 @@ tools/                 atlas.mjs, icons.mjs, screenshots.mjs (Playwright)
 - 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
+  (display fullscreen, icons, offline cache incl. the sprites). 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
+  "fail loudly" checks; add a check that the sprites were built). 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
@@ -566,13 +539,13 @@ tools/                 atlas.mjs, icons.mjs, screenshots.mjs (Playwright)
    (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
+   (press-and-hold / hover), keyboard controls; Kenney sprites (2026-10-20). ⏳ 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
+8. **M7 – Look & feel:** animations, sounds (Kenney pack, §13), PWA icons, remaining
    enemies and actions, screenshots tool.
 9. **M8 – Permanence:** Workshop, save export/import, victory certificate + QR, replay file and
    verify page.
diff --git a/README.md b/README.md
index 4fcfa59..ca216cb 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@ Enemies, pickups, the shop and the 50-level run are planned — see [PLAN.md](PL
 
 ## How to play
 
-You are the blue circle. Each turn you pick **one** action; then the world takes its turn.
+You are the green astronaut. Each turn you pick **one** action; then the world takes its turn.
 
 | Action | Phone | Keyboard | What it does |
 |---|---|---|---|
@@ -28,9 +28,9 @@ You are the blue circle. Each turn you pick **one** action; then the world takes
 ### 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.
+  shows **3**. A bomb showing **1** (flashing white) 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.
+  the first soft block (wooden crate) in each direction and stops there.
 - A bomb caught in a blast **explodes at once**, whatever its number (a chain reaction).
 - Bombs block movement.
 
@@ -46,8 +46,9 @@ layout. With none left, the run is over and the seed is shown, so the same board
 
 ### The board
 
-31 × 13 tiles, like the original: dark walls and pillars (indestructible), brown soft blocks
-(destructible), and the camera follows you sideways.
+31 × 13 tiles, like the original: a stone outer wall and metal pillars (indestructible; the
+pillars change colour from level to level), wooden crates as soft blocks (destructible), and the
+camera follows you sideways.
 
 ## Running it
 
@@ -71,7 +72,9 @@ Deployed to GitLab Pages by `.gitlab-ci.yml` on every push to the default branch
   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.
+- **`src/render/sprites.ts`** — the sprite list. The art lives in `assets/kenney/` under the
+  pack's own file names.
+- **`src/ui/`** — the HUD, action bar and banners, as plain HTML, using the same sprites as icons.
 - 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.
 
@@ -79,10 +82,16 @@ 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.
+Art: [New Platformer Pack](https://kenney.nl/assets/new-platformer-pack) 1.1 by
+[Kenney](https://www.kenney.nl), under [CC0](http://creativecommons.org/publicdomain/zero/1.0/)
+(public domain). Kenney doesn't require credit, but the pack was chosen deliberately and the game
+looks the way it does because of it, so it is credited here and in the game (on the banner shown
+after each death). Thank you, Kenney! Support his work at <https://www.kenney.nl/donate>.
+
+The sprites used are copied, unchanged and under their original file names, into
+[`assets/kenney/`](assets/kenney/) with the pack's [licence file](assets/kenney/License.txt). They
+are not covered by this project's MIT licence. Sound effects, when the game gets them, will come
+from the same pack.
 
 ## Licence
 
diff --git a/assets/kenney/License.txt b/assets/kenney/License.txt
new file mode 100644
index 0000000..2346704
--- /dev/null
+++ b/assets/kenney/License.txt
@@ -0,0 +1,28 @@
+	

+

+	New Platformer Pack (1.1)

+

+	Created/distributed by Kenney (www.kenney.nl)

+	Creation date: 03-12-2025

+	

+			------------------------------

+

+	License: (Creative Commons Zero, CC0)

+	http://creativecommons.org/publicdomain/zero/1.0/

+

+	You can use this content for personal, educational, and commercial purposes.

+

+	Support by crediting 'Kenney' or 'www.kenney.nl' (this is not a requirement)

+

+			------------------------------

+

+	• Website : www.kenney.nl

+	• Donate  : www.kenney.nl/donate

+

+	• Patreon : patreon.com/kenney

+	

+	Follow on social media for updates:

+

+	• Twitter:	 twitter.com/KenneyNL

+	• BlueSky:	 kenney.bsky.social

+	• Instagram:	 instagram.com/kenney_nl
\ No newline at end of file
diff --git a/assets/kenney/block_blue.png b/assets/kenney/block_blue.png
new file mode 100644
index 0000000..2cd5345
Binary files /dev/null and b/assets/kenney/block_blue.png differ
diff --git a/assets/kenney/block_green.png b/assets/kenney/block_green.png
new file mode 100644
index 0000000..fe56e2f
Binary files /dev/null and b/assets/kenney/block_green.png differ
diff --git a/assets/kenney/block_planks.png b/assets/kenney/block_planks.png
new file mode 100644
index 0000000..0af3e4a
Binary files /dev/null and b/assets/kenney/block_planks.png differ
diff --git a/assets/kenney/block_red.png b/assets/kenney/block_red.png
new file mode 100644
index 0000000..e9f7fed
Binary files /dev/null and b/assets/kenney/block_red.png differ
diff --git a/assets/kenney/block_yellow.png b/assets/kenney/block_yellow.png
new file mode 100644
index 0000000..3115c5c
Binary files /dev/null and b/assets/kenney/block_yellow.png differ
diff --git a/assets/kenney/bomb.png b/assets/kenney/bomb.png
new file mode 100644
index 0000000..f902149
Binary files /dev/null and b/assets/kenney/bomb.png differ
diff --git a/assets/kenney/bomb_active.png b/assets/kenney/bomb_active.png
new file mode 100644
index 0000000..dacff54
Binary files /dev/null and b/assets/kenney/bomb_active.png differ
diff --git a/assets/kenney/character_green_climb_a.png b/assets/kenney/character_green_climb_a.png
new file mode 100644
index 0000000..d66a8bb
Binary files /dev/null and b/assets/kenney/character_green_climb_a.png differ
diff --git a/assets/kenney/character_green_climb_b.png b/assets/kenney/character_green_climb_b.png
new file mode 100644
index 0000000..540e88b
Binary files /dev/null and b/assets/kenney/character_green_climb_b.png differ
diff --git a/assets/kenney/character_green_duck.png b/assets/kenney/character_green_duck.png
new file mode 100644
index 0000000..db945f2
Binary files /dev/null and b/assets/kenney/character_green_duck.png differ
diff --git a/assets/kenney/character_green_front.png b/assets/kenney/character_green_front.png
new file mode 100644
index 0000000..996dfae
Binary files /dev/null and b/assets/kenney/character_green_front.png differ
diff --git a/assets/kenney/character_green_hit.png b/assets/kenney/character_green_hit.png
new file mode 100644
index 0000000..eee3c53
Binary files /dev/null and b/assets/kenney/character_green_hit.png differ
diff --git a/assets/kenney/character_green_idle.png b/assets/kenney/character_green_idle.png
new file mode 100644
index 0000000..49562df
Binary files /dev/null and b/assets/kenney/character_green_idle.png differ
diff --git a/assets/kenney/character_green_jump.png b/assets/kenney/character_green_jump.png
new file mode 100644
index 0000000..79f990c
Binary files /dev/null and b/assets/kenney/character_green_jump.png differ
diff --git a/assets/kenney/character_green_walk_a.png b/assets/kenney/character_green_walk_a.png
new file mode 100644
index 0000000..547fd6d
Binary files /dev/null and b/assets/kenney/character_green_walk_a.png differ
diff --git a/assets/kenney/character_green_walk_b.png b/assets/kenney/character_green_walk_b.png
new file mode 100644
index 0000000..c2a1d3f
Binary files /dev/null and b/assets/kenney/character_green_walk_b.png differ
diff --git a/assets/kenney/coin_bronze.png b/assets/kenney/coin_bronze.png
new file mode 100644
index 0000000..3ebb2ed
Binary files /dev/null and b/assets/kenney/coin_bronze.png differ
diff --git a/assets/kenney/coin_gold.png b/assets/kenney/coin_gold.png
new file mode 100644
index 0000000..8724da8
Binary files /dev/null and b/assets/kenney/coin_gold.png differ
diff --git a/assets/kenney/coin_silver.png b/assets/kenney/coin_silver.png
new file mode 100644
index 0000000..590e9e4
Binary files /dev/null and b/assets/kenney/coin_silver.png differ
diff --git a/assets/kenney/door_closed.png b/assets/kenney/door_closed.png
new file mode 100644
index 0000000..93a02c6
Binary files /dev/null and b/assets/kenney/door_closed.png differ
diff --git a/assets/kenney/door_closed_top.png b/assets/kenney/door_closed_top.png
new file mode 100644
index 0000000..50c804d
Binary files /dev/null and b/assets/kenney/door_closed_top.png differ
diff --git a/assets/kenney/door_open.png b/assets/kenney/door_open.png
new file mode 100644
index 0000000..b9c6f56
Binary files /dev/null and b/assets/kenney/door_open.png differ
diff --git a/assets/kenney/door_open_top.png b/assets/kenney/door_open_top.png
new file mode 100644
index 0000000..f0d4e1d
Binary files /dev/null and b/assets/kenney/door_open_top.png differ
diff --git a/assets/kenney/fireball.png b/assets/kenney/fireball.png
new file mode 100644
index 0000000..4133e6a
Binary files /dev/null and b/assets/kenney/fireball.png differ
diff --git a/assets/kenney/flag_green_a.png b/assets/kenney/flag_green_a.png
new file mode 100644
index 0000000..2b08602
Binary files /dev/null and b/assets/kenney/flag_green_a.png differ
diff --git a/assets/kenney/fly_a.png b/assets/kenney/fly_a.png
new file mode 100644
index 0000000..7a1bb9b
Binary files /dev/null and b/assets/kenney/fly_a.png differ
diff --git a/assets/kenney/fly_b.png b/assets/kenney/fly_b.png
new file mode 100644
index 0000000..5e250d4
Binary files /dev/null and b/assets/kenney/fly_b.png differ
diff --git a/assets/kenney/fly_rest.png b/assets/kenney/fly_rest.png
new file mode 100644
index 0000000..3c70fe2
Binary files /dev/null and b/assets/kenney/fly_rest.png differ
diff --git a/assets/kenney/frog_idle.png b/assets/kenney/frog_idle.png
new file mode 100644
index 0000000..de90bef
Binary files /dev/null and b/assets/kenney/frog_idle.png differ
diff --git a/assets/kenney/frog_jump.png b/assets/kenney/frog_jump.png
new file mode 100644
index 0000000..bc63391
Binary files /dev/null and b/assets/kenney/frog_jump.png differ
diff --git a/assets/kenney/frog_rest.png b/assets/kenney/frog_rest.png
new file mode 100644
index 0000000..a74e18f
Binary files /dev/null and b/assets/kenney/frog_rest.png differ
diff --git a/assets/kenney/gem_blue.png b/assets/kenney/gem_blue.png
new file mode 100644
index 0000000..7cd3ec8
Binary files /dev/null and b/assets/kenney/gem_blue.png differ
diff --git a/assets/kenney/gem_green.png b/assets/kenney/gem_green.png
new file mode 100644
index 0000000..7ccce2e
Binary files /dev/null and b/assets/kenney/gem_green.png differ
diff --git a/assets/kenney/gem_red.png b/assets/kenney/gem_red.png
new file mode 100644
index 0000000..953761c
Binary files /dev/null and b/assets/kenney/gem_red.png differ
diff --git a/assets/kenney/gem_yellow.png b/assets/kenney/gem_yellow.png
new file mode 100644
index 0000000..1e7acfd
Binary files /dev/null and b/assets/kenney/gem_yellow.png differ
diff --git a/assets/kenney/heart.png b/assets/kenney/heart.png
new file mode 100644
index 0000000..baab2e4
Binary files /dev/null and b/assets/kenney/heart.png differ
diff --git a/assets/kenney/hud_character_0.png b/assets/kenney/hud_character_0.png
new file mode 100644
index 0000000..3042a55
Binary files /dev/null and b/assets/kenney/hud_character_0.png differ
diff --git a/assets/kenney/hud_character_1.png b/assets/kenney/hud_character_1.png
new file mode 100644
index 0000000..1ce87b4
Binary files /dev/null and b/assets/kenney/hud_character_1.png differ
diff --git a/assets/kenney/hud_character_2.png b/assets/kenney/hud_character_2.png
new file mode 100644
index 0000000..344e578
Binary files /dev/null and b/assets/kenney/hud_character_2.png differ
diff --git a/assets/kenney/hud_character_3.png b/assets/kenney/hud_character_3.png
new file mode 100644
index 0000000..ecd4b1e
Binary files /dev/null and b/assets/kenney/hud_character_3.png differ
diff --git a/assets/kenney/hud_character_4.png b/assets/kenney/hud_character_4.png
new file mode 100644
index 0000000..488f053
Binary files /dev/null and b/assets/kenney/hud_character_4.png differ
diff --git a/assets/kenney/hud_character_5.png b/assets/kenney/hud_character_5.png
new file mode 100644
index 0000000..0a7687d
Binary files /dev/null and b/assets/kenney/hud_character_5.png differ
diff --git a/assets/kenney/hud_character_6.png b/assets/kenney/hud_character_6.png
new file mode 100644
index 0000000..a3eeb0e
Binary files /dev/null and b/assets/kenney/hud_character_6.png differ
diff --git a/assets/kenney/hud_character_7.png b/assets/kenney/hud_character_7.png
new file mode 100644
index 0000000..2faa35e
Binary files /dev/null and b/assets/kenney/hud_character_7.png differ
diff --git a/assets/kenney/hud_character_8.png b/assets/kenney/hud_character_8.png
new file mode 100644
index 0000000..7a678fb
Binary files /dev/null and b/assets/kenney/hud_character_8.png differ
diff --git a/assets/kenney/hud_character_9.png b/assets/kenney/hud_character_9.png
new file mode 100644
index 0000000..844a6a9
Binary files /dev/null and b/assets/kenney/hud_character_9.png differ
diff --git a/assets/kenney/hud_coin.png b/assets/kenney/hud_coin.png
new file mode 100644
index 0000000..89c4a53
Binary files /dev/null and b/assets/kenney/hud_coin.png differ
diff --git a/assets/kenney/hud_heart.png b/assets/kenney/hud_heart.png
new file mode 100644
index 0000000..edc3ca4
Binary files /dev/null and b/assets/kenney/hud_heart.png differ
diff --git a/assets/kenney/hud_heart_empty.png b/assets/kenney/hud_heart_empty.png
new file mode 100644
index 0000000..9b548d8
Binary files /dev/null and b/assets/kenney/hud_heart_empty.png differ
diff --git a/assets/kenney/lock_yellow.png b/assets/kenney/lock_yellow.png
new file mode 100644
index 0000000..b3905d7
Binary files /dev/null and b/assets/kenney/lock_yellow.png differ
diff --git a/assets/kenney/mouse_rest.png b/assets/kenney/mouse_rest.png
new file mode 100644
index 0000000..36bced1
Binary files /dev/null and b/assets/kenney/mouse_rest.png differ
diff --git a/assets/kenney/mouse_walk_a.png b/assets/kenney/mouse_walk_a.png
new file mode 100644
index 0000000..da39c6a
Binary files /dev/null and b/assets/kenney/mouse_walk_a.png differ
diff --git a/assets/kenney/mouse_walk_b.png b/assets/kenney/mouse_walk_b.png
new file mode 100644
index 0000000..3542beb
Binary files /dev/null and b/assets/kenney/mouse_walk_b.png differ
diff --git a/assets/kenney/saw_a.png b/assets/kenney/saw_a.png
new file mode 100644
index 0000000..98eb775
Binary files /dev/null and b/assets/kenney/saw_a.png differ
diff --git a/assets/kenney/saw_b.png b/assets/kenney/saw_b.png
new file mode 100644
index 0000000..e49321c
Binary files /dev/null and b/assets/kenney/saw_b.png differ
diff --git a/assets/kenney/saw_rest.png b/assets/kenney/saw_rest.png
new file mode 100644
index 0000000..cd10501
Binary files /dev/null and b/assets/kenney/saw_rest.png differ
diff --git a/assets/kenney/slime_block_jump.png b/assets/kenney/slime_block_jump.png
new file mode 100644
index 0000000..c6134e2
Binary files /dev/null and b/assets/kenney/slime_block_jump.png differ
diff --git a/assets/kenney/slime_block_rest.png b/assets/kenney/slime_block_rest.png
new file mode 100644
index 0000000..620a1ab
Binary files /dev/null and b/assets/kenney/slime_block_rest.png differ
diff --git a/assets/kenney/slime_block_walk_a.png b/assets/kenney/slime_block_walk_a.png
new file mode 100644
index 0000000..237dc56
Binary files /dev/null and b/assets/kenney/slime_block_walk_a.png differ
diff --git a/assets/kenney/slime_block_walk_b.png b/assets/kenney/slime_block_walk_b.png
new file mode 100644
index 0000000..e9dfde3
Binary files /dev/null and b/assets/kenney/slime_block_walk_b.png differ
diff --git a/assets/kenney/slime_fire_flat.png b/assets/kenney/slime_fire_flat.png
new file mode 100644
index 0000000..bbd3df5
Binary files /dev/null and b/assets/kenney/slime_fire_flat.png differ
diff --git a/assets/kenney/slime_fire_rest.png b/assets/kenney/slime_fire_rest.png
new file mode 100644
index 0000000..6332464
Binary files /dev/null and b/assets/kenney/slime_fire_rest.png differ
diff --git a/assets/kenney/slime_fire_walk_a.png b/assets/kenney/slime_fire_walk_a.png
new file mode 100644
index 0000000..b8d7a72
Binary files /dev/null and b/assets/kenney/slime_fire_walk_a.png differ
diff --git a/assets/kenney/slime_fire_walk_b.png b/assets/kenney/slime_fire_walk_b.png
new file mode 100644
index 0000000..918b8b2
Binary files /dev/null and b/assets/kenney/slime_fire_walk_b.png differ
diff --git a/assets/kenney/slime_normal_flat.png b/assets/kenney/slime_normal_flat.png
new file mode 100644
index 0000000..85da736
Binary files /dev/null and b/assets/kenney/slime_normal_flat.png differ
diff --git a/assets/kenney/slime_normal_rest.png b/assets/kenney/slime_normal_rest.png
new file mode 100644
index 0000000..10d8fb5
Binary files /dev/null and b/assets/kenney/slime_normal_rest.png differ
diff --git a/assets/kenney/slime_normal_walk_a.png b/assets/kenney/slime_normal_walk_a.png
new file mode 100644
index 0000000..c551587
Binary files /dev/null and b/assets/kenney/slime_normal_walk_a.png differ
diff --git a/assets/kenney/slime_normal_walk_b.png b/assets/kenney/slime_normal_walk_b.png
new file mode 100644
index 0000000..3f78558
Binary files /dev/null and b/assets/kenney/slime_normal_walk_b.png differ
diff --git a/assets/kenney/star.png b/assets/kenney/star.png
new file mode 100644
index 0000000..2f773f8
Binary files /dev/null and b/assets/kenney/star.png differ
diff --git a/assets/kenney/terrain_stone_block_center.png b/assets/kenney/terrain_stone_block_center.png
new file mode 100644
index 0000000..23ef36a
Binary files /dev/null and b/assets/kenney/terrain_stone_block_center.png differ
diff --git a/decisions.md b/decisions.md
index dce4a51..f26bba2 100644
--- a/decisions.md
+++ b/decisions.md
@@ -21,7 +21,7 @@ in [PLAN.md](PLAN.md); this file records the reasoning so it isn't lost.
 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.
+    LittleJS ships no icons. *Replaced 2026-10-20 — see "Art: Kenney's New Platformer Pack" below.*
 
 ## Technical
 
@@ -54,3 +54,23 @@ so every flame that reaches a block that turn stops at it.
 
 `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.
+
+### 2026-10-20 — Art: Kenney's New Platformer Pack, replacing the Flaticon plan
+
+The user supplied Kenney's New Platformer Pack 1.1 and asked for the game to be re-styled with it:
+`character_green` as the player, the pack's enemies for enemies, its blocks for the board, gems as
+power-ups, and its coins, doors, bomb and HUD icons. This replaces design decision 13 (Icongeek26
+outline icons over a board of plain drawn shapes). The pack is CC0, so attribution is optional;
+the README credits Kenney anyway.
+
+- **Separate textures, no atlas.** Every sprite is its own LittleJS texture, listed in
+  `src/render/sprites.ts`. The Canvas 2D renderer doesn't batch, so an atlas would add a build
+  step and save nothing. Revisit only if the WebGL renderer comes back.
+- **Copied, not referenced.** Only the sprites in use or assigned to a planned feature are copied
+  into `assets/kenney/`, unchanged and under their pack names, so the build doesn't depend on a
+  folder outside the repo and every file can be traced back to the pack.
+- **Not inlined.** `assetsInlineLimit: 0`: Vite would otherwise turn every sprite under 4 KB into
+  a data URL inside the script, including the art for enemies and pickups that aren't drawn yet.
+- **Not pixelated.** `setTilesPixelated(false)`: the sprites are smooth art drawn at about
+  100–110 device pixels from 128, which looks jagged without smoothing.
+- **Pale floor, not grass.** The player is mint green; a green floor hid them.
diff --git a/docs/screenshots/bomb-picked.png b/docs/screenshots/bomb-picked.png
index 82b288a..d5507da 100644
Binary files a/docs/screenshots/bomb-picked.png and b/docs/screenshots/bomb-picked.png differ
diff --git a/docs/screenshots/countdown.png b/docs/screenshots/countdown.png
index 9014a94..073e704 100644
Binary files a/docs/screenshots/countdown.png and b/docs/screenshots/countdown.png differ
diff --git a/docs/screenshots/death.png b/docs/screenshots/death.png
index e6e12b9..54ee735 100644
Binary files a/docs/screenshots/death.png and b/docs/screenshots/death.png differ
diff --git a/docs/screenshots/desktop.png b/docs/screenshots/desktop.png
index 2ba5115..947a818 100644
Binary files a/docs/screenshots/desktop.png and b/docs/screenshots/desktop.png differ
diff --git a/docs/screenshots/scrolled.png b/docs/screenshots/scrolled.png
index 7b22db6..999adfd 100644
Binary files a/docs/screenshots/scrolled.png and b/docs/screenshots/scrolled.png differ
diff --git a/docs/screenshots/start.png b/docs/screenshots/start.png
index 3145463..c7b6252 100644
Binary files a/docs/screenshots/start.png and b/docs/screenshots/start.png differ
diff --git a/index.html b/index.html
index 8002e41..a71dcf5 100644
--- a/index.html
+++ b/index.html
@@ -5,7 +5,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="theme-color" content="#353541" />
+<link rel="icon" type="image/png" href="/assets/kenney/bomb.png" />
 <meta name="mobile-web-app-capable" content="yes" />
 <meta name="apple-mobile-web-app-capable" content="yes" />
 <link rel="stylesheet" href="/src/style.css" />
@@ -19,8 +20,10 @@
 </header>
 <nav id="actions" aria-label="Actions"></nav>
 <div id="banner" hidden>
+  <img id="banner-icon" alt="" />
   <p id="banner-text"></p>
   <button id="banner-button" type="button"></button>
+  <p class="credit">Art by <a href="https://www.kenney.nl" target="_blank" rel="noopener">Kenney</a> · New Platformer Pack (CC0)</p>
 </div>
 <script type="module" src="/src/main.ts"></script>
 </body>
diff --git a/src/main.ts b/src/main.ts
index ec5f51d..80fa620 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -6,11 +6,13 @@ import {
   setCanvasPixelRatio,
   setGLEnable,
   setShowSplashScreen,
+  setTilesPixelated,
   time,
   vec2,
 } from 'littlejsengine'
 import { BACKGROUND, drawBoard, FLAME_SECONDS, toTile, type Flame } from './render/board'
 import { updateCamera } from './render/camera'
+import { TEXTURES } from './render/sprites'
 import { isAvailable, isValidTarget, ACTIONS } from './sim/actions'
 import { predictDanger, type Danger } from './sim/danger'
 import { newRecord, type RunRecord } from './sim/record'
@@ -30,6 +32,7 @@ let danger: Danger
 let selected: ActionId | null = null
 let hover: Vec | null = null
 let flames: Flame[] = []
+let facingLeft = false
 let snapCamera = true
 
 function startRun(seed: number): void {
@@ -49,6 +52,7 @@ function act(action: Action): void {
   for (const e of result.events) {
     if (e.type === 'exploded') flames.push({ tiles: e.tiles, born: time })
     if (e.type === 'levelRestarted') snapCamera = true
+    if (e.type === 'moved' && e.to.x !== e.from.x) facingLeft = e.to.x < e.from.x
   }
   selected = null
   afterChange()
@@ -125,10 +129,12 @@ 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
+// Canvas 2D, not WebGL: a board of a few hundred sprites doesn't need it, and headless
 // Chromium's screenshots of the WebGL canvas showed a black strip that isn't in the drawing,
 // which made the screenshots untrustworthy for review (decisions.md).
 setGLEnable(false)
+// The sprites are smooth vector-style art drawn smaller than their 128 px, not pixel art.
+setTilesPixelated(false)
 
 await engineInit(
   () => {
@@ -153,6 +159,7 @@ await engineInit(
     flames = flames.filter((f) => time - f.born < FLAME_SECONDS)
   },
   () => {},
-  () => drawBoard({ state, danger, selected, hover, flames }),
+  () => drawBoard({ state, danger, selected, hover, flames, facingLeft }),
   () => {},
+  TEXTURES,
 )
diff --git a/src/render/board.ts b/src/render/board.ts
index 95d0db3..5277b15 100644
--- a/src/render/board.ts
+++ b/src/render/board.ts
@@ -1,9 +1,10 @@
-import { drawCircle, drawRect, drawText, rgb, time, vec2, type Color, type Vector2 } from 'littlejsengine'
+import { drawRect, drawTile, 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'
+import { digitSprite, sprite, type BoardSprite } from './sprites'
 
 /**
  * The one place where sim tiles meet LittleJS world space. The sim's y grows downward
@@ -30,46 +31,48 @@ export interface View {
   /** The tile under the pointer or finger, for the commit preview. */
   hover: Vec | null
   flames: Flame[]
+  /** The way the player last stepped. The character sprite faces right; this mirrors it. */
+  facingLeft: boolean
 }
 
 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),
+  sky: rgb(0.765, 0.89, 1),
+  floor: rgb(0.95, 0.97, 0.99),
+  floorAlt: rgb(0.9, 0.94, 0.98),
   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),
+  target: rgb(0.25, 0.62, 0.87, 0.4),
+  targetHover: rgb(0.25, 0.62, 0.87, 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),
+  ghost: rgb(1, 1, 1, 0.45),
 }
 
-export const BACKGROUND: Color = C.grid
+export const BACKGROUND: Color = C.sky
+
+/** Pillars change colour from level to level; the outer wall is always stone. */
+const PILLARS: BoardSprite[] = ['block_blue', 'block_green', 'block_red', 'block_yellow']
 
 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)
+  const tileSize = vec2(1)
+  const pillar = sprite(PILLARS[(s.level - 1) % PILLARS.length]!)
 
   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)
+      drawRect(pos, wallSize, (x + y) % 2 ? C.floorAlt : C.floor)
+      if (cell === 'wall') {
+        const edge = x === 0 || y === 0 || x === s.width - 1 || y === s.height - 1
+        drawTile(pos, wallSize, edge ? sprite('terrain_stone_block_center') : pillar)
+      } else if (cell === 'block') drawTile(pos, tileSize, sprite('block_planks'))
     }
   }
 
@@ -89,23 +92,32 @@ export function drawBoard(view: View): void {
   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 t of f.tiles) {
+      const pos = toWorld(s, t)
+      drawRect(pos, vec2(0.9), C.flame.scale(1, fade))
+      drawTile(pos, vec2(0.6 + 0.5 * (1 - fade)), sprite('fireball'), C.white.scale(1, fade), time * 6)
+    }
   }
 
   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)
+    // The last turn before it goes off, it flashes.
+    const hot = b.timer <= 1 && Math.sin(time * 14) > 0
+    drawBomb(toWorld(s, b.pos), b.timer, hot ? 'bomb_active' : 'bomb', C.white)
   }
+  if (hovered && selected === 'bomb') drawBomb(toWorld(s, hovered), s.stats.fuse, 'bomb', C.ghost)
+
+  if (hovered && selected === 'move') drawTile(toWorld(s, hovered), tileSize, sprite('character_green_idle'), C.ghost, 0, view.facingLeft)
+  const alive = s.status === 'playing'
+  drawTile(toWorld(s, s.player), tileSize, sprite(alive ? 'character_green_idle' : 'character_green_hit'), C.white, 0, view.facingLeft)
+}
 
-  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)
+/** The bomb sprite with its timer on the body, which sits a little below and right of the tile's centre. */
+function drawBomb(pos: Vector2, timer: number, body: BoardSprite, color: Color): void {
+  drawTile(pos, vec2(0.9), sprite(body), color)
+  const digits = String(timer)
+  const w = 0.32
+  const start = pos.add(vec2(0.04 - ((digits.length - 1) * w) / 2, -0.06))
+  for (let i = 0; i < digits.length; i++) drawTile(start.add(vec2(i * w, 0)), vec2(0.42), digitSprite(Number(digits[i])), color)
 }
 
 function tileOf(s: GameState, i: number): Vec {
diff --git a/src/render/sprites.ts b/src/render/sprites.ts
new file mode 100644
index 0000000..26e0571
--- /dev/null
+++ b/src/render/sprites.ts
@@ -0,0 +1,57 @@
+import { tile, vec2, type TileInfo } from 'littlejsengine'
+
+/**
+ * Art from Kenney's New Platformer Pack 1.1 (CC0), copied into `assets/kenney/` under the pack's
+ * own file names, so a sprite can always be found again in the pack. Every file is 128 × 128:
+ * tiles and enemies from the pack's `Double` folders, characters from `Default`.
+ */
+const URLS = import.meta.glob<string>('../../assets/kenney/*.png', { eager: true, query: '?url', import: 'default' })
+
+export function spriteUrl(name: string): string {
+  const url = URLS[`../../assets/kenney/${name}.png`]
+  if (!url) throw new Error(`Missing sprite: assets/kenney/${name}.png`)
+  return url
+}
+
+/** The sprites drawn on the canvas. Their order is their LittleJS texture index. */
+const BOARD = [
+  'terrain_stone_block_center',
+  'block_blue',
+  'block_green',
+  'block_red',
+  'block_yellow',
+  'block_planks',
+  'bomb',
+  'bomb_active',
+  'fireball',
+  'character_green_idle',
+  'character_green_hit',
+  'hud_character_0',
+  'hud_character_1',
+  'hud_character_2',
+  'hud_character_3',
+  'hud_character_4',
+  'hud_character_5',
+  'hud_character_6',
+  'hud_character_7',
+  'hud_character_8',
+  'hud_character_9',
+] as const
+
+export type BoardSprite = (typeof BOARD)[number]
+
+/** For `engineInit`, which loads them all before the first frame. */
+export const TEXTURES = BOARD.map(spriteUrl)
+
+const tiles = new Map<BoardSprite, TileInfo>()
+
+/** Only after `engineInit` has loaded the textures. */
+export function sprite(name: BoardSprite): TileInfo {
+  let t = tiles.get(name)
+  if (!t) tiles.set(name, (t = tile(0, vec2(128), BOARD.indexOf(name))))
+  return t
+}
+
+export function digitSprite(d: number): TileInfo {
+  return sprite(`hud_character_${d}` as BoardSprite)
+}
diff --git a/src/style.css b/src/style.css
index f04fa06..d74d693 100644
--- a/src/style.css
+++ b/src/style.css
@@ -1,10 +1,11 @@
+/* Colours taken from the Kenney sprites, so the bars and the board look like one set. */
 :root {
-  --paper: #f4efe4;
-  --ink: #1d1b18;
-  --muted: #6b665c;
-  --accent: #2f6fd6;
-  --bar-bg: #fffaf0;
-  --line: #d9d1c0;
+  --sky: #c3e3ff;
+  --ink: #353541;
+  --muted: #6b6b7b;
+  --accent: #2cc987;
+  --accent-dark: #1f9e69;
+  --panel: #fff;
 }
 
 html,
@@ -12,9 +13,9 @@ body {
   margin: 0;
   height: 100%;
   overflow: hidden;
-  background: var(--paper);
+  background: var(--sky);
   color: var(--ink);
-  font: 16px/1.3 system-ui, -apple-system, sans-serif;
+  font: 16px/1.3 ui-rounded, 'SF Pro Rounded', 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;
@@ -23,6 +24,19 @@ body {
   overscroll-behavior: none;
 }
 
+img {
+  pointer-events: none;
+}
+
+.sr-only {
+  position: absolute;
+  width: 1px;
+  height: 1px;
+  overflow: hidden;
+  clip-path: inset(50%);
+  white-space: nowrap;
+}
+
 #hud {
   position: fixed;
   top: 0;
@@ -31,11 +45,45 @@ body {
   z-index: 2;
   display: flex;
   justify-content: space-between;
+  align-items: center;
   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;
+  padding: calc(env(safe-area-inset-top) + 6px) 16px 6px;
+  background: var(--ink);
+  color: #fff;
+}
+
+#hud > span {
+  display: flex;
+  align-items: center;
+}
+
+#hud .icon {
+  width: 28px;
+  height: 28px;
+  /* A light rim, so the dark bomb sprite stands out on the dark bar. */
+  filter: drop-shadow(0 0 1.5px rgb(255 255 255 / 0.7));
+}
+
+#hud .digit {
+  width: 20px;
+  height: 20px;
+  margin-right: -4px;
+}
+
+#hud .icon + .sr-only + .digit {
+  margin-left: 2px;
+}
+
+#hud .digit + .icon {
+  margin-left: 10px;
+}
+
+#hud small {
+  margin-right: 4px;
+  font-size: 13px;
+  font-weight: 800;
+  letter-spacing: 0.04em;
+  text-transform: uppercase;
 }
 
 #actions {
@@ -48,40 +96,57 @@ body {
   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);
+  background: var(--ink);
 }
 
 #actions button {
   flex: 1 0 auto;
+  display: grid;
+  grid-template-columns: auto auto;
+  grid-template-rows: auto auto;
+  align-items: center;
+  justify-content: center;
+  column-gap: 6px;
   min-width: 72px;
   min-height: 56px;
-  border: 2px solid var(--ink);
+  margin-bottom: 4px;
+  border: 3px solid var(--ink);
   border-radius: 12px;
-  background: #fff;
+  background: var(--panel);
+  /* Kenney-style chunky button: a solid shadow that it sinks into when pressed. */
+  box-shadow: 0 4px 0 #1e1e27;
   color: var(--ink);
   font: inherit;
-  font-weight: 600;
+  font-weight: 800;
+}
+
+#actions .icon {
+  grid-row: span 2;
+  width: 36px;
+  height: 36px;
+}
+
+#actions button:active:not(:disabled) {
+  transform: translateY(4px);
+  box-shadow: none;
 }
 
 #actions button[aria-pressed='true'] {
   background: var(--accent);
-  border-color: var(--accent);
   color: #fff;
 }
 
 #actions button:disabled {
-  opacity: 0.35;
+  opacity: 0.45;
 }
 
 #actions kbd {
-  display: block;
   font: 11px ui-monospace, monospace;
   color: var(--muted);
 }
 
 #actions button[aria-pressed='true'] kbd {
-  color: #dce8ff;
+  color: #e2fff2;
 }
 
 #banner {
@@ -90,29 +155,47 @@ body {
   z-index: 3;
   display: grid;
   place-content: center;
+  justify-items: center;
   gap: 16px;
   padding: 16px;
   text-align: center;
-  background: rgb(244 239 228 / 0.85);
+  background: rgb(53 53 65 / 0.75);
+  color: #fff;
 }
 
 #banner[hidden] {
   display: none;
 }
 
+#banner img {
+  width: 96px;
+  height: 96px;
+}
+
 #banner p {
   margin: 0;
   font-size: 24px;
-  font-weight: 700;
+  font-weight: 800;
+}
+
+#banner .credit {
+  font-size: 13px;
+  font-weight: 600;
+  opacity: 0.85;
+}
+
+#banner .credit a {
+  color: inherit;
 }
 
 #banner button {
   min-height: 56px;
   padding: 0 32px;
-  border: 0;
+  border: 3px solid var(--ink);
   border-radius: 12px;
   background: var(--accent);
+  box-shadow: 0 4px 0 var(--accent-dark);
   color: #fff;
   font: inherit;
-  font-weight: 700;
+  font-weight: 800;
 }
diff --git a/src/ui/controls.ts b/src/ui/controls.ts
index 2cbd69e..c3be5e9 100644
--- a/src/ui/controls.ts
+++ b/src/ui/controls.ts
@@ -1,5 +1,7 @@
 import { ACTIONS, ACTION_ORDER, isAvailable } from '../sim/actions'
+import { START_LIVES } from '../sim/state'
 import type { ActionId, GameState } from '../sim/types'
+import { spriteUrl } from '../render/sprites'
 
 /** The HTML around the board: HUD, action bar, and the banner shown on death. */
 export interface Handlers {
@@ -10,6 +12,16 @@ export interface Handlers {
 }
 
 const KEYS: Record<ActionId, string> = { move: 'WASD', bomb: 'B', wait: 'Space' }
+const ICONS: Record<ActionId, string> = { move: 'character_green_walk_a', bomb: 'bomb', wait: 'character_green_duck' }
+
+function icon(name: string, className = 'icon'): string {
+  return `<img class="${className}" src="${spriteUrl(name)}" alt="" draggable="false">`
+}
+
+/** A number in the pack's HUD digits. The images are decoration; `label` is what screen readers get. */
+function number(n: number, label: string): string {
+  return `<span class="sr-only">${label}</span>` + [...String(n)].map((d) => icon(`hud_character_${d}`, 'digit')).join('')
+}
 
 export function createControls(handlers: Handlers) {
   const bar = document.getElementById('actions')!
@@ -18,7 +30,7 @@ export function createControls(handlers: Handlers) {
     const b = document.createElement('button')
     b.type = 'button'
     b.dataset.action = id
-    b.innerHTML = `${ACTIONS[id].label}<kbd>${KEYS[id]}</kbd>`
+    b.innerHTML = `${icon(ICONS[id])}<span>${ACTIONS[id].label}</span><kbd>${KEYS[id]}</kbd>`
     b.addEventListener('click', () => handlers.pick(id))
     bar.append(b)
     buttons.set(id, b)
@@ -26,6 +38,7 @@ export function createControls(handlers: Handlers) {
 
   const banner = document.getElementById('banner')!
   const bannerText = document.getElementById('banner-text')!
+  const bannerIcon = document.getElementById('banner-icon') as HTMLImageElement
   const bannerButton = document.getElementById('banner-button') as HTMLButtonElement
   bannerButton.addEventListener('click', () => handlers.banner())
 
@@ -42,18 +55,25 @@ export function createControls(handlers: Handlers) {
         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}`
+      // Lives lost from the starting three show as empty hearts; extra lives add full ones.
+      const hearts = Array.from({ length: Math.max(s.lives, START_LIVES) }, (_, i) =>
+        icon(i < s.lives ? 'hud_heart' : 'hud_heart_empty'),
+      )
+      hud.lives.innerHTML = `<span class="sr-only">${s.lives} lives</span>${hearts.join('')}`
+      hud.level.innerHTML = icon('flag_green_a') + number(s.level, 'Level ')
+      hud.turn.innerHTML = `<small aria-hidden="true">Turn</small>` + number(s.turn, 'Turn ')
+      hud.stats.innerHTML =
+        icon('bomb') + number(s.stats.capacity, 'Bombs ') + icon('fireball') + number(s.stats.fire, ', 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'
+        bannerIcon.src = spriteUrl('character_green_hit')
       } else if (s.status === 'gameOver') {
         bannerText.textContent = `Game over on level ${s.level}. Seed ${s.seed}.`
         bannerButton.textContent = 'New run'
+        bannerIcon.src = spriteUrl('hud_heart_empty')
       }
     },
     /** True if the banner is showing; Enter presses its button. */
diff --git a/vite.config.ts b/vite.config.ts
index 74e70f8..63193ba 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -16,5 +16,7 @@ export default defineConfig({
   // 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' },
+  // Sprites stay separate files rather than being inlined into the script: the art for enemies and
+  // pickups that aren't in the game yet then costs nothing until something draws it.
+  build: { target: 'es2022', assetsInlineLimit: 0 },
 })