fuzzyworld

explodorama / commit

Bushes drawn from Kenney's Foliage Sprites; screenshot tool follows the app base

05e79f5f4bfe62173344c7ccb7f94ea4c20e89f6 · Pavel Safronov · 2026-10-26 23:38:08 -0700 · parents e3d8dc497528

Claude Code usage: this commit ~17.1K out / ~8.5M cached · cost unknown (no per-model trailer) · session 5 tally 300 in / 97.6K out / 20.4M cached · usage by commit and session

Bushes drawn from Kenney's Foliage Sprites; screenshot tool follows the app base

- Bushes are tall grass: four Foliage Sprites shapes, tinted green and outlined by
  tools/compose-icons.sh (grass_a-d; canopy_a-b composed for later), one picked per tile.
- The pack is credited in the menu and the README, with its licence in assets/kenney-foliage/.
- tools/screenshots.mjs serves and opens the build at /explodorama/, the base builds use
  since the fuzzyworld-tools deploy change (the shots timed out at /).
- Screenshots refreshed.

Usage: 0.3K in / 97.6K out / 20.4M cached (session tally)
rtk:   2.5M saved, 55.1% over 5321 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
 CHANGELOG.md                          |   2 ++
 PLAN.md                               |   2 +-
 README.md                             |   7 +++++--
 assets/composed/canopy_a.png          | Bin 0 -> 8759 bytes
 assets/composed/canopy_b.png          | Bin 0 -> 8525 bytes
 assets/composed/grass_a.png           | Bin 0 -> 7267 bytes
 assets/composed/grass_b.png           | Bin 0 -> 8043 bytes
 assets/composed/grass_c.png           | Bin 0 -> 11790 bytes
 assets/composed/grass_d.png           | Bin 0 -> 10856 bytes
 assets/kenney-foliage/License.txt     |  22 ++++++++++++++++++++++
 assets/kenney-foliage/sprite_0052.png | Bin 0 -> 29510 bytes
 assets/kenney-foliage/sprite_0053.png | Bin 0 -> 29155 bytes
 assets/kenney-foliage/sprite_0062.png | Bin 0 -> 30971 bytes
 assets/kenney-foliage/sprite_0063.png | Bin 0 -> 27280 bytes
 assets/kenney-foliage/sprite_0090.png | Bin 0 -> 39312 bytes
 assets/kenney-foliage/sprite_0091.png | Bin 0 -> 39347 bytes
 decisions.md                          |  10 ++++++++++
 docs/screenshots/acid.png             | Bin 225076 -> 225081 bytes
 docs/screenshots/almanac-charges.png  | Bin 481755 -> 597444 bytes
 docs/screenshots/almanac-enemies.png  | Bin 319768 -> 322377 bytes
 docs/screenshots/almanac.png          | Bin 521865 -> 522389 bytes
 docs/screenshots/bomb-picked.png      | Bin 215850 -> 215802 bytes
 docs/screenshots/countdown.png        | Bin 219721 -> 219713 bytes
 docs/screenshots/death.png            | Bin 233723 -> 233987 bytes
 docs/screenshots/demo.png             | Bin 952035 -> 958041 bytes
 docs/screenshots/desktop.png          | Bin 270235 -> 270197 bytes
 docs/screenshots/dev-offer.png        | Bin 218864 -> 202975 bytes
 docs/screenshots/dev.png              | Bin 219379 -> 219408 bytes
 docs/screenshots/enemies.png          | Bin 246353 -> 298830 bytes
 docs/screenshots/gem-offer.png        | Bin 207227 -> 220493 bytes
 docs/screenshots/gem.png              | Bin 200172 -> 200138 bytes
 docs/screenshots/info-hud.png         | Bin 178628 -> 201914 bytes
 docs/screenshots/lingering.png        | Bin 219165 -> 219212 bytes
 docs/screenshots/menu.png             | Bin 150195 -> 154106 bytes
 docs/screenshots/move-picked.png      | Bin 216088 -> 216015 bytes
 docs/screenshots/nuke.png             | Bin 231990 -> 231965 bytes
 docs/screenshots/shop.png             | Bin 209166 -> 200559 bytes
 src/main.ts                           |   4 ++--
 src/render/board.ts                   |   6 +++---
 src/render/sprites.ts                 |  15 +++++++++++++++
 src/ui/info.ts                        |   2 +-
 tools/compose-icons.sh                |  21 +++++++++++++++++++++
 tools/screenshots.mjs                 |  12 +++++++++---
 43 files changed, 91 insertions(+), 12 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 868ab1f..75fafb7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -98,6 +98,8 @@ saved before the change is dropped and the next visit starts a new run.
 
 ### Changed
 
+- **Bushes are tall grass** from Kenney's Foliage Sprites, tinted green and outlined like the rest of the art, in four shapes picked by tile so a clump doesn't repeat one picture. Credited in the menu.
+
 - **The Acid bomb is an area weapon, like the Nuke:** it burns every tile within 2 steps, through crates, and cracks every pillar in that area, diagonal ones too (it used to burn a cross and crack only a pillar an arm of it ran into). Version 40: runs saved before don't resume.
 
 - **Tapping yourself no longer waits** (it could kill you by accident); use the Wait button or Space.
diff --git a/PLAN.md b/PLAN.md
index 295e637..fbb498c 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -1317,7 +1317,7 @@ and all the objects below are wanted.*
 |---|---|---|---|---|
 | **Oil spill** | Floor: anyone can walk on it. | Flame touching any part ignites the **whole connected spill** at once: it burns this turn and lingers next turn like flame, so a spill carries a blast round corners. Then it is gone. | Splat Pack, tinted dark | first |
 | **Oil barrel** | Blocks like a crate. | Flame sets it off **next turn** (a 1-turn fuse, shown as a 1): a blast of reach 2, and it spills oil around it (more chains). | none in the packs — an open question; `block_strong_danger` or `block_exclamation` could stand in | first |
-| **Bush** | Anyone walks through it. | Burns away, and **fire spreads** to touching bushes one turn later — a slow, visible fire front to outrun or to use. | `bush` | first |
+| **Bush** | Anyone walks through it. | Burns away, and **fire spreads** to touching bushes one turn later — a slow, visible fire front to outrun or to use. | `bush`; since 2026-10-26 `grass_a`–`grass_d` (Foliage Sprites, tinted and outlined, one picked per tile) | first |
 | **Metal crate** | Blocks everything, like a pillar but anywhere. | Not hurt (even Pierce stops). Maybe Kick pushes it one tile (to discuss). | `block_strong_empty` | first |
 | **Spikes** | Floor trap, up every other turn (shown, with the turns until it changes). Up, it hurts anyone on it, enemies too; enemies plan around raised spikes. | — | `spikes` / `block_spikes` | first |
 | **Acid puddle** | Hurts anyone who ends a turn in it. Enemies avoid it. A bomb in it is a dud. | Flame dries it up. | Splat Pack, tinted green | second |
diff --git a/README.md b/README.md
index c6c0f93..1ca36a7 100644
--- a/README.md
+++ b/README.md
@@ -574,14 +574,17 @@ Art: [New Platformer Pack](https://kenney.nl/assets/new-platformer-pack) 1.1,
 [Game Icons](https://kenney.nl/assets/game-icons) and
 [Board Game Icons](https://kenney.nl/assets/board-game-icons), all by
 [Kenney](https://www.kenney.nl), under [CC0](http://creativecommons.org/publicdomain/zero/1.0/)
-(public domain), and the [Splat Pack](https://kenney.nl/assets/splat-pack) (the oil spills). Kenney doesn't require credit, but the pack was chosen deliberately and the game
+(public domain), the [Splat Pack](https://kenney.nl/assets/splat-pack) (the oil spills) and
+[Foliage Sprites](https://kenney.nl/assets/foliage-sprites) (the bushes). 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 (in the menu). Thank you, Kenney! Support his work at <https://www.kenney.nl/donate>.
 
 The files used are copied, unchanged and under their original file names, into
 [`assets/kenney/`](assets/kenney/), [`assets/kenney-ui/`](assets/kenney-ui/) (keeping the pack's
 colour folders), [`assets/kenney-ui-rpg/`](assets/kenney-ui-rpg/) and
 [`assets/kenney-splat/`](assets/kenney-splat/) (a few of the Splat Pack's splats, scaled down to
-128 px), each with its pack's licence file. One exception: the demo's pointing hand, `assets/kenney-ui-rpg/cursorHand_beige.svg`,
+128 px) and [`assets/kenney-foliage/`](assets/kenney-foliage/) (six of Foliage Sprites' white
+shapes, which `tools/compose-icons.sh` tints green and outlines into `assets/composed/grass_*` and
+`canopy_*`), each with its pack's licence file. One exception: the demo's pointing hand, `assets/kenney-ui-rpg/cursorHand_beige.svg`,
 is the RPG Expansion's `cursorHand_beige` cut out of the pack's vector sheet
 (`Vector/uipack_rpg_vector.svg`: just its three shapes, unchanged), since the PNG is only 27 px
 across. They
diff --git a/assets/composed/canopy_a.png b/assets/composed/canopy_a.png
new file mode 100644
index 0000000..a1066b4
Binary files /dev/null and b/assets/composed/canopy_a.png differ
diff --git a/assets/composed/canopy_b.png b/assets/composed/canopy_b.png
new file mode 100644
index 0000000..a7e63f1
Binary files /dev/null and b/assets/composed/canopy_b.png differ
diff --git a/assets/composed/grass_a.png b/assets/composed/grass_a.png
new file mode 100644
index 0000000..37f6654
Binary files /dev/null and b/assets/composed/grass_a.png differ
diff --git a/assets/composed/grass_b.png b/assets/composed/grass_b.png
new file mode 100644
index 0000000..e3566f6
Binary files /dev/null and b/assets/composed/grass_b.png differ
diff --git a/assets/composed/grass_c.png b/assets/composed/grass_c.png
new file mode 100644
index 0000000..bfa71c5
Binary files /dev/null and b/assets/composed/grass_c.png differ
diff --git a/assets/composed/grass_d.png b/assets/composed/grass_d.png
new file mode 100644
index 0000000..e4e82c1
Binary files /dev/null and b/assets/composed/grass_d.png differ
diff --git a/assets/kenney-foliage/License.txt b/assets/kenney-foliage/License.txt
new file mode 100644
index 0000000..85e0226
--- /dev/null
+++ b/assets/kenney-foliage/License.txt
@@ -0,0 +1,22 @@
+	

+

+	Foliage Sprites

+

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

+	Creation date: 08-02-2020

+

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

+

+	License: (Creative Commons Zero, CC0)

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

+

+	This content is free to use in personal, educational and commercial projects.


+	Support us by crediting Kenney or www.kenney.nl (this is not mandatory)

+

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

+

+	Donate:   http://support.kenney.nl

+	Patreon:  http://patreon.com/kenney/

+

+	Follow on Twitter for updates:

+	http://twitter.com/KenneyNL
\ No newline at end of file
diff --git a/assets/kenney-foliage/sprite_0052.png b/assets/kenney-foliage/sprite_0052.png
new file mode 100644
index 0000000..0af730f
Binary files /dev/null and b/assets/kenney-foliage/sprite_0052.png differ
diff --git a/assets/kenney-foliage/sprite_0053.png b/assets/kenney-foliage/sprite_0053.png
new file mode 100644
index 0000000..23a5db4
Binary files /dev/null and b/assets/kenney-foliage/sprite_0053.png differ
diff --git a/assets/kenney-foliage/sprite_0062.png b/assets/kenney-foliage/sprite_0062.png
new file mode 100644
index 0000000..8efadb7
Binary files /dev/null and b/assets/kenney-foliage/sprite_0062.png differ
diff --git a/assets/kenney-foliage/sprite_0063.png b/assets/kenney-foliage/sprite_0063.png
new file mode 100644
index 0000000..a90e03f
Binary files /dev/null and b/assets/kenney-foliage/sprite_0063.png differ
diff --git a/assets/kenney-foliage/sprite_0090.png b/assets/kenney-foliage/sprite_0090.png
new file mode 100644
index 0000000..c98ec7d
Binary files /dev/null and b/assets/kenney-foliage/sprite_0090.png differ
diff --git a/assets/kenney-foliage/sprite_0091.png b/assets/kenney-foliage/sprite_0091.png
new file mode 100644
index 0000000..a019580
Binary files /dev/null and b/assets/kenney-foliage/sprite_0091.png differ
diff --git a/decisions.md b/decisions.md
index 61801a7..edeb729 100644
--- a/decisions.md
+++ b/decisions.md
@@ -2241,3 +2241,13 @@ burns every tile within `NUKE_RANGE` (2) steps, through anything, and every pill
 is reached (cracked, or broken if cracked before). It is now a Nuke plus cracks in a 1-bomb charge
 (the Nuke gives 3 bombs); its price (6) is unchanged, to revisit with the balance lab if it proves
 too strong. The Nuke itself still breaks a cracked pillar only along its cross. `SIM_VERSION` 40.
+
+### 2026-10-26 — Bushes drawn from Foliage Sprites
+
+User request (2026-10-26): new bush art. Kenney's **Foliage
+Sprites** (CC0; the user added the pack, 2026-10-26) supply white shaded shapes, which
+`tools/compose-icons.sh` tints green (multiplied, so its shading stays), outlines in the art's
+ink (`#353541`) and sizes to 128 px: four tall-grass tufts (`grass_a`–`grass_d`) and two round
+canopies (`canopy_a`, `canopy_b`, composed but not used yet). A bush's picture is picked from its
+cell index (`bushSprite`), so neighbours differ but a bush never changes picture between frames.
+Credited in the menu and the README with the other packs.
diff --git a/docs/screenshots/acid.png b/docs/screenshots/acid.png
index 76f03b7..05f0b78 100644
Binary files a/docs/screenshots/acid.png and b/docs/screenshots/acid.png differ
diff --git a/docs/screenshots/almanac-charges.png b/docs/screenshots/almanac-charges.png
index ddff9b7..9451b98 100644
Binary files a/docs/screenshots/almanac-charges.png and b/docs/screenshots/almanac-charges.png differ
diff --git a/docs/screenshots/almanac-enemies.png b/docs/screenshots/almanac-enemies.png
index f1d98fd..98ce282 100644
Binary files a/docs/screenshots/almanac-enemies.png and b/docs/screenshots/almanac-enemies.png differ
diff --git a/docs/screenshots/almanac.png b/docs/screenshots/almanac.png
index b036e4b..78d4eee 100644
Binary files a/docs/screenshots/almanac.png and b/docs/screenshots/almanac.png differ
diff --git a/docs/screenshots/bomb-picked.png b/docs/screenshots/bomb-picked.png
index 406f055..018e461 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 cfc73ad..3907931 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 61aa5ab..f6393dc 100644
Binary files a/docs/screenshots/death.png and b/docs/screenshots/death.png differ
diff --git a/docs/screenshots/demo.png b/docs/screenshots/demo.png
index b1be056..92bece6 100644
Binary files a/docs/screenshots/demo.png and b/docs/screenshots/demo.png differ
diff --git a/docs/screenshots/desktop.png b/docs/screenshots/desktop.png
index d3d7f06..43fdf23 100644
Binary files a/docs/screenshots/desktop.png and b/docs/screenshots/desktop.png differ
diff --git a/docs/screenshots/dev-offer.png b/docs/screenshots/dev-offer.png
index b100dd2..899456e 100644
Binary files a/docs/screenshots/dev-offer.png and b/docs/screenshots/dev-offer.png differ
diff --git a/docs/screenshots/dev.png b/docs/screenshots/dev.png
index 9a37ec1..71da21e 100644
Binary files a/docs/screenshots/dev.png and b/docs/screenshots/dev.png differ
diff --git a/docs/screenshots/enemies.png b/docs/screenshots/enemies.png
index 19fcba8..dbb2e7f 100644
Binary files a/docs/screenshots/enemies.png and b/docs/screenshots/enemies.png differ
diff --git a/docs/screenshots/gem-offer.png b/docs/screenshots/gem-offer.png
index 422cd18..a1b832c 100644
Binary files a/docs/screenshots/gem-offer.png and b/docs/screenshots/gem-offer.png differ
diff --git a/docs/screenshots/gem.png b/docs/screenshots/gem.png
index 610f9a3..6f24b9a 100644
Binary files a/docs/screenshots/gem.png and b/docs/screenshots/gem.png differ
diff --git a/docs/screenshots/info-hud.png b/docs/screenshots/info-hud.png
index 66a61b7..265b3ca 100644
Binary files a/docs/screenshots/info-hud.png and b/docs/screenshots/info-hud.png differ
diff --git a/docs/screenshots/lingering.png b/docs/screenshots/lingering.png
index 5a9e8b8..502010c 100644
Binary files a/docs/screenshots/lingering.png and b/docs/screenshots/lingering.png differ
diff --git a/docs/screenshots/menu.png b/docs/screenshots/menu.png
index e29d4eb..1fe5021 100644
Binary files a/docs/screenshots/menu.png and b/docs/screenshots/menu.png differ
diff --git a/docs/screenshots/move-picked.png b/docs/screenshots/move-picked.png
index b128d72..169cc28 100644
Binary files a/docs/screenshots/move-picked.png and b/docs/screenshots/move-picked.png differ
diff --git a/docs/screenshots/nuke.png b/docs/screenshots/nuke.png
index a1b38d3..fba9223 100644
Binary files a/docs/screenshots/nuke.png and b/docs/screenshots/nuke.png differ
diff --git a/docs/screenshots/shop.png b/docs/screenshots/shop.png
index 59dcacc..4efd6a0 100644
Binary files a/docs/screenshots/shop.png and b/docs/screenshots/shop.png differ
diff --git a/src/main.ts b/src/main.ts
index e45c881..66d1c3d 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -486,7 +486,7 @@ function showMenu(): void {
       (modes.length ? `<p class="mode">${modes.length > 1 ? 'Modes' : 'Mode'}: ${modes.join(', ')}.</p>` : '') +
       feedbackChoices() +
       `<p class="credit">Art and sounds by <a href="https://www.kenney.nl" target="_blank" rel="noopener">Kenney</a>: ` +
-      `New Platformer Pack, UI Pack, UI Pack: RPG Expansion, Game Icons, Board Game Icons and Splat Pack (CC0). Thank you!</p>` +
+      `New Platformer Pack, UI Pack, UI Pack: RPG Expansion, Game Icons, Board Game Icons, Splat Pack and Foliage Sprites (CC0). Thank you!</p>` +
       // The version is the rules version: it changes exactly when old saves stop resuming.
       `<p class="credit">Version ${SIM_VERSION}${commitLine()}. ` +
       `<a href="${REPO_URL}/" target="_blank" rel="noopener">Source code</a></p>`,
@@ -913,7 +913,7 @@ const SCENE_ICONS: Record<SceneKind, string> = {
   dieDetonator: POWER_UP_ICONS.detonator, dieRun: POWER_UP_ICONS.run,
   rpg: POWER_UP_ICONS.rpg, proximity: POWER_UP_ICONS.proximity, slide: POWER_UP_ICONS.slide, icbm: CHARGE_ICONS.icbm,
   acid: CHARGE_ICONS.acid,
-  bush: 'bush', bushFire: 'bush', metal: 'block_metal', oil: 'oil', spikes: 'spikes', barrel: 'block_strong_danger',
+  bush: 'grass_a', bushFire: 'grass_a', metal: 'block_metal', oil: 'oil', spikes: 'spikes', barrel: 'block_strong_danger',
 }
 
 
diff --git a/src/render/board.ts b/src/render/board.ts
index 0abea13..0ed05a4 100644
--- a/src/render/board.ts
+++ b/src/render/board.ts
@@ -12,7 +12,7 @@ import {
   appearScale, dropSprite, frostCrack, frostLevel, GONE_SECONDS, isSlide, key, moveProgress, moveSeconds, progress, pulseScale, TELEPORT_SECONDS, WALK_SECONDS,
   type Effects, type Gone, type PlayerMove, type PlayerPose,
 } from './effects'
-import { CHARGE_ICONS, digitSprite, ENEMY_SPRITES, GEM_SPRITES, sprite, wallSprite, type BoardSprite } from './sprites'
+import { bushSprite, CHARGE_ICONS, digitSprite, ENEMY_SPRITES, GEM_SPRITES, sprite, wallSprite, type BoardSprite } from './sprites'
 
 /**
  * Where the board's bottom-left corner is in world space: the origin, except while a dialog's
@@ -331,7 +331,7 @@ export function drawBoard(view: View): void {
   // catches fire next turn.
   for (const i of s.bushes) {
     const smoulder = s.smoldering.includes(i)
-    drawTile(toWorld(s, tileOf(s, i)).add(vec2(0, 0.04)), vec2(1.02), sprite('bush'), smoulder ? C.smoulder.lerp(C.white, 0.5 + 0.5 * Math.sin(time * 6 + i)) : C.white)
+    drawTile(toWorld(s, tileOf(s, i)).add(vec2(0, 0.04)), vec2(1.02), sprite(bushSprite(i)), smoulder ? C.smoulder.lerp(C.white, 0.5 + 0.5 * Math.sin(time * 6 + i)) : C.white)
   }
 
   for (const e of s.enemies) drawEnemy(s, e, view.enemySteps.get(e.id), fx)
@@ -350,7 +350,7 @@ export function drawBoard(view: View): void {
   }
   drawPlayer(s, view.playerMove, view.playerPose, view.facingLeft)
   // Hidden in a bush: leaves over the player too, see-through, so they still know where they are.
-  if (s.bushes.includes(idx(s, s.player)) && at.distance(toWorld(s, s.player)) < 0.01) drawTile(toWorld(s, s.player).add(vec2(0, 0.04)), vec2(1.02), sprite('bush'), C.white.scale(1, 0.55))
+  if (s.bushes.includes(idx(s, s.player)) && at.distance(toWorld(s, s.player)) < 0.01) drawTile(toWorld(s, s.player).add(vec2(0, 0.04)), vec2(1.02), sprite(bushSprite(idx(s, s.player))), C.white.scale(1, 0.55))
 }
 
 /** Where the player is on screen right now: sliding between tiles while walking, otherwise on their tile. */
diff --git a/src/render/sprites.ts b/src/render/sprites.ts
index 230cff3..be14292 100644
--- a/src/render/sprites.ts
+++ b/src/render/sprites.ts
@@ -129,10 +129,25 @@ const BOARD = [
   'spikes',
   'charge_acid',
   'oil',
+  'grass_a',
+  'grass_b',
+  'grass_c',
+  'grass_d',
+  'canopy_a',
+  'canopy_b',
 ] as const
 
 export type BoardSprite = (typeof BOARD)[number]
 
+/**
+ * A bush's picture (user request, 2026-10-26: from Kenney's Foliage Sprites, composed by
+ * tools/compose-icons.sh), one of a few by its tile, so a clump doesn't repeat one picture.
+ */
+const BUSH_SPRITES: readonly BoardSprite[] = ['grass_a', 'grass_b', 'grass_c', 'grass_d']
+export function bushSprite(cell: number): BoardSprite {
+  return BUSH_SPRITES[(cell * 7 + (cell >> 3)) % BUSH_SPRITES.length]!
+}
+
 /** For `engineInit`, which loads them all before the first frame. */
 export const TEXTURES = BOARD.map(spriteUrl)
 
diff --git a/src/ui/info.ts b/src/ui/info.ts
index 7608bd2..544c7f9 100644
--- a/src/ui/info.ts
+++ b/src/ui/info.ts
@@ -269,7 +269,7 @@ export const BOARD_TEXT: Record<BoardThing, { icon: string; name: string; text:
     clip: 'pillar',
   },
   bush: {
-    icon: 'bush',
+    icon: 'grass_a',
     name: 'Bush',
     text: [
       'Walk into it to hide: enemies can’t see you there, and head for where they last saw you instead. Anything lying in it is hidden too; an enemy in one shows faint.',
diff --git a/tools/compose-icons.sh b/tools/compose-icons.sh
index 0528a92..18e85e4 100644
--- a/tools/compose-icons.sh
+++ b/tools/compose-icons.sh
@@ -81,6 +81,27 @@ magick assets/kenney-splat/splat05.png -fill '#2b2631' -colorize 100 \
   \( assets/kenney-splat/splat05.png -alpha extract \) -compose copyopacity -composite PNG32:"$OUT/oil.png"
 echo "  $OUT/oil.png"
 
+# Bushes (user request, 2026-10-26): Foliage Sprites' white shaded shapes, tinted green (its shading
+# kept by multiplying) and given the platformer art's dark outline, at 128 × 128. Tall grass tufts,
+# and round canopies; a few of each, so a clump doesn't repeat one picture.
+FOL=assets/kenney-foliage
+foliage() {
+  magick "$FOL/sprite_00$1.png" -colorspace sRGB -type TrueColorAlpha -resize 112x112 -background none -gravity center -extent 128x128 PNG32:"$TMPF/shape.png"
+  magick "$TMPF/shape.png" \( +clone -fill "$2" -colorize 100 \) -compose multiply -composite \
+    \( "$TMPF/shape.png" -alpha extract \) -compose copyopacity -composite PNG32:"$TMPF/tinted.png"
+  magick "$TMPF/tinted.png" \( +clone -alpha extract -morphology dilate disk:5 -blur 0x0.6 -level 40%,60% \
+    -background '#353541' -alpha shape \) +swap -compose over -composite PNG32:"$OUT/$3.png"
+  echo "  $OUT/$3.png"
+}
+TMPF=$(mktemp -d)
+foliage 52 '#45c060' grass_a
+foliage 53 '#3db457' grass_b
+foliage 62 '#4cc866' grass_c
+foliage 63 '#41b85b' grass_d
+foliage 90 '#45c060' canopy_a
+foliage 91 '#3db457' canopy_b
+rm -r "$TMPF"
+
 # Shield: the Move figure inside an empty ring (the board-game timer at 0).
 magick "$B/timer_0.png" -fill '#353541' -colorize 100 \
   \( "$OUT/icon_move.png" -resize 84x84 \) -gravity center -composite \
diff --git a/tools/screenshots.mjs b/tools/screenshots.mjs
index 501736e..5bcd4b6 100644
--- a/tools/screenshots.mjs
+++ b/tools/screenshots.mjs
@@ -4,11 +4,16 @@
  *
  *   npm run screenshots
  */
-import { mkdirSync } from 'node:fs'
+import { mkdirSync, readFileSync } from 'node:fs'
 import { chromium } from 'playwright'
 import { preview } from 'vite'
 
 const OUT = 'docs/screenshots'
+/**
+ * Where the build is served: its app path on apps.fuzzyworld.net (`fuzzyworld.app` in
+ * package.json), since builds use it as their base (fuzzyworld-tools/vite, 2026-10-26).
+ */
+const APP_PATH = `/${JSON.parse(readFileSync('package.json', 'utf8')).fuzzyworld.app}/`
 const PHONE = { width: 390, height: 844 }
 const DESKTOP = { width: 1280, height: 720 }
 /** Phone-wide and tall enough for a whole almanac list. */
@@ -125,14 +130,15 @@ const SHOTS = [
 ]
 
 mkdirSync(OUT, { recursive: true })
-const server = await preview({ preview: { port: 4179, strictPort: true } })
+// The same base as the build, or the preview serves its assets somewhere else.
+const server = await preview({ base: APP_PATH, preview: { port: 4179, strictPort: true } })
 // Chromium's new headless mode (the full browser), not Playwright's default headless shell: the
 // shell's screenshots of the WebGL canvas show a black strip that isn't in the drawing.
 const browser = await chromium.launch({ channel: 'chromium' })
 try {
   for (const { name, viewport = PHONE, query = '', act, settle = 800 } of SHOTS) {
     const page = await browser.newPage({ viewport, deviceScaleFactor: 2, hasTouch: viewport === PHONE })
-    await page.goto(`http://localhost:4179/?${query.includes('seed=') ? '' : 'seed=1&'}${query}`)
+    await page.goto(`http://localhost:4179${APP_PATH}?${query.includes('seed=') ? '' : 'seed=1&'}${query}`)
     await page.waitForSelector('body > canvas') // the game's, not a clip's
     await page.waitForTimeout(300)
     await act(page)