Decisions
Each design or technical decision, why it was made, and when. Newest last. The full design is in PLAN.md; this file records the reasoning so it isn't lost.
Design (agreed in planning, 2026-10-19)
- Enemies move before bombs explode. Enemies announce their next move (intent arrows), so the player can plan traps. Luck matters less than planning.
- Run is an ability, not a charge — gained like the NES roller skates.
- 3 lives, plus a rogue-like layer of coins and upgrades.
- No undo.
- All actions in PLAN.md §3 are in scope.
- Name: Explodorama. "Bomberman" is a Konami trademark and must never appear in the game.
- Shop purchases last for the current run only.
- Permanent upgrades come from a Workshop between runs (Everspace-style tracks, tier n costs 5 × n coins). Later milestone. Dropped 2026-10-24 (see that day's entry).
- (Dropped 2026-10-24, see that day's entry.) On death the player chooses Keep or Fresh, balanced so each is picked about half the time. See PLAN.md §9.
- Curses only appear after the first shop.
- Reapers and the turn limit come later.
- 50 levels and an ending, with a victory certificate and QR code.
- Icons: Icongeek26 "Outline" from Flaticon, with attribution, until there is final art. LittleJS ships no icons. Replaced 2026-10-20 — see "Art: Kenney's New Platformer Pack" below.
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
Superseded 2026-10-24: WebGL is back (see "WebGL again; Developer mode's gem is a rainbow gem").
setGLEnable(false). Headless Chromium's full-viewport screenshots of LittleJS's WebGL canvas
showed a black strip below the HUD that is not in the drawing (a clipped screenshot of the same
page didn't show it, and Canvas 2D doesn't show it at all). Screenshots are how changes are
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.
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.
2026-10-20 — Enemies, exit and charges: rules settled while building M4/M5
- Bombs can't be placed on an enemy's tile (Bomb and Throw). Otherwise a bomb could sit under an enemy, which the turn rules don't cover.
- Enemies don't block each other and may share a tile. Simpler, and the NES did the same.
- Shield absorbs every hit for the rest of the turn it breaks in, not just the first one, so an enemy step followed by a flame in the same turn doesn't kill through a shield.
- Freeze cancels the move already announced, in the same turn, rather than the one after. The arrows the player sees are what Freeze stops, which is easier to read.
- Kicked bombs stop at enemies as well as at walls, crates and bombs.
- Exit-spawned enemies drop nothing (3 of the level's toughest type), so flaming the exit is only a punishment, never a coin farm.
- Par = 50 + 10 per enemy, bonus 5 coins. A first guess, to tune by playtesting.
?charges=Nis part of the run record (startCharges), since it changes the starting state; replays stay exact.SIM_VERSIONis now 2 (3 after the lingering-flame change below).- Death banner says "Ouch!", not "Boom.", since enemies can kill too now.
2026-10-20 — Touch shortcuts: tap self to wait, hold to bomb, tap far to auto-move
Asked for by the user after playing. All three live in the UI layer (main.ts); the sim only
ever sees ordinary Wait, Bomb and Move actions, so the run record and replays are unchanged.
- Auto-move is one step per press, not a walk that plays out by itself. The game is turn-based and the world moves between steps, so the player keeps the chance to stop. The path ignores enemies (they move), but Go never steps onto one.
- Long press is 450 ms, a common long-press length that doesn't feel slow. It only arms a bomb on a tile next to the player with nothing picked, so it can't clash with the two-tap targeting or with the press-and-slide preview.
2026-10-20 — The other seven action charges
- Fuse ± is one charge with two buttons (Fuse +1, Fuse −1) rather than one button that asks
which way afterwards: the action log stays a plain
{ id, target }, and the choice is visible up front. Timers go from 1 to 9. - Pierce flame still stops at bombs (setting them off), like any flame; it only ignores crates.
- Power bomb fire = the level's width, so it always reaches the walls; crates stop it.
- Line places bombs one per tile from the tile picked, until capacity or a blocked tile ends the row. With the starting capacity of 1 it is just a bomb; it gets better with Bomb+.
- Decoy blocks nothing and only Seekers chase (the other types don't target the player). It lasts 3 enemy plannings. One at a time.
- Teleport is rare by drop weight (½ of each other charge), as the plan asked.
- Keys:
=/−Fuse, G Diagonal (then Q E Z C), P Teleport, I Pierce, O Power, L Line, Y Decoy.
2026-10-20 — Tapping the auto-move flag steps toward it
The user asked for the flag itself to act like Go, so the thumb can stay on the board. That took over "tap the flag again to clear it", so clearing moved to tapping a wall or crate (a tap that otherwise does nothing), besides Esc and the existing automatic cases.
Testing the flag tap in a browser walked the player straight into a Blob's announced step. So auto-move now refuses any step it knows is deadly — onto an enemy, onto a tile an enemy's arrow points at, or into flame this turn — and shows that next step in red. Manual moves are not restricted: the player can still choose a risky step on purpose.
2026-10-20 — Lingering flame, and enemies planning 3 moves ahead
The user found kills too hard: flame lasted one turn, and an enemy had to be on the exact tile at that moment. The cause: bombs take 3 turns, but enemies only showed 1 move, and Blobs keep their direction only 75% of the time, so a 3-turn guess was right about 42% of the time. Agreed fix, two parts:
- Flame lingers one extra turn (option 1 of the ones offered). It is the turn-based version of the NES flames staying on screen briefly, which is where most early kills come from there. Lingering flame kills creatures (enemies, player, decoy) but not crates, drops, the exit or bombs, so the drops a kill leaves survive. The player can't enter it, since walking into fire you can see would only ever be a mistake. Changes the PLAN.md §1 rule "flame is instantaneous".
- Enemies commit to 3 moves and show them (instead of option 3, making Blobs steadier): the user asked for the next 2–3 moves to be shown, stacked vertically, future ones fading. First drawn above the enemy's head on a white backing; the user found that blocky and spilling into the tile above, so they are now see-through arrows down the right side of the enemy's own tile, with the enemy drawn a little smaller and to the left to make room (the user briefly tried full-size enemies under the arrows, then chose the smaller enemy). Each arrow is one polygon, so it has no darker band where shaft and head would overlap. The moves are a real plan, not a forecast: the enemy makes exactly those moves unless the board blocks one, so the player can plan a 3-turn bomb against them. Moves a newly placed bomb blocks are dropped and re-planned in phase 5; the blocked-this-turn rule for the next move is unchanged. Layout chosen by the user over a chain of arrows along the path.
- Auto-move's safety check uses each enemy's next planned move.
- Starting fire stays at 1, so Fire+ pickups (M6) still have room to matter.
2026-10-20 — M6 part 1: power-ups, the ending, mid-run save
- Interim death rule until Keep / Fresh: a death keeps stats, Skates, coins and charges and loses Crate Pass, Bomb Pass, Flame Pass, the Detonator and a Star — the NES rule plus "Keep". Keep / Fresh (PLAN.md §9) will replace the stats/Skates part.
- The power-up is under any crate but the exit's, including near the start, so an early Bomb+ or Fire+ is possible. Skates are on level 1 or 2, chosen by the seed, and not in the random draw anywhere else (so there is never a useless second pair).
- A flamed power-up spawns the same punishment as a flamed exit (3 of the level's toughest type, no drops). Lingering flame doesn't destroy it, like drops.
- Run collects what is on the tile it passes through (coins, drops, the power-up), since running past a coin without taking it would feel like a bug. Running into an enemy there kills.
- Star and Flame Pass: Star blocks every hit for 8 turns (counting the one it is taken in); Flame Pass blocks flame only. Neither kills enemies on contact.
- Save = the run record in
localStorage, replayed on load. It is a few KB even for a long run, and replay is exact because the sim is deterministic. Test URLs neither load nor save. - Ending: clearing level 50 sets status
won; the banner shows total turns, lives lost and the seed. The victory certificate is M8. SIM_VERSIONis now 4: older saved runs are not resumed (they would replay differently).
2026-10-21 — Playtest feedback: flag, new run, info dialogs, Kenney UI packs
- The auto-move flag clears when the player moves off its path. Any player move (move, run, diagonal, teleport) that doesn't land on a tile of the current path means "changed my mind". Bombs, waits and charges don't clear it.
- New run lives in a menu, behind a confirmation, since a run can't be got back. The menu button is in the top bar; Esc opens it when there is nothing to cancel.
- Tapping anything that isn't an action explains it. One native
<dialog>(focus trap, Esc to close) for info, the menu and confirmations. Enemies, bombs and burning tiles always explain rather than act, even next to the player: a tap on an enemy used to walk into it, which was never what anyone meant. Walls and crates keep clearing the flag when there is one (that tap did nothing else), otherwise explain. Things you can walk to offer "Walk there". - UI art from Kenney's UI Pack 2.0 and UI Pack: RPG Expansion (the user added both, CC0),
as CSS 9-slice
border-images so the panels and buttons stretch to any text. Credited in the README and the menu like the platformer pack. The action bar keeps its CSS-drawn buttons for now; they already follow the same look.
2026-10-21 — Board sizes, Developer / God / crumbling-crates modes, player animation
Decisions made with the user:
- Small board = 9 × 11, 20 levels, fewer enemies (2 → 5) and crates (25%). 9 × 11 keeps the NES pillar pattern and fits a 390-wide phone at about 43 px a tile.
- Developer and God are separate checkboxes (the user split them mid-way): Developer gives everything (every ability and pass, unlimited charges, 5 bombs, blast 5) but stays killable, "mainly to test out all the possible actions and power-ups"; God makes the player unkillable. They combine.
- Crumbling crates: each crate 1–3 turns, seeded, once the last enemy dies. Not flame, so nothing is punished.
- Options are chosen in a New-run dialog and remembered for next time; not asked on the very first launch.
Made by me:
- Options are part of the run record and state (
SIM_VERSION5), so replays and saves stay exact; older saves aren't resumed. - Developer charges are 99 that are never spent, shown as ∞, rather than a special case in every check.
- The new-run dialog replaces the separate "Start a new run?" confirmation: it carries the warning itself when a run is in progress.
- Player animation is drawn, not simulated: the sim still moves the player instantly; the
renderer slides the sprite over 0.16 s on walking frames, crouches for a bomb, jumps on a
cleared level, bobs and glances at the camera when idle, shakes in the hit pose. Teleports
just appear. Enemies got the same hop afterwards (user request): each
enemyMovedevent slides that enemy over the same 0.16 s.
2026-10-21 — Playtest feedback: credit, wall info, drops, enemy hops
- The Kenney credit is only in the menu (the user's "settings / about" screen), no longer on the death / game-over banner.
- Wall info shows the exact tile: the stone outer wall, or the pillar in this level's colour, titled "Outer wall" or "Pillar".
- Drops never share a tile. A drop whose tile is taken (a drop, a bomb, the uncovered
power-up) rolls to the nearest free floor tile within 2 steps, searching over floor only (not
through walls or crates), else stays. It changes where drops land, so
SIM_VERSIONis 6 (a run saved while testing version 5 would not replay the same). - Enemies hop between tiles like the player.
- Enemies sharing a tile is unchanged for now: the NES original let enemies pass through each other (from memory, not verified). Listed as open question 10 in PLAN.md §14.
- Exclusive power-ups are written up as an idea with its conflicts (PLAN.md §5b), not built.
2026-10-22 — The outer wall gets borders
The user found the walls too uniform and asked for the pack's bordered wall tiles.
- The outer wall is a stone frame: the edge, corner and strip pieces of the pack's
terrain_stoneset, so a dark outline runs along both sides of the ring (towards the board and towards the screen edge) with rounded outer corners. Top and bottom strips show the pack's dark ledge on their upper side, as in the pack.terrain_stone_block_centeris no longer used and was removed fromassets/kenney/. - Inner corners are drawn squares: the pack has no inner-corner piece, so where two sides of the frame meet the outline would have a gap; a square in the outline colour fills it.
- Pillars are unchanged: the coloured blocks already have their own outline.
- Floors are drawn before walls (render only): each tile is drawn a hair oversized to hide seams, and a floor drawn after its neighbouring wall painted over that wall's edge, leaving thin light lines through the stone.
2026-10-22 — "What is that?" moves behind a ? button
The user asked for info to need a dedicated button first, so a plain tap on the board either does nothing or starts walking.
- A plain tap: the player = Wait; the flag = one step; a tile next to the player = move; any other tile the player can walk to (now including the exit, the power-up and drops) = set the auto-move flag; a wall or crate = clear the flag if there is one (kept from 2026-10-21, since that was how the flag gets cleared by touch); an enemy, a bomb or burning flame = nothing.
- The ? button sits in the action bar, beside Wait, not in the top bar next to the menu: the top bar's second row on a 390 px phone had no room for a second round button, and the menu wrapped onto a third row, shrinking the board. In the action bar it is picked like an action (green while on, key ?), which is what it is: pick, then tap a tile. Move / Bomb / Wait give up about 16 px each for it.
- One tap, then off, whatever the tap hit (a plain floor tile has nothing to explain, so it just turns the mode off). Picking an action, the ? again, or Esc also turns it off; turning it on drops any picked action. While it is on, a long press doesn't arm a bomb.
- Its icon is the Game Icons pack's
question(the user added the pack, CC0), not a "?" typed in the Kenney Future font, which read poorly. Credited in the README and the menu. - No key hints on phones (user request): at 600 px wide or less, the action buttons drop their WASD / B / Space / ? labels (the charge buttons already had), and Move / Bomb / Wait put icon and name side by side. Wider screens keep the hints.
- Top-bar items keep their plain-tap info: they are not on the board and do nothing else.
2026-10-22 — Auto-move walks by itself once the enemies are gone, and runs with Skates
User request, before committing the ? button.
- No enemies left: one tap is enough. A set flag is walked to automatically, one step
AUTO_STEP_SECONDS(1 s) after the last action — so the first step comes at once if you haven't just acted. Each step is still an ordinary turn (bombs tick, crates crumble) and is recorded as usual. It pauses while an action is picked, "What is that?" is on or a dialog is open. With enemies on the board nothing changes: Go / the flag, one press per step. - An unsafe next step just waits, as for Go: auto-move never takes it, and nothing changes until you act (flame from last turn only goes out after your next action), so you Wait or step aside and the walk carries on.
- With Skates, auto-move runs whenever the next two tiles of the way go straight on and Run is allowed there, if both tiles pass the same safety check as a single step. This applies to every auto-move step: Go, tapping the flag, and the automatic walk.
- Checked by driving the game in a browser (seed 1, enemies removed through a temporary hook
that was taken out again), since this lives in
main.ts, outside the tested sim.
2026-10-22 — Everything on the board is animated
The user asked that nothing just appear, disappear or jump any more (enemies vanished, kicked bombs teleported), with flame fading out over about half a second.
render/effects.tsturns each turn's events, plus the state before and after, into short animations;board.tsdraws them. The sim is unchanged (no new events,SIM_VERSIONstays).- What animates, and how:
- appearing (≈0.22 s, a pop with a slight overshoot): placed bombs (Line's one after another), drops, enemies spawned from a burned exit or power-up, the decoy, a revealed exit or power-up, a new shield ring;
- moving: a kicked bomb slides (0.05 s a tile), a thrown bomb flies in an arc (0.25 s), a teleport shrinks away and grows back (0.15 s each);
- changing: a bomb swells as its number goes down or a Fuse charge changes it; the exit swells as it opens; Freeze flashes the enemies icy blue (0.6 s);
- going away: an enemy flips over with its eyes shut, hops and falls away fading (0.5 s); a crate swells, tips and fades (0.4 s), over whatever it uncovered; flame from last turn fades as it goes out (0.5 s, at the start of that turn); a drop or power-up taken rises and fades (0.4 s, starting as the player arrives); one burned, or the decoy, shrinks and spins away; a broken shield ring widens and fades.
- the banner comes in after 0.35 s, so the death or level-cleared jump is seen first; dialogs pop up over a fading backdrop. Both are off with "reduce motion".
- Follow-up (user request, same day): the top bar's numbers and the dialog closing. A
top-bar item that changed (a number, a heart, a pass arriving) is replaced and bumps (scales
to 1.3 and back in 0.3 s); unchanged items are kept as they are, so a bump isn't cut short by
the next update. Dialogs close with a 0.15 s shrink-and-fade — buttons, the close button, the
backdrop and Esc alike — and a button's action runs once it has closed. Fixed 2026-10-22
(user report: hearts, flag and enemies pulsed every turn): the kept items are re-attached on
every update, and one still carrying the
bumpclass played it again each time, so anything that had changed once kept pulsing. The class now comes off when the bump ends (animationend). The turn counter still bumps every turn, because it changes every turn. - No input lock (PLAN.md planned
animate.tsto own one). The game is turn-based and the sim has already moved on, so a quick next tap simply starts the next turn's animations over the end of the last ones; locking input would only slow play down. - One death animation for every enemy rather than the Blob's spare
_flatsprite, since the fly and saw have no flat frame. - Screenshots wait 0.8 s after their action (was 0.25 s), so they show the settled board and the banner.
- Checked by capturing frame bursts in a browser (seed 1, Developer mode, a temporary hook to act directly and to put an enemy in a blast, removed afterwards): bomb pop, kick slide, throw arc, shield ring, crate burst, enemy death, flame fading, teleport.
2026-10-22 — Fewer action buttons; Kick, Throw and Teleport become abilities
The user found there were too many action buttons and the "?" odd among them. Asked first: charges or permanent, kick on a plain tap, how far to fold, where to explain.
- "?" moves to the top bar, a round button beside the menu. The two are stacked in a column at the right of the top bar on a phone (beside its two rows), side by side on a wide screen — in a row with the items, the menu would have wrapped onto a third row.
- Icons only. The names are in the explanation strip, the tooltip and the screen-reader label. Key hints stay on wide screens.
- Upgrades widen Move and Bomb instead of adding buttons (
sim/picks.ts). Move: step, kick (walking into a bomb), run (Skates), teleport, diagonal step (a charge). Bomb: place, throw. The tile tapped decides. Preference order: a kick before walking onto the bomb with Bomb Pass (the user's "walking into a bomb kicks it"; a bomb with no room to slide is still walked over), a plain step before a run, anything free before spending a charge. - Kick, Throw and Teleport are permanent for the run (user's choice, over "spend a charge"
or "for the level"). Made by me: they are power-ups now, hidden under crates like Skates
(draw weights Kick 8, Throw 8, Teleport 3), not enemy drops — permanent things are power-ups
in this game, and as drops every run would have collected all three within a few levels.
Kept on death, like Skates ("permanent for the run"). They no longer drop from enemies;
the remaining 8 charges are equally likely (Teleport's half weight went with it).
SIM_VERSION7: saves from before don't resume. - Kick on a plain tap, always (user's choice): tapping a bomb next to you, or an arrow key into it, kicks it once you have Kick.
- Folded too (user's choice): Diagonal, Pierce, Power, Line. They stay one-use charges (the user chose "permanent" only for Kick, Throw and Teleport). Diagonal steps are Move targets while you hold one; Pierce, Power and Line are chips in Bomb's strip, each with its count. Fuse +1 and −1 became one Fuse button with +1 / −1 chips. Shield, Freeze, Decoy and Detonate keep icon buttons, in a second row of up to five.
- The explanation strip (user's choice: above the action bar) sits over the board's bottom edge, so the board doesn't move when it appears. Superseded 2026-10-23: it must not cover the board; see "The strip gets its own band". It lists only the upgrades held, so picking an action teaches what it can do now. Shield and Freeze, which happen at once, show theirs for 2.5 s after use; Wait shows none (it is pressed every few turns) — "?" then Wait explains it, as "?" then any button does.
- Not yet: small upgrade badges on Move and Bomb (the user: "that can wait").
- Checked in a browser (Developer mode, a temporary read-only hook, removed): Bomb's strip; a tap 2 away throws; Move teleports 3 tiles; the Pierce chip places a Pierce bomb; the Fuse −1 chip; "?" then Wait explains Wait; Shield's strip after use; an arrow into a bomb kicks it (found walking over it with Bomb Pass, hence the kick-first order).
2026-10-22 — Playtest: Detonator by tapping, Use to confirm, no Fuse charge, a real Freeze
- On a phone the menu sits above "?" (
column-reverse); side by side on a wide screen, as the user liked it. - The Detonator works by tapping a bomb (user request): while you have it, every bomb pulses with a red glow, and a tap on one sets it off this turn. Its button is gone; Bomb's group now includes Detonate too. A plain tap on a bomb next to you detonates rather than kicks (user's choice): you still kick by walking into it — an arrow key, or Move picked and then the bomb.
- Nothing but Wait goes off from its button (user request: Shield went off at once). Shield and Freeze open the strip with a Use chip, like Bomb's choices; Enter confirms too; pressing the button again cancels. Wait stays one tap (user's choice: it spends nothing and is pressed every few turns). Decoy already wanted a tile. The 2.5 s after-use explanation went with it.
- The Fuse ±1 charge is removed (user's choice). Asked whether it was in the original: no — the 1985 game had fixed fuses and no fuse power-up at all; both Fuse +1 (the power-up, kept) and Fuse ±1 (the charge) were this game's additions. 7 charges remain, equally likely.
- Fire is walkable while it can't hurt you (user report: with Flame Pass, tapping the burning
tiles round them did nothing, while the Move button worked). The tap rule refused burning tiles
outright; now it,
playerCanEnterand auto-move's safety check all go byflameSafe: Flame Pass, a Star or God mode. A Star now lets you step into fire too (it couldn't before). - Freeze lasts this turn and the next 3 (user request: one skipped move just wasted the
turn).
frozenis now a count. Auto-move ignores frozen enemies' arrows. Drawn with LittleJS's colour tinting (the renderer is Canvas 2D, so no shaders: multiply towards ice blue plus an added cold glow), a see-through ice block with a rim and a glint, and twinkling sparkles; it thaws a step each turn over 0.5 s, with a little crack-shake, and the arrows go faint and icy until it is over. SIM_VERSIONstays 7: the previous change hasn't been committed or played anywhere yet.- Checked in a browser (Developer mode, temporary hook, removed): Shield waits for Use; Freeze holds the nearest enemy in place for 4 turns (frozen 3, 2, 1, 0) and then it moves, with frames of the ice thawing; a bomb pulses and a tap detonates it; with Flame Pass a tap on a burning tile next to you walks into it.
2026-10-22 — A dialog when a power-up is picked up
Superseded 2026-10-23 by the gem's choice dialog (see "Gems: choose 1 of 5 power-ups").
User request. Taking a power-up opens the info dialog for it ("You picked it up!" and what it does, from the same texts as "What is that?"). It opens 0.45 s after the step, so the walk and the pickup's rise-away animation are seen first; auto-move pauses while it is open, as for any dialog. Every power-up gets one, Heart and Coin bag included. Charge and coin drops don't: they are frequent, and their buttons and the top bar show them. Checked in a browser (a Kick power-up placed next to the player through a temporary hook, removed): closed at 0.2 s, open with "Kick" by 0.9 s, the ability gained. Partly superseded the same day for charges: see the next entry.
2026-10-22 — A dialog for the first charge of each kind in a run
User request, after picking up a Shield on level 1 and getting no explanation: a new button appeared with nothing to say what it was. Now the first pickup of each kind of action charge in a run opens a dialog ("Shield charge", "You picked it up!", what it does, and that each charge is used once and takes the turn), at the same 0.45 s delay as a power-up's. Later pickups of that kind show nothing, which keeps the reason above (charges are frequent). Coins still get no dialog.
"In this run" was chosen over "ever, in this browser": a new run shows them again. The kinds
seen are kept in main.ts, not in the sim or the save: after a reload they are rebuilt from the
saved run's replay (replay now takes an onEvent callback), so the save format and
SIM_VERSION are unchanged. A death doesn't reset them. Checked in a browser (drops placed next
to the player through a temporary hook, removed): first Shield opens the dialog by 0.9 s, a
second Shield doesn't, then a first Freeze does.
2026-10-22 — The almanac
User request: to see every power-up and enemy (some sprites are to be replaced). Menu →
Almanac: one dialog with three tabs — Power-ups (all 14, in reading order: stats, abilities,
passes, the rest), Charges (all 7) and Enemies (the 3 built, each with the level it first
appears on). Each entry is the sprite plus the same words as "What is that?", so there is one
text per thing. Tabs swap the list without closing the dialog. Sprite file names aren't shown in
the game; for picking replacements, labelled sheets of every sprite (name and file) were made
outside the repo. Screenshots: almanac, almanac-enemies.
2026-10-22 — Almanac scrolling; badged power-up icons
- The almanac didn't scroll with a wheel or trackpad: LittleJS cancels every wheel event on
the page. The dialog now stops wheel events from reaching it (
dialog.ts), so long dialogs scroll. Its scroll bar is hidden (the user saw scroll bars on desktop); the cut-off last entry shows there is more. Measured on a 1280 × 720 window: a wheel turn scrolled 300 px, where it scrolled 0 before. - Almanac screenshots show whole lists: one per tab (
almanac,almanac-charges,almanac-enemies), taken in a 390 × 2100 window. - Power-ups that improve something you have show that thing plus a modifier badge (user
request, with the icons named by the user): Bomb +1 bomb + plus, Fire +1 bomb + expanding
arrows, Fuse +1 bomb + a three-quarter timer (from Board Game Icons, which the user added),
Skates the walking astronaut + fast-forward, Kick bomb + arrow right, Throw bomb + arrow
up-right. Made by me: the badge's look (green disc, dark rim, white symbol, 56 px of 128, top
right), and building the icons ahead of time with a script (
tools/compose-icons.sh) rather than drawing two sprites wherever an icon appears — the board, the top bar, dialogs and the almanac all take one picture. The replaced sprites (four gems,spring_out,sign_right) are no longer used and were removed fromassets/kenney/. - Watch for: a bomb-shaped power-up lying on the floor could be mistaken for a real bomb, and the Skates icon for the player. Both sit on the yellow power-up glow, carry the badge, bob, and the bombs have no number; not changed until the user has seen them in play.
- Detonator's text was stale ("pick one of your bombs"): now "your bombs pulse: tap one".
2026-10-22 — Move and Wait get their own icons
User request: the astronaut on the Move button looked like the player. Move is now Board Game
Icons' character (a game-piece figure) and Wait its character_remove (the figure with an ✕:
stand still). Those icons are white, so tools/compose-icons.sh makes ink-coloured copies
(icon_move, icon_wait), and CSS turns them white again on a picked (green) button and in the
dark explanation strip. Skates was defined as "the Move icon plus fast-forward", so its badged
icon now uses the new figure too (made by me, for consistency).
2026-10-22 — Badged icons for the rest of the abilities and passes
User request, symbols named by the user: Teleport = the Move figure + an outbox (Game Icons
export), Detonator = bomb + exclamation, Crate Pass = crate (block_planks) + cross, Bomb
Pass = bomb + shield (Board Game Icons shield), Flame Pass = fireball + shield. Built by
tools/compose-icons.sh like the first six. bush, ladder_top, torch_off and window are
no longer used and were removed; lever stays (the "Run settings" info icon). Star, Heart and
Coin bag keep their plain sprites.
2026-10-22 — The fuse shows the timer, not a torch
User request: the lit torch beside the fuse number in the top bar (and on its info dialog) was
unclear. It is now the three-quarter timer of the Fuse +1 badge (Board Game Icons
timer_CW_75), made ink-coloured by tools/compose-icons.sh (icon_fuse) and turned white on
the dark top bar. torch_on_a is no longer used and was removed.
Also: the top bar's bombs, blast and fuse info still named the old gems ("Bomb +1 (blue gem)
adds one"); they now say "The Bomb +1 power-up adds one" and so on.
2026-10-22 — Wait is an hourglass
User request: the Wait button shows Board Game Icons' hourglass instead of the figure with an
✕. Made ink-coloured like the others (icon_wait), white on a dark or green background.
character_remove is no longer used and was removed from assets/kenney-board-icons/.
2026-10-22 — "Skates" is called "Run"
User request. The power-up that lets Move go 2 tiles straight is now Run everywhere: on
screen, in the almanac, the docs, and the code (the power-up's id skates → run, sprite
powerup_run, skatesLevel → runLevel). The seed hash that picks level 1 or 2 for it keeps
its old key 'skates', so every seed still has it on the same level. Earlier entries here keep
the old name, as they were written.
2026-10-23 — Auto-move never crosses the open exit
User report: the dotted way to a power-up went over the open exit, so walking it ended the
level without the power-up. playerPath now treats the open exit as blocked unless it is the
destination. If the only way somewhere is over it, there is no way (the flag can't be set): ending
the level by surprise is worse. A locked exit is still crossed, since stepping on it does
nothing; the way is worked out again every turn, so it goes round the exit as soon as it opens.
2026-10-23 — The strip gets its own band
Superseded the same day: the strip is gone (see "No explanation strip during play").
User report: with the player on the bottom row, the explanation strip covered that row, so it couldn't be tapped. The user's rule: the strip must not cover any of the board. Of the two ways to do that, the user chose a band that is always kept free (over a board that shrinks only while the strip is open). The strip is now part of the action bar, at its top; the board is fitted above the whole bar, as before.
How tall: the tallest strip that could open now (every action that can be picked, plus the one
open), measured off-screen (reserve in ui/controls.ts). Measured on a phone: 103 px with
nothing collected, 181 px holding Pierce / Power / Line (Bomb's choices), 264 px in Developer
mode; on a desktop about 105 px. Reserving the Developer-mode maximum always would have cost a
phone about a third of the board, so the band follows what the player holds: the board resizes
when an upgrade or a charge is gained or spent, never when an action is picked. It is measured
again on a window resize. The fade-in now plays only when the strip's content changes.
2026-10-23 — Gems: choose 1 of 5 power-ups
User request: instead of a fixed power-up, every level's pickup is a gem of random colour, and taking it shows a choice of power-ups. User's answers on the open points:
- 5 offered (
OFFER_SIZE), all different, drawn with today's rarities (POWER_UP_WEIGHTS, now insim/turn.ts), listed in the almanac's order. Abilities and passes already held are left out (they would change nothing); if fewer than 5 remain, fewer are offered. - No Run guarantee any more: Run is in the draw with weight 10 (it had none, being
guaranteed on level 1 or 2).
runLevelis gone. - Choosing takes no turn and can't be put off: the dialog has no close button, and Esc and
the backdrop don't close it (
requiredinui/dialog.ts). The world doesn't move meanwhile.
How it works: taking the gem sets state.offer (the event powerUpTaken carries it); while it
is set, every action is unavailable except { id: 'choose', kind }, which applies the power-up
and clears the offer without a turn passing. The choice is an action in the run record, so it
replays; a reload with a choice open shows it again. The offer comes from the seed and level, so
a retry after a death offers the same, and dying can't reroll it (unless the death cost a pass,
which may then be offered). A death on the pickup turn drops the offer, and the retry has the gem
back; reaching the exit on the same step (a Run over the gem) still needs the choice before the
next level. A Star chosen this way lasts its full 8 turns (taken mid-turn it had lost one).
The colour is only how it looks: gem_blue / gem_green / gem_red / gem_yellow from the New
Platformer Pack (the gems were power-up sprites until 2026-10-22). The old "You picked it up!"
dialog is gone; the choice already says what each does. SIM_VERSION 8 (9 since the shelter rule below): saved runs from before
are dropped (the record's meaning changed). Level layouts are unchanged: the colour uses the
level stream's last draw, where the power-up's kind used to be.
Checked in a browser (a gem placed next to the player through a temporary hook, removed): the dialog opens with 5 choices and no close button, stays open on Esc and on a backdrop tap, Wait is refused meanwhile, choosing Fire +1 raised the blast from 1 to 2 with the turn unchanged, and play went on. All 5 choices fit without scrolling on a 1280 × 720 desktop and a 390 × 844 phone.
2026-10-23 — A short exit doorway
User request: the exit (the pack's two-tile door squeezed into one tile) was too tall. It is now
only the door's arched top piece (door_closed_top / door_open_top), at full tile size: a
short doorway, orange while locked, dark once open. The bottom halves are no longer used and
were removed from assets/kenney/.
2026-10-23 — No explanation strip during play
User feedback on the band above: "the text box keeps resizing, that's definitely not going to
work" (the band followed what the player held, so the board changed size as charges came and
went). Since power-ups are now explained in the gem's choice and charges on their first pickup,
the user chose to show no explanation text during play at all. "?" then a button still
explains it (actionInfo).
The strip also held two controls, which needed a new home (user's choices):
- Pierce, Power and Line get their own buttons in the second row, like Shield, Freeze and Decoy, shown while held, with a count: tap one, then a tile. Bomb no longer stands for them.
- Shield and Freeze are used with a second tap on their button (or Enter); the first tap only picks them (green), and anything else cancels. Still never a single accidental tap.
The second row now holds up to six buttons, so its buttons are a sixth wide (they were a fifth): all six fit in one row even on a 320 px phone, so a third row never comes and goes. The row itself still appears with the first charge held and goes with the last one, as before. Checked in a browser with every charge held (390, 360 and 320 px wide): the bar didn't move while picking Move, Bomb, Pierce, Line and Decoy or after using charges; one tap on Shield only picked it and the second used it; Freeze with Enter; a Pierce bomb placed with its button and an arrow key. The charge texts no longer say "a choice under Bomb", and the first-pickup dialog and "?" say how each charge is used.
2026-10-23 — Auto-move's way is blue tiles, with no flag
User request: no flag on the destination; highlight the way's tiles blue. Every tile of the way, the destination included, gets the same blue as a picked action's targets (the dots and the flag sprite are gone). The next step still turns red when it isn't safe. Tapping the destination still takes a step, as tapping the flag did. The Go button kept its flag icon at first; see the next entry.
2026-10-23 — Enemies let out by flame are sheltered from it
User report: a bomb on the uncovered (locked) exit let out 3 enemies, and the remaining fire killed them at once. Why: they appear on the exit tile at the end of the turn, and the blast lingers one more turn (since 2026-10-20), so the next turn they stand or step inside it and die — the NES punishment almost never bit. The same held for a flamed gem.
User's choice (of: shelter them from that flame; let them out a turn later; immune to all flame
for a turn; put them outside the blast): they can't be hurt by the flame that let them out,
but a new explosion kills them as usual. Enemy.sheltered is set when they appear; on the next
turn only new flame counts against them, then it is cleared. Tests check both halves, and that
the first one fails without the rule. SIM_VERSION 9.
2026-10-23 — Developer mode starts each level next to the gem
User request. In Developer mode the level's gem lies uncovered right of the start (a tile that is always open floor), every level and after every death, to try the choice without hunting for it. The crate that hid it stays a crate. Normal runs are unchanged.
2026-10-23 — Screenshots regenerated
User request, after today's changes. power-up / run became gem (seed 55's gem uncovered)
and gem-offer (its choice); charges now shows the six charge buttons with Pierce picked (Bomb's
choices in the strip are gone); comments in tools/screenshots.mjs updated for the blue way and
the missing strip. All 26 were taken again with npm run screenshots.
2026-10-23 — No glow behind the gem
User request: the yellow circle drawn behind an uncovered gem is gone; the gem is drawn on its own, still bobbing gently. (The glow dates from when the pickup was a power-up icon that needed to stand out from drops; a gem is distinct enough.)
2026-10-23 — Developer mode is only the gem
User request: Developer mode no longer gives every ability and pass, 5 bombs, blast 5 and
unlimited charges; it only puts the level's gem uncovered right of the start (see "Developer
mode starts each level next to the gem"). Gone with it: DEV_STATS, DEV_CHARGES, charges never
being spent, and the ∞ badges and "(unlimited)" labels. Its description in the New run dialog,
the top bar's mode tag and the docs say so. The move-picked screenshot, which used Developer
mode to show every Move upgrade, now shows a plain Move. Open question 5 of PLAN.md §5b (Developer
mode bypassing slots) no longer applies. SIM_VERSION 10, since a run saved with the option on
would replay differently.
2026-10-23 — Go keeps the walk icon
User request: while walking to a destination, the Move button (Go) no longer turns into a green
flag; it keeps Move's walk icon. It still gets its green border, so it shows that a press walks
a step, and its name for screen readers and the tooltip is still "Go". flag_green_a is now only
the level's icon (top bar, level-cleared banner).
2026-10-23 — Charges work on pickup, as temporary changes
User request: charges are no longer actions but temporary changes, applied the moment they are picked up. The new set and icons are the user's; the open points were the user's choices too:
| Charge | Effect | Icon |
|---|---|---|
| Pierce | next 3 bombs' flame goes through crates | Crate Pass icon |
| Power | next 3 bombs reach to the walls | Fire+ icon |
| Nuke (new) | next 3 bombs also burn every tile within 2 steps (Manhattan), through anything but walls | bomb + Board Game Icons skull badge |
| Diagonal | diagonal steps for the rest of this level and all of the next ("round" = level) | Move figure + diagonal-arrows badge |
| Shield | the next hit is absorbed, then it's gone | Move figure in the empty ring timer_0 |
| Freeze | every enemy frozen for 3 turns, the pickup turn included | Game Icons locked |
| Decoy | a copy of the player on the pickup tile (user's choice over "next to you") | pawns |
Line is gone (not in the list). Choices: 3 bombs per Pierce / Power / Nuke pickup (over 1 or 5); Nuke reach 2 (over 3: with a fuse of 3, 2 is escapable on foot, 3 hardly without Run).
How it works: state.charges is replaced by state.boosts (bombs left of each bomb boost, and
the last level Diagonal lasts to); Shield, Freeze and Decoy act through the existing shield,
frozen and decoy. applyCharge runs on pickup; picking up more adds to it (3 more bombs,
one more level of Diagonal). Every bomb placed or thrown uses one of each bomb boost held, so
they combine (Bomb.pierce / power / nuke, replacing Bomb.special). Boosts and an unbroken
shield go on to the next level and through a death, as charges did. The shield, freeze, decoy,
pierce, power, line actions and their buttons, keys (H, F, Y, I, O, L) and the Shield / Freeze
double tap are gone; the bar is Move, Bomb and Wait; Diagonal stays an action of Move (G, then Q
E Z C) while it lasts. The top bar shows each active charge with its count (bombs, levels or
turns left; the Shield while it's up), and tapping one explains it. Shield, Freeze and Decoy's
one-colour icons turn white there (MONO_ICONS); the Diagonal icon, like Run's, stays dark on the
bar. ?charges=N now starts as if N of each lasting charge had been picked up (not Freeze or
Decoy, which act on the spot). The new icons are built by tools/compose-icons.sh from source
icons copied unchanged into assets/kenney-board-icons/ (skull, timer_0, pawns,
arrow_diagonal_cross) and assets/kenney-icons/White/ (locked). SIM_VERSION 11.
Screenshots: pierce-decoy became nuke (a bomb with all three badges and the Nuke's reach in
the danger overlay); charges shows the top bar with every lasting charge.
2026-10-23 — Diagonal is a power-up; shield, auto-move, top bar, installing
User requests, from one round of playtesting:
- Diagonal becomes a power-up ("a nice walk power-up"): an ability like Run, Teleport, Kick
and Throw, kept on death, weight 8 in the gem's draw (as Kick and Throw), icon
powerup_diagonal(the former charge icon). It is no longer a charge: six charges remain. - The Shield's ring walks with the player (it jumped from the old tile to the new one): the
Shield's and a Star's rings are drawn where the player is drawn (
playerPos), sliding with a step. It is smaller: 0.88 of a tile across (was 1.08, which spilled onto the next tiles). - Setting an auto-move way takes the first step at once, as a tap next to the player does; an unsafe first step isn't taken (it shows red, as before). "Walk there" in a dialog does the same.
- The board never moves when the top bar fills up. The user is fine with the bar resizing, but not the board moving. The bar now has a fixed number of rows, by screen width only: three on a phone or a screen under 900 px (lives, level, enemies, coins / turn, bombs, blast, fuse / passes, Star and charges, a row kept even while empty), one row on a wide screen. Rows never wrap; a row that gets too long scrolls sideways. Checked in a browser at 320, 390, 700, 899 and 1280 px wide: the bar's height is the same with nothing, with every charge, and with every mode tag. With all four mode tags on a phone, the first row scrolls, hiding part of the hearts.
- Installable, as PLAN.md §17 planned (display fullscreen, icons, offline cache), but without
a PWA plugin: a manifest, icons built by
tools/compose-icons.sh(the bomb on the board's sky blue; a maskable one with room round it; an Apple touch icon), and a network-first service worker (static/sw.js) registered only in the build, so development keeps Vite's live reload. Checked in Chromium against the built game: no manifest or installability errors, the worker active, and the game loads with the network cut.
SIM_VERSION 12 (Diagonal moved from the charges to the powers). Also fixed: rewriting the
README's charge section earlier the same day had dropped the sections between it and "Lives"
(the preview notes, Bombs, Danger, Enemies, Drops and coins, The exit, Power-ups); they are back,
with the day's changes in them.
2026-10-23 — A death keeps every power-up
User request: power-ups stay after a death. carryOf now carries everything to the retry —
stats, every ability and pass (Detonator, Wall / Bomb / Flame Pass included), coins, charges'
boosts and an unbroken Shield; a death costs only the life. (A Star can't be running at a death,
since nothing hurts the player while it lasts.) The "Kept / Lost when you die" endings are gone
from the power-up texts, the README's "Lost when you die?" column and the death banner's "Lost:
…" list too. This drops the NES rule PLAN.md §5 and §9 were built on: the planned Keep / Fresh
choice on death (§9, not built) took the passes away in both options, so what Keep vs Fresh
costs needs rethinking before it is built — marked open there. §5b's question 4 (what a death
costs with slots) is settled: nothing. SIM_VERSION 13.
2026-10-23 — Enemies spread out
User report: enemies stacked on one tile moved in the same direction, taking up one tile together — "not fun, they should go their separate ways". Why: enemies plan without looking at each other and may share a tile, so enemies on one tile make the same choices (Drifters go straight; Seekers take the same shortest path; Blobs keep their heading 3 times in 4), and the 3 let out of a blasted exit or gem start on one tile facing the same way (and since the shelter rule they live).
Now, in planIntents, enemies are planned in turn and each keeps clear of where the ones before
it will be at each of the next 3 moves (spreadSteps): a Blob wanders among the clear steps, a
Drifter turns if straight on is another enemy's, a Seeker's step toward the player is dropped
for a wander if another enemy takes that tile. A crowded step is used only when no open step is
clear. Enemies still don't block each other, so each still makes exactly the moves its arrows
show. Tests: three of a kind on one tile in an open room never share a tile over their 3
planned moves (they all fail without the rule); they still share when there is no other way.
SIM_VERSION 14.
2026-10-23 — Guards against a frozen game, and the game's logs in the dev terminal
User report: on a phone (under npm run dev), the game froze with the player missing from the
board. Why it froze rather than carrying on: LittleJS's development build stops its frame loop at
the first error in a frame (its release build goes on).
The cause: a Freeze drop on the board. LittleJS cuts a 128 × 128 tile out of each board
texture; every sprite is 128 × 128 except the new Freeze icon (charge_freeze), inked from a
100 × 100 Game Icons file, so its tile was invalid and the development build's check threw while
drawing the drops — after the coin drawn before it, so the enemies, fading things and the player
were never drawn. Found by replaying the screenshot's situation (Small, Developer, Crumbling
crates, the last enemy blasted) on 16 seeds in a browser: seed 13 dropped a Freeze and failed
with "Sprite charge_freeze (texture 68): Assert failed". Fixed: tools/compose-icons.sh makes
every inked icon 128 × 128; a test (test/sprites.test.ts) checks every PNG in assets/kenney
and assets/composed is 128 × 128 (it fails with the old file); the same seed then played on to
turn 20, and the 16 seeds without an error.
- Guards (user request): the game's per-frame update and draw are wrapped (
guardedin main.ts): an error is reported once while it repeats, and the next frame runs as usual. A turn the rules refuse (stepthrows) is reported and nothing happens, instead of an uncaught error.sprite()now names an unknown sprite instead of handing LittleJS texture −1. - Logs on the dev desktop (user request: "a callback to show the logs as npm run dev
output"): Vite 8's own
server.forwardConsolesends the browser's console (log, info, warn, error) and uncaught errors over its dev connection to the terminal runningnpm run dev, marked(client), from any device on the Wi-Fi. A first version had its own plugin for this; Vite's built-in does the same, so it was dropped. Caught errors are logged with their stack and the device ("Android 390×844") bysrc/devlog.ts. Nothing of this is in the build. - The top bar's first row was full of mode tags in that game (Small, Dev, Crumble), which pushed the level, enemies and coins out of sight. The mode tags now start the third row, so the first row always shows hearts, level, enemies and coins.
2026-10-23 — Into the doorway, and a demo before the game
- The step into the exit is animated (user report: it happened instantly). The turn reports the step and then the level cleared; the cleared jump replaced the step's walk at once. The jump now remembers where the step began: the player walks into the doorway (0.16 s), then jumps.
- A demo before the game (user request: some people never played the original; scenes
generated at play time and shown view-only, like a screensaver, before the player starts).
Made up on the spot from the real rules rather than recorded: a small random room, random
games on it until one shows the scene (an enemy killed, a narrow escape, a chain reaction,
Freeze, the Detonator), replayed with a caption. Details in PLAN.md §13a. Choices made here,
since the request left them open: it plays every time the game opens (not only the first
time), until any tap or key, which then does nothing else (a click on a button that appears
under the finger is swallowed too); the scenes cycle through all five kinds in a shuffled
order; captions are one plain sentence each; test URLs skip it unless
?demo, and with?seedit is the same every time (for the screenshot). The rooms are 9 × 7, so on a tall phone the room sits in the middle with sky above and below. Tests: every kind is found on 10 seeds, replays by the rules with the player alive throughout, and ends on its moment; the same seed gives the same scene. Checked in a browser: scenes play with captions and no errors; a tap ends the demo and the game starts at turn 0 with nothing else done. - LittleJS's development build draws its version and frame rate in the top left; the top bar
covered it, the demo doesn't, so it is turned off (
setDebugWatermark(false)).
2026-10-23 — The last enemy drops a charge that lasts; icons in the demo's captions
- No wasted charges from the last enemy (user report: a charge dropped by the last enemy,
like a Decoy, was often wasted). Freeze and Decoy only work on enemies. Of three options (the
last enemy drops only lasting charges; Freeze and Decoy picked up with no enemies are saved for
the next level; both), the user chose the first: when a turn's kills leave no enemy on the
board, every charge they drop is drawn from Pierce, Power, Nuke and Shield, which go on to the
next level. Decided once the turn's kills are all done, so two enemies dying together are both
"last". A Freeze or Decoy dropped earlier and picked up after the last kill still does nothing
(accepted). Tests: over 2,000 rolls a last drop is never Freeze or Decoy (and all four lasting
kinds come up), an ordinary drop still can be.
SIM_VERSION15. - The demo's captions show the icon of the charge or power-up a scene is about (user request: "better visual associations with concepts"): the padlock for Freeze, the Detonator's badged bomb for the Detonator — the same icon as on the board, in the top bar and the almanac. The three scenes about bombs in general have none. The icon sits beside the words, not in the line, so a two-line caption stays evenly spaced.
2026-10-23 — An enemy walks into the flame before it dies
User report (spotted in the demo): an enemy walking into flame started its death on the new
tile at once, without ever leaving the old one. Its step and its death come in the same turn,
and the death replaced the step. Now an enemy that moved this turn and dies in it finishes the
step first (the same 0.16 s hop as any enemy step, from the tile it left), then flips over and
falls away there — as the player walks into the doorway before the level-cleared jump.
WALK_SECONDS moved to effects.ts, which schedules the death after the step. Checked in a
browser: 70 ms in, the Blob is half-way into the flame; at 350 ms it is dying on the flame tile.
2026-10-23 — The demo shows every feature
User request: the demo's scenes should show every feature of the game except the shop and the
exit, and one more scene on the enemies' move arrows ("a good idea to watch those"). The scenes
are now a table (SCENES in sim/demo.ts), each with its enemies, what the player starts with
(a power-up held, or a charge lying two steps away to pick up first) and its moment — e.g. Kick:
a kick and then a kill; Crate Pass: a step onto a crate; Nuke: a kill off the bomb's row and
column; arrows: an enemy killed on the tile it stepped onto, the bomb waiting there. 27 scenes:
kill, arrows, escape, chain, lingering flame, the enemy types, drops, the gem (uncovered and
taken; its choice dialog isn't shown), Bomb +1, Fire +1, Fuse +1, Run, Kick, Throw, Teleport,
Diagonal, the Detonator, Crate Pass, Bomb Pass, Flame Pass, Star, Pierce, Power, Nuke, Shield,
Freeze and Decoy. Heart and Coin bag are left out: they only change numbers in the top bar,
which the demo hides. The random play now also uses Run, Kick, Throw, Teleport and Diagonal
when held. Pierce rarely found two crates in a row, so its room gets extra crates. Every
caption now has an icon. Tests: each kind is found on 6 seeds, replays with the player alive,
and showsAtEnd confirms its last turn is its moment; probes found every kind on 15–30 seeds,
the slowest under 0.4 s. A minute of the demo in a browser played 8 scenes with no errors.
2026-10-23 — No second Decoy, Freeze or Shield while one would be wasted
User report (from the demo): two Decoys were dropped, the second while the first still lay
there. The last-enemy rule didn't cover it (the enemy wasn't the last). One decoy is out at a
time, a second Freeze adds no turns, a second Shield nothing — so an enemy's charge drop now
leaves out a Decoy, Freeze or Shield that already lies on the board or is working (wasted in
enemies.ts), on top of the last-enemy rule. Pierce, Power and Nuke add up and are never left
out, so there is always something to drop. A search over 40 seeds of every demo scene found 6
such drops before; after, only 2 Freezes dropped in the turn the old freeze ran out (rolled once
it had, so not wasted). Tests: over 2,000 rolls each, none of the three drops while one lies
there or works; Pierce still does. SIM_VERSION 16.
2026-10-23 — The demo shows how you die, and the exit
User request: "how does the player die?" — scenes for the ways to die (walking into flame, blocking yourself in, not getting far enough, a bomb setting off another, an enemy, "etc."), alternating living and dying scenes, "some funny ones maybe"; and the exit, with scenes ending naturally when the player walks out through the open door.
- Seven death scenes, each ending on the death: into a blast (stepped from a safe tile onto one that burns that turn), not far enough (was in reach, moved, still in reach; the room gets Fire 3 so it happens), blocked in (every step is about to burn), chain (caught only by a bomb that wasn't due, so the chain set it off), an enemy, and the funny two: the Detonator set off while standing next to the bomb, and a Run straight into an enemy. Walking into lingering flame isn't possible (the game refuses that step), so "walking into flame" is the blast one. The random play still refuses deadly moves except the one that is the scene's death.
- The exit: one scene where it opens and the player walks out — the scene ends there, with the jump into the doorway (once open, the random play heads for it four times in five, or it is rarely found) — and one where flame on the uncovered exit lets three enemies out. I read "demos end naturally when the player walks into an open door" as that exit scene; other living scenes still end on their own moment.
- Living and dying scenes take turns, each from its own shuffled queue, so the 29 living
ones aren't spread thin between deaths. The "Ouch!" and level-cleared banners are hidden in
the demo. Tests: every kind found on 6 seeds, the player alive throughout and dead at the end
of a death scene, and
showsAtEndconfirms the moment. In a browser: 12 scenes in 70 s, alternating, no errors.
2026-10-23 — No drops on the uncovered exit
User report: an enemy died and its charge landed on the closed (uncovered, still locked) exit.
That hides the door, and once the last enemy dies the drop can only be taken by stepping on the
exit — which ends the level — while auto-move won't cross the open exit at all. dropSpot now
counts the uncovered exit as taken, like a drop, a bomb or the uncovered gem: a drop there rolls
to the nearest free floor tile (up to 2 steps). An exit still under its crate isn't floor, so
drops never reached it. Test: a Blob dying on the uncovered exit (in lingering flame — a new
blast there would let enemies out) never leaves a drop on it over 500 rolls; it fails without
the rule. SIM_VERSION 17.
2026-10-23 — No throwing over a pillar
User question: "a bomb throw shouldn't be able to go through a column/pillar, right?" Throw had
been "exactly 2 tiles away, over anything" since the first plan (as in the later Bomberman
games, where a thrown bomb sails over blocks) — the plan's wording, not a user choice. Of the
options (not over pillars or walls; only over open floor; keep over anything) the user chose
the first: a pillar or the outer wall on the tile in between blocks a throw; a crate, a bomb or
an enemy there can still be thrown over. Pillars look like tall solid columns, and crates stay
a reason to have Throw. Teleport still goes "over anything" (the question was about throws).
Tests: a pillar blocks, a crate, bomb or enemy doesn't. SIM_VERSION 18.
2026-10-23 — A tap on a diagonal neighbour steps there
User report: with Diagonal, a plain tap on a free diagonal tile walked one tile sideways first,
as auto-move does for a far tile — a tap on the board took a direct step only for the four
straight neighbours, and auto-move's way uses straight steps. Now a plain tap on a diagonal
neighbour, with Diagonal, is a diagonal step, as a straight neighbour is a step. And with Move
picked, Diagonal now comes before Teleport in Move's order (GROUPS in picks.ts), so a tile
next to the player is always a step, and Teleport is for tiles further away (it used to win a
diagonal neighbour too). Auto-move's way still uses straight steps only. Checked in a browser: a
tap on a free diagonal neighbour moves there in one turn. No rule changed, so no new
SIM_VERSION.
2026-10-23 — Auto-move runs and teleports
User report: with Teleport, tapping a far tile still walked there one tile at a time. The
user's rule: a tile 1 step away is always a step; 2 steps away is two steps, or one run with
Run, or one teleport with Teleport (or both); further away, run 2 or teleport as far as possible,
then reassess. Built as autoMoveAction (sim/path.ts), used for every move of auto-move,
including the first one on the tap: a neighbour is a step; otherwise every option — a step along
the way, a Run along it, each Teleport landing — is scored by the walk left from where it lands,
and the shortest wins, ties going to Teleport, then Run, then the step. Only tiles auto-move
calls safe are landed on, and never the open exit unless it is the destination. Auto-move's own
teleport doesn't end auto-move (a move off the way normally means "changed my mind"). With
Teleport, a tile out of walking reach but within Teleport's (behind a pillar) is one teleport.
Tests cover each case; in a browser, a tile 7 steps away took 3 turns (teleport, teleport, step).
2026-10-23 — Developer mode's gem is an extra one
User request: the gem next to the player in Developer mode is not the level's gem but an extra
one, added for the developer. It used to be the level's own gem, moved beside the start, which
left the level without its hidden gem. A level can now hold several gems (state.gems, which
replaces powerUp); Developer mode adds one marked extra right of the start, in the next
colour after the level gem's so the two look different, and it draws its own offer ('offer-extra'
stream) so it doesn't offer the same five as the level's gem. The level's own gem stays hidden
under its crate exactly as in a normal run. Burning, revealing, taking and drops' "never on a gem"
work per gem. Tests: the level's gem is unchanged and hidden; the extra one is another colour
and its offer differs on most seeds; a normal run has one gem. SIM_VERSION 19.
2026-10-23 — One board; crates always crumble; God becomes Immortal
User request: move away from the "Normal" and "Small" labels, make the small board the main one, make crate crumbling the default and drop it from the options, so the New run menu has only the two modes; and rename God to Immortal, noting that it earns no achievements but does allow playing to the end.
- One board (user's choice of "delete the big one entirely" over keeping it behind a test
URL): 9 × 11, 20 levels, density 25%, 2 enemies on level 1 + 1 per 4 levels up to 5, par
25 + 8 per enemy — the former Small board.
RunOptions.size,BoardSize,SIZESand the big board's numbers are gone; the camera always fits the whole board. This changes recorded decision #12 ("50 levels, like the NES") and PLAN.md §11's NES-size board: both are marked as changed, not deleted. The screenshotssmall,small-desktopandscrolledare gone. - Crates always crumble once the last enemy is gone (
RunOptions.crumbleremoved). - Immortal (
RunOptions.immortal, wasgod): nothing can kill the player, so a run can be played to the end, but it earns no achievements — said in the New run dialog, the menu and its tag; there are no achievements yet, so PLAN.md notes it for the victory certificate and the Workshop. Saved menu choices with the oldgodkey still read as Immortal. - The New run dialog now has just Developer and Immortal; URL parameters are
?devand?immortal.SIM_VERSION20.
2026-10-23 — A readable demo title; Developer runs' achievements in red
- The demo's "Explodorama" title was hard to read in Kenney Future (blocky letters, an X like an H). It now uses the rounded system font the game's text uses, extra bold. Kenney Future stays for buttons and dialog titles, where it is smaller.
- User request: a Developer run earns achievements like any run, but each carries a red border, "like the red quarters some bars used in jukeboxes". There are no achievements yet, so for now the victory banner of a Developer run is framed in red and says "(Developer run)"; PLAN.md records the rule for the certificate and the rest. (Immortal runs earn none.) The New run dialog and the run's info say so. Also fixed: the victory banner said "1 turns".
2026-10-23 — Moves are animated from the change of state
User report: in the demo, the player walking into flame jumped straight onto the tile — and "this issue has happened before (no animation upon a movement); can this pattern be made more resilient?" It had: the step into the doorway, an enemy walking into flame, and now the player into flame. Each time, something else in the same turn (the level cleared, a death) replaced the step's animation, because the player had one animation slot, set from events.
Now movement and pose are separate. The move comes from the state: the player's tile before
and after the turn (playerTransition, a pure function in effects.ts), drawn as a slide or, for
a teleport, vanishing and reappearing. The pose — hit, jump, crouch — is drawn only once the
move is done. Nothing in the turn can skip a move any more. Enemies' steps are likewise taken
from their change of position (and a dying enemy's last step too), not from events. A new level
or a retry starts still. Tests (test/animation.test.ts): a step into flame is a move then the
hit pose; into the open exit a move then the jump; a teleport; a bomb is a crouch with no move;
a new level has no move. CONTRIBUTING.md now asks new animations to follow the pattern. Checked
in a browser: 70 ms after stepping into a blast the player is part-way there, walking; at 450 ms
they are in the hit pose on the flame tile.
2026-10-23 — The Move button's key is M
User request: the Move button's key hint said "WASD", which isn't the whole truth with Diagonal; make it M, like B for Bomb. M now picks Move (then a direction or a tile), and the button shows "M". W A S D and the arrows still step at once, as before. With Diagonal, Q / E / Z / C now step diagonally at once too (they needed G first), or with Move picked. Checked in a browser: the button shows M, M picks Move, and Z steps down-left in one turn.
2026-10-23 — The auto-walk is twice as fast
User request: auto-move "at least 50% faster". Once every enemy is gone, the walk to the
destination now takes a step every 0.5 s (AUTO_STEP_SECONDS, was 1 s) — twice as fast. A
step's slide takes 0.16 s, so each one still finishes before the next. Each step is still one
turn, and a Run or Teleport still counts as one move.
2026-10-24 — The death banner says how and where
User request: when the player dies, a short text says how and on which level, then play goes on
as before. The sim now records the death in the state (GameState.death: the cause, the enemy
for an enemy, and the turn), since the banner is drawn from the state and a reloaded game
replays to it. Causes: walked into an enemy (first contact check), an enemy walked into the
player (after the enemies move), caught in a blast of a bomb that was due (or set off by the
Detonator), caught only by a bomb a chain set off (it wasn't due), or by last turn's lingering
flame. The banner: "Ouch! Level 3, turn 12: you walked into a Blob. 2 lives left."; game over
puts the same line before the seed. Nothing is recorded when a death is prevented (Shield,
Star, Flame Pass, Immortal). Tests cover each cause. SIM_VERSION 21.
2026-10-24 — Same dungeon or a new one at game over; an optional seed
User request: when dying, choose between the same seed (dungeon) again and a new one from a random seed — and should a player be able to type a seed? There was no reason not to (the seed is already shown, and sharing or replaying a dungeon, races and a daily seed all need it; the victory certificate is built on the seed), so, with the user's choices:
- At game over only (over "every death"): the banner's buttons are Same dungeon (the same seed and modes, from level 1) and New dungeon (a random seed, same modes). A death with lives left still has the one Try again. After the victory it is still New run (the dialog).
- An optional seed field in the New run dialog: empty for a random seed; a whole number is
used as it is; any other text (a word to share) is hashed into a seed. Unlike a
?seedlink it is a real, saved run. A seed gives the same dungeon only with the same rules version.
Checked in a browser: "1234" gives seed 1234, "pavel" a hashed one, empty a random one; at game over both buttons show, and Same dungeon restarts the same seed at level 1.
2026-10-24 — A local high-score board, planned for a global one; the daily dungeon planned
User request: a local high-score board, based on coins or level reached, with the power-ups in use, enemies killed, how the run died; kept local but planned for a global board; seeds kept; and start thinking about a daily dungeon with a seed from the date.
- Ranking (user's choice of "level, then coins"): a win first, then the level, then coins,
then fewer turns. The best 25 finished runs are kept in
localStorage. - Entries (
sim/scores.ts, pure and tested) hold the board's facts and the whole run record with format and rules versions, so a global board can verify a score by replaying it (the sim is deterministic TypeScript that a server can run). Enemies killed is a new run-wide count (runKills), soSIM_VERSION22. - Which runs: finished ones only (game over, or the win); not Immortal (no achievements); Developer with a red border, as decided; never from a test URL.
- Where: Menu → High scores, and a "High scores" button on the game-over and victory banners — those banners cover the menu, which found that the menu couldn't be reached at game over. The banner says "New high score: #N!" when a run makes the board.
- Planned, not built (PLAN.md §12b): the global board — verification by replay, no accounts
(a name and an install id), opt-in submission, a small backend — and the daily dungeon (seed
hashSeed('daily', UTC date), fixed modes, its own board, a shareable result). This would be the app's first backend; PLAN.md recorded "no backend or telemetry, on purpose", so building it needs the user's go-ahead. Open: whether only the first daily run counts, or the best.
Checked in a browser: two game overs (the second a Developer run with more coins) each said "New high score: #1!"; the board showed both, the Developer one first, red-bordered.
2026-10-24 — The version number is the rules version
User request: a version number in the menu and the README, using the SIM_VERSION counter.
- One number: the game's version is
SIM_VERSION(now 22), notpackage.json's 0.1.0, which nothing bumps. It goes up exactly when a rule change makes old runs replay differently, so it also says which saved runs and seeds carry over. - Where: a small line at the bottom of the menu, under the art credit, and a "Version:" line
near the top of the README. A test (
test/version.test.ts) fails if the README's number andSIM_VERSIONdisagree, so a bump can't leave the README behind. - A change that alters only the look (no rule change) keeps the same version.
- The commit and the repository (user request, same day): the menu also shows the short
commit hash the build came from, linked to it on GitLab, and a "Source code" link. The hash
is read from git when Vite starts (
vite.config.ts, asVITE_COMMIT), so the deployed build shows the commit CI built. A build with uncommitted changes says "plus local changes", since the hash alone would claim code it doesn't contain; with no git, the commit is left out.
2026-10-24 — WebGL again; Developer mode's gem is a rainbow gem offering everything
User request: try the WebGL renderer again, and make Developer mode's extra gem "some sort of rainbow/glittering/strange gem", clearly a very special one, offering every possible choice (minus what the player already has) instead of the usual 5.
- WebGL is back (
setGLEnable(true); supersedes "Canvas 2D renderer, not WebGL"). The black strip in the screenshots came from Playwright's default headless shell: with the same build, the full Chromium in its new headless mode (channel: 'chromium'), headed Chromium, and the SwiftShader and Metal back ends all draw it correctly.tools/screenshots.mjsnow launcheschannel: 'chromium', so the screenshots stay trustworthy. Still separate textures, no atlas: a few hundred sprites a frame don't need batching across textures. - One WebGL difference fixed: an added colour's alpha is added to the sprite's alpha, so the
frozen enemies' sparkles (added white, alpha 1) would have drawn as white squares; they add
C.glint(white, alpha 0) instead. - The rainbow gem (
GemColor'rainbow', only the extra gem; the level's gems keep the four colours):assets/composed/gem_rainbow.png, built bytools/compose-icons.shfrom the green gem — its body in a red-to-violet rainbow over its own shading, the outline unchanged (Kenney art from the packs already in use). On the board it turns like a coin, flashes as it faces you, and five sparkles circle it, each a flat colour cycling through the hues. Its info title is "Rainbow gem (Developer mode)". - Its offer is every power-up that would do anything: abilities and passes already held
are left out; stats, Star, Heart and Coin bag are always in. Listed in the usual order, with
nothing drawn (so no random stream). The dialog scrolls (15 at most).
SIM_VERSION23, since a Developer run saved before would replay differently. - Screenshots:
devshows the rainbow gem; newdev-offershows its choice.
2026-10-24 — The rainbow gem offers every charge too
User request: so Freeze and the other charges can be tried without waiting for an enemy to drop
them. The rainbow gem's offer lists every power-up that would do anything, then all six charges
(OfferKind = a power-up or a charge; isCharge). A chosen charge works exactly as a picked-up
one (applyCharge): bomb boosts add BOMB_BOOST, Shield goes up, Freeze freezes every enemy,
Decoy is left on the gem's tile. Choosing takes no turn and comes after the gem's turn, so
a Freeze chosen this way skips the enemies' next 3 moves (one picked up skips its own turn's). A level's own gem never offers a charge.
SIM_VERSION stays 23: a run recorded before still replays the same (its choices are still on
offer), and 23 itself is from earlier today.
2026-10-24 — Demos get to the point, and show the controls
User request: scenes sometimes ran many moves, the player and enemies dancing around, before the thing they're about; it should come within 3–6 steps of the start. And the demo should teach the controls: a tap effect, the action buttons, a virtual finger, with scenes for moving, a bomb by long press, kicking a bomb, and so on.
- The moment comes 3–6 turns in. Rather than limiting the random games to 6 turns (most would fail), a game still runs until it shows the moment, and the scene is its last 3–6 turns, from a calm board: no bomb (so each bomb is seen placed and counting down), no flame, no drop the room didn't start with (a coin or charge left by an enemy killed before the scene would be a mystery) and auto-move not under way. The shortest such ending wins, and it is replayed from its start to check the moment still comes (a charge must be picked up, a kick made, within the scene). I read "within 3–6 steps" as: at least 3 turns of lead-up, at most 6, so the moment is never the very first turn.
- Scene changes for the window: Fuse +1 now starts the player at fuse 4 (start 3, plus one; it was 5); Pierce, Power and Nuke lie next to the player rather than two tiles away; Pierce's room has Fire 2 and a straight row (Pierce, a free tile, two crates), and its moment is now a crate burnt behind another — before, two crates on different sides counted, which any bomb does.
- Gestures. Every turn of a scene comes with the gesture that plays it, chosen so the real
controls would read it as exactly that action: a tap (step, diagonal step, kick, detonate),
a long press (a bomb), a button and then a tile (Run, Teleport and Throw; a bomb in the Bomb
button scene), Wait's button, a far tap (auto-move) and Go. What a tap means moved from
tapTileinto the sim astapMeaning(picks.ts) so the demo and its test use the same rules;tapTilebehaves exactly as before. Auto-move's safety check moved to path.ts (safeStep) for the same reason. A random turn no gesture can play (a step onto a bomb with Bomb Pass, whose tap would kick or do nothing) is never chosen. - The finger is the UI Pack: RPG Expansion's pointing hand (
cursorHand_beige), already one of the game's packs. Its PNG is 27 px, too small to scale up, so its three shapes were cut out of the pack's vector sheet into an SVG. It glides to each tile or button, sinks as it presses, and leaves a white ripple (with a thin dark rim, to show on the pale floor); a long press draws a yellow ring closing in over the real 450 ms, then the armed bomb's preview for 0.6 s. The board answers through the real view state: the picked button's tiles and the preview under the finger, the armed bomb, auto-move's way and Move turning into Go. - The action bar shows in the demo, as a dark rounded tray right under the board (8 px
below it, as wide as it), and the caption panel stays apart at the bottom, at least 24 px
below: the board and its buttons are one group, the explanation another (user request, same
day — at first the bar sat on the caption panel, which read as part of the text). The camera
fits board and bar together above the panel; main.ts places the bar every frame from the
board's screen rectangle. A press shows as the real one (
.pressed, like:active). - Five control scenes — step (3 taps), bomb by long press, bomb with the Bomb button, Wait (a wait, then a kill), auto-move (a far tap, then Go until there) — play first, in that order, and again each time the living scenes have all been shown. Kicking has no scene of its own: the Kick scene already shows it, and its caption now says "tap a bomb next to you"; the Run, Throw, Teleport, Diagonal and Detonator captions say how too.
- Timing: a tap turn takes about 1.2 s, a button-and-tile turn about 2 s, so a scene is
4–12 s. Tests: every kind on 6 seeds is 3–6 turns long from a calm start, and each gesture,
read by the real rules (
tapMeaning,resolvePick,autoMoveActionwithsafeStep), gives that turn's action. A probe found every one of the 41 kinds on 40 seeds each, the slowest 0.39 s (Nuke). Checked in a browser: two minutes of the demo played 15 scenes (the five control scenes first) with no errors; frame by frame, the finger, ripple, ring, button presses, highlighted tiles and auto-move's way look as described; a tap ended the demo and left no finger or marks behind. Thedemoscreenshot is now taken mid long-press.
2026-10-24 — No compatibility code for old versions; surviving a rules change shelved
The game hasn't been released, so there are no saves or settings to carry forward. The one
piece of old-version code, reading the New run choices' former god key as Immortal, is gone,
with the God name in comments and test names. The SIM_VERSION check on resume stays: it isn't
compatibility code, it keeps a move log from being replayed under rules it wasn't played by.
Keeping a run across a rules change (a state snapshot, the record noting the change) or playing
older versions was discussed and shelved by the user: a lot of effort for a doubtful pay-off
while nobody else plays (PLAN.md §11).
2026-10-24 — The demo's finger starts at the player; a "You" tag
User request: the eyes follow the hand, so it should start close to the player; and it isn't immediately clear which of the moving figures is the player.
- The finger appears beside the player at the start of every scene: its tip at the bottom-right corner of the player's tile, faded in there rather than gliding in from wherever the last scene ended (or, for the first scene, the middle of the screen). It is placed once the camera shows the new scene, so the first scene gets it right too.
- A "You" tag over the player for the whole demo: a green pill (the accent colour, as on a
picked button) with a dark rim and a point down at them, following where the player is drawn,
so it walks, runs and teleports with them (
playerPos, now exported from board.ts). It hugs the top of the player's head, always over them (user's call, same day: for a while it flipped under the player on the top row, where it blended into the stone wall; now it stays put, and a white halo round its dark rim keeps it apart from the stone). It sits above the finger, so a tap on the tile over the player doesn't hide it. Only in the demo; in play, the action bar's Move button and the tapping make it clear soon enough, and the tag would cover the tile above. Checked in a browser frame by frame.
2026-10-24 — The top bar in the demo, its tour, "What is that?"; the player stands higher; bumps no longer clipped
User request: add the top bar to the demos, describe its items, and show how to use ? to learn about enemies. Also: draw the player a few pixels higher, so its legs never touch the line between tiles; and (spotted meanwhile) the Turn item, bumping when a turn passes, was cut off by the bar's edge.
- The top bar shows in the demo, as in play; the board, the action bar under it and the
caption panel fit below it. Each scene now starts at turn 0 with no coins (a scene is the end of
a longer game: the counter would have opened at, say, 14). Safe because a scene starts with no
bomb out, and the turn number only matters relative to a bomb's (
placedTurn). Demo rooms have par 20 instead of a dummy 99, which the bar would show. - The top-bar tour, after the control scenes: the finger points (no tap: each item would open a dialog, twice as long) at lives, level, enemies left, coins, turn and par, bombs / blast / fuse, and the row of power-ups and charges, each ringed in yellow for 2.6 s while the caption says what it is; the last says that in a game a tap on any item reads more. The room is a still one (no turns), given Kick and 3 Pierce so the last row has something to show.
- "What is that?", next: the finger taps ? and then an enemy, through the real
inspectandtapTile, so the real info dialog opens; 3.2 s later it taps Got it. A tap that ends the demo closes the dialog too. In the demo the dialog isn't a modal (user request, same day: the modal dimmed the caption panel as well):setDialogInDemoopens it withshow(), centred over the scene above the panel and dimming only that (a wide box-shadow), with the panel above it. That also lets the finger go over it; a modal sits in the page's top layer, above any z-index. - The tour's ring moves with the finger (user request, same day: it changed before the finger got there): it changes over half-way along the glide, about where the finger crosses from one item to the next, and for the first item once the finger arrives; the caption with it.
- The player stands 0.07 of a tile higher (
PLAYER_LIFTin board.ts): the sprites' feet are at their very bottom edge, and the idle bob dipped them below it. Walking, teleporting and the move preview use the same lift; the decoy is drawn smaller, so it needed none. - Top-bar bumps aren't clipped: a row that can scroll sideways also clips, so an item bumping (scale 1.3) at a row's edge — Turn, first in its row — was cut off. Each row has 6 / 16 px of room inside, given back with negative margins; checked on a phone and a wide screen that the layout is otherwise unchanged.
2026-10-24 — The demo on a CRT; EXPLODORAMA!
User request: "try out LittleJS post-processing: can we make the demo appear on a CRT TV with lines … like someone is actually playing on a touch-capable CRT"; then, on a wide desktop, limit its width with black bars beside it. And the app is now called EXPLODORAMA!.
- Two layers, because the board is a WebGL canvas but the top bar, the action bar, the "You"
tag and the dialog are HTML, which no shader reaches. LittleJS's
PostProcessPluginshades the board with a CRT's signal: colour fringes toward the edges (postProcessChromatic) and faint static (postProcessNoise). It is a pass-through in play (setShaderCode()with nothing) and the CRT only while the demo runs. (Since 2026-10-24 it is switched off in play instead —postProcess.enabled, LittleJS 1.28.0 — see "Android Chrome at half a frame a second".) A layer over the whole screen part,#demo-crt, adds what belongs to the tube: scan lines 3 px apart, a vignette, a faint glare, and the glass's rounded corners in a dark bezel, with a slight flicker (none with reduced motion). Then (user request, same day) what moves on an old TV: a soft dark bar rolling down every 7 s, as a TV filmed by a camera shows, and two thin bright lines sweeping down, out of step (3.4 s and 5.3 s); all hidden with reduced motion. And (user request, same day) interference, as when a vacuum cleaner is switched on nearby: every 12–25 s (the first after 8 s), for 0.5 s (1.6 s at first, too distracting), the picture tears — its lines jump sideways, some far, in a custom post-process piece before the colour fringes, driven by a shader value,interference, set each frame and fluttering like a motor — the static gets ten times louder (staticNoise), and an SVG-noise snow jumps over the whole screen, top bar and buttons included; then it stops at once. None with reduced motion. Then the sideways jumps were halved (user request, same day: the left-to-right distortion was too much); the static, snow and darkening stayed as they were. The finger is in front of the glass (above it), the ripples on the screen (below it); the caption panel is not on the TV. - No glow, no bulge. LittleJS's TV effect glows from black up, and even a glow only past 0.8 brightness washed the pale board out (the sky measured 243 green instead of 227); without it the colours match play. A bulged screen would bend the board but not the HTML over it, and the finger would miss the tiles it points at.
- At most 4:3: on a wide screen the TV is as wide as 4:3 of its height and centred, with black bars either side (a huge box-shadow); the top bar narrows to it. On a phone it is the full width.
- EXPLODORAMA!, all caps with an exclamation mark: the page title, the home-screen name
(manifest and Apple's), the demo's title and the README's heading. The package name stays
explodorama, and the docs still write Explodorama in running text.
2026-10-24 — Every demo room has its exit
User request: most demo rooms had no door, which made captions that mention it (the enemies
counter: "once they're all gone, the exit opens") strange. Every room now gets one, as every
level does (placeDoor): under a crate five rooms in six, otherwise already uncovered and
locked while enemies live (open once they're gone); never under the gem's crate or on a drop,
and at least 3 steps from the player. The exit scenes still place theirs themselves. Since the
exit is part of the rules (flame on an uncovered one lets enemies out; once open, walking in
clears the level), the random games play with it; a probe over 40 seeds of every kind found all
but one Nuke scene (a missing scene is skipped, and the tests' seeds all pass), the slowest
0.44 s. At a scene's start, 79% of the doors are still covered.
2026-10-24 — Clips in the info dialogs: plan, and the drawing spike
User request: now that the demo works, give the info dialogs animations made on the spot the same way. The plan (every dialog's clip, which demo scene each reuses, the new ones, the order) is in PLAN.md §13c. The user's answers: the finger where it helps, no CRT, a still where one frame says it best under reduced motion, a 1 s hold and a new setup every loop, and every item on the list. How to draw them was left to me:
- One canvas, drawn twice a frame while a clip shows: the clip's room first, copied into a
<canvas>in the dialog, then the canvas cleared and the game drawn. The clips use exactly the board's drawing and animation code, with nothing duplicated. Rejected: drawing the clip behind a hole in the dialog (the modal's backdrop covers it too), a second renderer (a second copy of the board's drawing), frames rendered ahead (heavy, and no new setup each loop). - The spike (
ui/clip.ts): LittleJS builds the camera's transform only at the start of a frame (glPreRender, not exported), so the clip doesn't move the camera; the board gets an origin instead (setBoardOriginin board.ts, used bytoWorldandtoTile), and the clip's room is drawn centred where the camera already looks, at the game's scale. AfterglFlush, that patch of the WebGL canvas is copied withdrawImage(the drawing buffer is still there within the frame), thenglClearCanvas, the origin back at 0, and the game drawn as before. Its own view (effects, flames, moves) for now repeats main.ts'sanimateTurn; step 2 makes that view an object both use. A clip stops when its dialog closes. - First clip, to prove it: "What is that?" on an enemy played the demo's
enemiesscene in the dialog, a turn every 0.6 s, its moment held 1 s, then a new setup. Checked in a browser: the clip plays crisply above the words, loops with new rooms, the dimmed game behind is undisturbed, and the board is normal once the dialog closes; no errors. - Enemies get no clip after all (user's call, on seeing it): the scene was a busy room with all
three kinds, and the player learns soon enough how a Blob moves; the others aren't different
enough to need one either. Their dialogs keep only their picture, on the board and in the
almanac. The first clip is now "Still burning" (flame from last turn), with the
lingeringscene. Clips should keep their rooms simple, with as few enemies as the moment needs.
2026-10-24 — The demo from the menu; a shorter menu; bug reports on the backlog
- Watch the demo (user request: "just seems fun"): a menu button that starts the demo again, from its first scene (the controls'), as when the game opens; the run waits behind it, and the tap that ends it brings the run back as it was (an auto-move destination is dropped).
- The menu names the run's modes and nothing more ("Modes: Developer, Immortal."), instead of a paragraph on each (user request: the menu looked busy). The tags in the top bar still open the full explanation.
- Bug reports through GitLab Service Desk (user request) are on PLAN.md's new Backlog: the user turns it on in the project's settings, then a menu button opens an email to its address, filled in with the version, device, seed and run record.
2026-10-24 — Clips, step 2: one animation object, scenes made in a worker
- The board's animation state is one object (
Animationinrender/animate.ts: effects, flames, the player's move, pose and facing, enemy steps), with oneanimateTurnthat starts a turn's animations. main.ts keeps one for the game and the demo; each clip has its own. Nothing about play changed; main.ts lost six module variables and its copy of the turn's animation. - Scenes are made in a Web Worker (
sim/scene-worker.ts;makeSceneinui/scenes.ts): one can take ~0.45 s, which on the page stalls every animation. The demo makes its next scene, and a clip its next loop, while the current one plays. Where a worker can't be made (or fails), they are made on the page as before. Vite builds the worker as its own file (32 kB). - Checked in a browser: three minutes of the demo with no errors, the "Still burning" clip, and the menu's Watch the demo and back.
2026-10-24 — The demo in order, numbered, with ◀ ▶ and ↺, carrying on where it was
User request: "loop through all the possible demos in order, indicate which number demo this is, and allow left/right controls to switch demo scenes. When a page is reloaded, the demos continue from the same spot. Add a round arrow or something to restart from the beginning, but make it a choice."
- One fixed order, looped (
DEMO_ORDER): the seven control scenes (the five on the controls, the top-bar tour, "What is that?"), then the living scenes in the table's order with a way to die after every four — the dying ones used to take turns with the living, shuffled; I kept them spread out rather than all at the end. 43 in all. Each is still made new every time it plays. - The panel shows "n / 43", between ◀ and ▶ (Game Icons' arrow, the left one mirrored), with ↺ (the UI Pack's repeat icon, as on New run) set apart after them: back to the first, only when pressed. ← and → do the same as ◀ ▶. These buttons and keys don't end the demo; any other tap or key still does. Skipping stops whatever was under way (the finger's script, an open dialog, the tour's ring) and plays the chosen scene as soon as it is made.
- It carries on where it was: the scene playing is kept in
localStorage(explodorama.demoAt), so the demo starts there next time — on a reload, and from the menu's Watch the demo. Not on a test URL, which starts at the first scene and saves nothing (the screenshots stay the same). - Checked in a browser: ▶ and → step forward, ← and ◀ back (wrapping from 1 to 43), a reload resumes at the same scene, ↺ goes to 1, and a tap elsewhere ends the demo; no errors.
2026-10-24 — Report a bug, through GitLab Service Desk
The user turned Service Desk on and gave its address; it lives in the source (BUG_EMAIL in
main.ts), not a CI variable: it is public by design — the game's email link carries it either way —
and if it draws spam, GitLab can give the project a new alias. The menu's Report a bug opens
an email to it (mailto:), subject "Bug report: version 23, level N", body "What happened:" with
room to write, then the details: version and build commit, the device and its user agent, seed,
level, turn, lives, status and modes, the last 5 errors the game caught and carried on from (kept
by reportError, their first lines), and the run record, which replays the run exactly. Some mail
apps cut off long links, so over ~1,900 characters the record stays out of the email; the full
details go on the clipboard (only then, so the clipboard isn't overwritten for nothing), and the
email asks to paste them. Checked in a browser: a short run's email carries the record (919
characters); after 90 turns it doesn't, and the clipboard holds the full details.
2026-10-24 — Sensible play in the demo
User report: an early scene placed its bomb on the exit; demos should show reasonable actions,
"unless the entire point is to paint ourselves into a corner". The random play now only takes a
turn a sensible player might (reasonable in sim/demo.ts), except in the scenes about dying:
- nothing that lets enemies out of an uncovered exit or gem — neither a turn whose flame does, nor a bomb whose blast would reach one (the scene about blasting the exit is exempt);
- a bomb, placed or thrown, only where its blast reaches a crate, an enemy, a tile an enemy is going to, or another bomb (to set it off) — never one that does nothing.
A random game gets 6 goes at each turn instead of 4. On 40 seeds of every kind, every scene was found (the one Nuke miss is gone too), the slowest in 0.5 s, in the worker. Tests: in the living scenes no enemies are let out (but in the exit-blast scene) and no bomb goes on the exit.
2026-10-24 — Long dialogs scroll by touch again
User report: the almanac wouldn't scroll. By mouse wheel it did (fixed 2026-10-22); by finger it
didn't: LittleJS listens for touches on the whole document and cancels every one that isn't on a
control (no page scrolling in a game), which also cancelled a finger dragging the almanac's list,
and the gem's long offer. Touches inside the dialog now stop there (as the wheel already did), so
LittleJS never sees them; the board is behind the dialog anyway. The dialog's body also allows
vertical panning (touch-action: pan-y) against the body's none. Checked with simulated touch
drags in a mobile browser: the almanac's list scrolls (it didn't on the last three commits), and
taps on its tabs and buttons still work.
2026-10-24 — Clips, step 3: the dialogs that reuse demo scenes
Each info text (Info in ui/info.ts) now names the scene its dialog plays (clip), and
showInfo plays it: the action buttons (Move: plain steps, or the move picked — Run, Teleport,
Kick, Diagonal; Bomb: the long press, or Throw, the Detonator; Wait), the top bar's level (the
exit), coins, bombs, blast, fuse, Star, each pass and each charge held, and on the board a bomb
(its countdown and blast, or the Pierce / Power / Nuke shape), the decoy, flame still burning,
the gem, the exit locked (blasting it) and open (walking out), a drop, and the first-pickup
dialog of each charge. Lives, enemies left, turn and par, crates and walls wait for step 4's new
scenes; the modes have none yet.
The finger shows how in the clips about the actions and the power-ups worked by hand (Run, Kick, Throw, Teleport, Diagonal, the Detonator), as the user chose ("when helpful"): it glides to the tile, taps (a ripple) or holds (the ring, then the armed bomb's blast), and the turn plays as it lets go; a button's tiles light up first where the scene picks one, as in play. The action bar isn't in a clip, so Wait is shown as a tap on the player, which waits in play too. The finger is an image over the dialog's canvas, placed by tile. Checked in a browser: the Bomb, Coins and Blast dialogs play their clips, the Bomb one with the finger's long press; no errors. (The development build's LittleJS debug text showed behind the dialog after Esc: that is LittleJS's own debug key in the dev build, not in the built game.)
2026-10-24 — Clips, step 4: the new scenes
Three scenes made for the dialogs only (clipOnly: not in the demo, whose 43 stay as they were):
lastEnemy for "Enemies left" (the last one dies and the uncovered exit opens), crate (a
blast breaks the crate over the gem) and pillar for walls (flame, Fire 2, stopped by a pillar).
"Lives" plays the touched-by-an-enemy death (dieEnemy). The top bar isn't in a clip, so what only
a number shows — turn and par — gets no clip, and nor do the modes. On 40 seeds each, all three
new scenes were found, the slowest in 0.09 s. Checked in a browser: the Crate, Pillar and Enemies
left dialogs play their clips; no errors.
2026-10-24 — Clips, step 5: the offer, the almanac, the "Ouch!" replay
The user chose, of the options offered: one clip above the offer's cards, almanac entries that open their clip, and a replay of the player's own last turns on the banner.
- The gem's offer: a clip above the cards, kept in view while they scroll (sticky). It plays the first power-up with a clip, then the one whose picture is tapped (or pointed at with a mouse). The picture is now its own square button beside the card, ringed green while it plays; choosing is still one tap on the card. Heart and Coin bag have no clip: the clip goes blank.
- The almanac: entries with a clip (every power-up but Heart and Coin bag, every charge) are buttons with a green ▶; one opens that thing's own dialog with its clip and Back, which returns to the almanac on the same tab. Enemies, without clips, stay plain.
- The "Ouch!" replay: main.ts keeps the last 4 turns of the current attempt (cleared when a level starts or restarts); on a death, and on game over, the banner plays them as a fixed scene, over and over: a 9 × 7 window of the level round where the player died (the clip draws the whole level and copies only that part). Not after a resumed save, which has no turns kept.
- Under it:
startCliptakes a kind (a new setup each loop) or a fixed scene, can switch a canvas already playing, and several clips can play at once (the banner's and a dialog's): each is drawn and copied in turn. A clip plays while its canvas is on the page and shown — not in a closed dialog or a hidden banner. Everycanvas.clipis sized by its frame (the banner's showed only a corner at first, at its natural size). The screenshot tool now waits for the game's own canvas, not the first on the page. - Checked in a browser: the rainbow gem's offer plays Bomb +1, then Kick when its picture is tapped, and choosing Kick works; the almanac's Throw entry opens with its clip, Back returns; bombing yourself shows the replay on the banner, and Try again hides it; no errors. All screenshots retaken: most changed by a few thousand pixels, the player now standing a little higher; the death, almanac, offer and info shots show the new clips.
2026-10-24 — A clip's finger starts beside the player; the screenshot tool's taps
- In a clip, the finger now appears beside the player at the start of each loop (its tip half a tile below and right of the player, faded in there), then glides to its first tile, as in the demo (user report: in the Move dialog it came in from the top left corner, where it sat before its first move). Checked in a browser: the Move clip's finger starts by the player.
- The screenshot tool tapped the wrong tiles (user report:
info-enemy.pngshowed a crate). ItstileCentrestill assumed the old 13-row board scrolled to the left edge; it now works out the 9 × 11 board fitted and centred between the bars. The enemy shot taps a Blob (1, 7) now; the auto-move and long-press shots, which tap too, show what their comments say: the way to (3, 3) in blue with Go, and a bomb armed right of the player.
2026-10-24 — Clips: the modes, and stills under reduced motion
The last of the clips plan. The modes' tag now plays a clip: the rainbow gem beside the
player, taken (Developer mode; a new clip-only scene, rainbowGem), or the player unharmed in a
blast or by an enemy (immortal); a run with both shows Developer's. Under reduced motion a
clip is a still, as the user chose ("whatever is clearer"): the scene played to its end at once
and held — the moment just after it, flame still on the board, the player's pose — with no finger
and no next loop. Checked in a browser with reduced motion emulated: the Blast dialog shows its
still, the flame on the board; both mode clips play; no errors.
2026-10-24 — The gem's offer: select, then Choose
User report: in the (rainbow) gem's offer a tap on a card's description chose it at once; it should
be two steps — tap to select and see it in action, then confirm. Now a tap anywhere on a card
selects it (ringed green, aria-pressed), the clip above the cards plays it, and a Choose Kick
button (it names the selection) takes it; the first card starts selected. Every gem's offer works
this way, not only the rainbow one, so it is the same everywhere. The separate picture button and
the mouse-hover preview of the step before are gone: the selection is the preview. Checked in a
browser with touch: a tap on Kick keeps the dialog open and changes the button to "Choose Kick";
Choose takes it (Kick appears in the top bar).
2026-10-24 — Dialogs fit the visible screen
User report (a screenshot from a phone): the gem's offer ran off the bottom of the screen, its
Choose button cut in half. The dialog's body had a height guessed from the screen's (100vh less
150 or 260 px for the rest); on a phone 100vh includes the part behind the browser's address
bar, and the offer's two-line title, clip and Choose button took more than the guess. Now the
panel is a column never taller than what the browser shows (100dvh, less room for the close
button above it; 100vh where dvh isn't known) and only its body scrolls, so the title and
buttons always show, whatever is in it. Checked at 390 × 640, 390 × 844 and 1280 × 600: the
rainbow gem's offer and the almanac fit, the Choose button 30 px above the panel's bottom edge.
2026-10-24 — Heart and Coin bag show their picture in the offer
User report: selecting Heart or Coin bag in the gem's offer left the clip's place empty. They have
no clip (nothing changes on the board: a life, coins), so their picture now shows there, big, on a
pale box the clip's size and shape; any other selection brings the clip back. On the way: a
hidden clip canvas didn't hide (canvas.clip sets display: block, which beats hidden), so
canvas.clip[hidden] is display: none now — the "Ouch!" banner's canvas, hidden when there is no
replay, relied on it too. Checked in a browser: Heart and Coin bag show their pictures, Kick its
clip again.
2026-10-24 — The gem screenshots show the gem again
User report: gem-offer.png showed the board, not the offer. Its seed-55 key presses no longer
reached the gem (the levels have changed since they were written), and gem.png, the step
before, showed no gem either. A short search with the sim — random moves, bombs and waits on
seeds 1–120, keeping the shortest that ends with the gem taken and the player alive — found seed
107: down, a bomb below, wait, up, wait three times, down twice (the gem uncovered beside the
player: gem.png), right onto it (gem-offer.png, the offer open).
2026-10-24 — Run extends Teleport; RPG, Proximity alert, Slide and the ICBM charge
User requests: with Teleport, Run should extend it (Teleport already reaches every tile a run does); a power-up to "send the bomb as far as it can fly" (RPG); a charge that flings the bomb the same way but explodes instantly (ICBM); a power-up that explodes a bomb when an enemy steps into its range (Proximity alert); and a power-up to slide in a straight line until a wall or a crate, its highlight showing the next tile and then the far tile where the slide ends. The rules below are the user's choices of the options offered (all the recommended ones).
- Run + Teleport: Teleport reaches 4 steps instead of 3 while Run is held too
(
teleportRange). Run's own move stays, and is never better than a teleport. - RPG (power-up, weight 6, part of Bomb, key F): fired in a straight line, the bomb flies until
the next tile is a wall, crate, bomb or enemy and lands just before it (
flightEnd), 2 tiles away or more (1 is a plain bomb); it counts down as usual, within the bomb limit. It flies straight, like a kicked bomb (bombFired), not in a throw's arc. - ICBM (charge, key I): 3 launches per pickup, added up like Pierce; Bomb fires one as an RPG bomb at any distance and it goes off that same turn; the bomb limit doesn't count, and it works without the RPG power-up. It lasts past the last enemy, so the last one may drop it. Choice made here: while launches are left, a far tile picked with Bomb is an ICBM, not an RPG bomb (Bomb's group puts ICBM first; the preview shows it going off) — otherwise a player holding both could only use launches by key. The other way round would hide the ICBM behind every RPG target.
- Proximity alert (power-up, weight 5, passive): bombs placed while it is held carry the flag;
after the enemies move, each one with an enemy in its reach is set to go off that turn
(
proximityAlert), so an enemy already in reach when the bomb is placed sets it off too — with the player perhaps beside it. The danger overlay runs the same check on where the enemies' arrows say they will be, this turn and the next. - Slide (power-up, weight 6, part of Move, key L): over free floor until the next tile is a
wall, crate or bomb (
slidePath); it picks up what it passes (coins, charges, an uncovered gem), crashes into an enemy on the way (a life lost, like Run's) and burns in flame on the way unless flame can't hurt; the open exit stops it there. Highlighted is the end tile, 2 or more away (next to the player is a step). A teleport to the same tile wins (it can't crash or burn). - Keys: with an action picked, a direction now takes the nearest tile that way it can use, at any distance (it tried 1 and 2), so F, I and L then a direction work.
- Icons (
tools/compose-icons.sh, from Game Icons and Board Game Icons, packs already in use): RPG a bomb with a forward arrow, Proximity a bomb with a warning sign, Slide the Move figure with a two-way arrow, ICBM a bomb with a target. - The demo and clips: four new scenes (rpg, proximity, slide, icbm; 47 in all), the finger in the RPG, ICBM and Slide clips; the almanac, the offer, the top bar and the texts know all four.
- Offers and drops draw from more kinds now, so old runs would replay differently:
SIM_VERSION24. Tests: the new rules each (13), and every demo scene still found on every seed tried (40 per kind, the slowest 0.38 s). Checked in a browser: the four new demo scenes play.
2026-10-24 — A slide glides
User report: a slide looked jumpy, like half a dozen evenly spaced steps. Every move took a step's
0.16 s whatever its length, so six tiles went by in about ten frames, each a big jump. A move of
more than 2 tiles that isn't a teleport (a slide) now takes 0.07 s per tile — 0.42 s for six —
eases out (fast at first, slowing as it stops), and is drawn crouched, with no hop and no walking
frames (isSlide, moveProgress in effects.ts; the "You" tag and the Shield's ring follow the
same curve). Steps and runs are as they were. Checked on a video of the demo's slide scene: the
player moves a little less each frame down to the stop, crouched.
2026-10-24 — A tap on a slide's end slides
User report: tapping a tile a slide reaches, with nothing picked, walked there by auto-move
instead of sliding. A tap's meaning (tapMeaning) now has a slide after a step and a diagonal
step and before auto-move's walk: the tile where a slide stops, with Slide held, is a slide. A
tile that is also a teleport target still slides only if picked with Move (there a teleport wins,
being safe); with nothing picked, a tile you can walk to was never a teleport anyway. The demo's
slide scene now shows the plain tap (its caption: "tap where you stop").
2026-10-24 — No Workshop
User's call: drop the Workshop, the permanent upgrades between runs planned since the start (decision 8): "I can't imagine cross-game permanent upgrades that wouldn't ruin the whole thing." Every run now starts the same, and nothing a run gains outlives it. Nothing of it was built; the plan's mentions are gone (PLAN.md §7 says why). One knock-on, flagged to the user: Keep or Fresh (§9) was balanced on coins being worth more than their shop price, as leftovers would become Workshop progress; without it, Fresh's coin payout needs another look when §9 is built. Leftover coins at the end of a run now count only on the high-score board.
2026-10-24 — No Keep or Fresh; nothing carried between runs
User's call, on the flag raised with dropping the Workshop: no leftover coins carry over between runs, and a retry — "fresh or re-run" — is just another try at the same board, "no added benefit (except their knowledge)". So the Keep or Fresh choice after a death (decision 9: keep the run's power, or reset it for a coin payout) is dropped too, with its payout formula and its open questions. What is built already matches: a death with lives left restarts the level from its seed with everything the run had, and game over's Same dungeon starts the seed again from level 1 with nothing carried over. The rest of the plan no longer mentions it (§0, §5, §5b, §9, the run summary, the certificate, §14, the architecture and the milestones).
2026-10-24 — The shop
User request: the next thing, the shop. The user chose from the options offered:
- Every few levels, not after each one: after levels 3, 6, 9, 12, 15 and 18. The interval (3)
is my reading of "every few"; it is one constant (
SHOP_EVERY). It opens when the player goes on from the level-cleared banner, before the next level starts, and Next level leaves it. - Power-ups and charges (recommended): 4 items, drawn by the gem's rarity weights, charges at weight 7 (about an uncommon power-up); never a power-up already held, nor a Coin bag.
- Prices by rarity (recommended): 6 for Bomb +1 and Fire +1 up to 16 for Teleport and Proximity alert and 18 for a Heart; charges 6–9. A first guess to tune: the test run found to reach the shop earned 5 coins in 3 levels (one par bonus, no drops picked up), so prices may well be too high.
- Restock, getting dearer (recommended): 2 coins, then 3, 4… within a shop.
How it works: continue from a cleared level that is due a shop opens it (state.shop); buy
and reroll are new actions in the run record, taking no turn; continue again starts the next
level. The draw is seeded by the seed, level and restock count. The dialog follows the gem's
offer (select a card, its clip plays above, then Buy), with buttons that keep it open
(keepOpen in dialog.ts) and are greyed when unaffordable. A saved run closed in the shop reopens
in it. SIM_VERSION 25 (going on from level 3 now opens the shop). Tests: when it opens and
leaves, the draw (no held power-up, no Coin bag, seeded), buying (coins, at once, kept, once only),
too dear, the dearer restock, no board actions while open. Checked in a browser: reaching the shop
after level 3, selecting, restocking (5 → 3 coins), leaving to level 4; a new shop screenshot.
2026-10-24 — The shop leaves room for its items
User report: the shop's three big buttons (Buy, Restock, Next level, stacked) and its clip left little room for the items, so it needed scrolling all the time. Now the selected item has its own Buy button, inside its card ("Buy for 6", or "Need 1 more" greyed when too dear), and the dialog's buttons, Restock and Next level, share one short row; the shop's clip is smaller (at most 200 px wide, from 300). The cards are no longer buttons (a button can't hold another), but selectable boxes that the keyboard reaches too (Enter or Space). On a 390 × 844 phone the four items now show with only the last one's bottom lines below the edge, where before only about two did.
2026-10-24 — The ICBM flies
User request: the ICBM was instant; make it a smoothly flying bomb. The rule stays (it goes off
the turn it is fired, so nothing can step out of the way); only the drawing changes. Since the
bomb has already exploded in the state the screen shows, the effects keep a flight (from the
player to where it lands: a bomb in a high arc, turning once, 0.3 s plus 0.06 s per tile) and a
time, blastAt, when the turn's blasts show. Until then the flame, the lingering flame, crates
bursting, enemies dying, drops and gems burned or uncovered, the exit opening and enemies let out
are held back — crates and enemies stay drawn as they were — and then play as usual. The danger
overlay already shows where the blast will land while the bomb flies. Checked on a video of a
launch on a test board: the bomb arcs across the row and the blast starts as it lands.
2026-10-24 — Longer animations for rare or involved turns
User's guidance: "some turns can take longer than others. If we have a charge or something rare or involved, it's ok to take a bit more time and animate a more complex change of state." Recorded in PLAN.md §13 (Animation timing) as a rule for what comes next: ordinary turns stay at a step's pace; rare ones may play out in stages, the sim still settling the turn at once and the drawing holding back what comes later, as the ICBM's flight does. Input stays unlocked, as decided on 2026-10-22; the longer an animation, the more a quick tap can start the next turn over it — to watch for, and lock only if it looks wrong. First use beyond the flight itself: an ICBM that catches the player now shows the hit pose when it lands, not when it is fired.
2026-10-24 — The ICBM speeds straight, trailing smoke
User report: despite the name, the ICBM flew in a curve, as if thrown. It now flies straight along its line, picking up speed (its distance grows with the square of the time) — 0.25 s plus 0.05 s per tile — trailing seven puffs of grey smoke that grow and fade behind it; no arc, no spin. Checked on a video of a launch: the bomb leaves the player slowly, speeds along the row with the smoke behind it, and the blast starts as it arrives.
2026-10-24 — Diagonal widens Teleport into a square
User question: with Teleport held, what should buying Diagonal do? Teleport (3 steps across plus
down, over anything) already reached every diagonal step, squeezes included, so Diagonal added
only a one-tap diagonal step and the Q E Z C keys. Of the two options offered — widen Teleport, or
stop offering Diagonal to a Teleport holder — the user chose to widen it, "so it doesn't waste a
power-up if the order is weird": with Diagonal, Teleport counts a diagonal step as one
(teleportSteps), so its reach is a square, 7 × 7 (9 × 9 with Run too), instead of a diamond.
Run gives Teleport length, Diagonal gives it shape. Diagonal's own steps and keys are unchanged.
SIM_VERSION 26. Test: a tile 3 across and 3 up is out of reach without Diagonal, in with it.
2026-10-24 — Coins and prices for a reasonable shop
User request: make the shop reasonable, "perhaps leaning towards cheap", working out the coin gains. Before, a level paid almost nothing but its par bonus: 2 Blobs at a 20% coin chance (1.7 coins a drop on average) give under 1 coin a level, so three levels earned about 2 coins plus 5 for each level cleared within par, and a playtest reached the first shop with 5 coins, short of the cheapest item (6). Three changes, worked out in PLAN.md §8 (the coin budget):
- Every cleared level pays 2 coins (
CLEAR_BONUS), so even a slow player gets a baseline; par pays 5 more, as before. - Enemies drop coins more often: Blob 60% (was 20%), Seeker 80% (50%), Drifter 50% (20%), about 1 coin a kill. Charge chances are unchanged.
- Prices roughly two thirds of before: Bomb +1 / Fire +1 4, Fuse +1 5, most abilities and passes 6, Slide / RPG / Detonator 8, Flame Pass 9, Teleport / Proximity alert 10, Heart 12, Star 6, charges 3–6; a restock 1 coin, then one more each time.
The first shop now sees about 11 coins (two cheap things) to about 26 within par (three or four).
The banner says what the level paid: "+2 coins." or "+7 coins, under par!". SIM_VERSION 27.
2026-10-24 — Sounds and vibration
User request: "add sounds and vibrations (where possible)". Built as decided on 2026-10-20: the New
Platformer Pack's 10 effects, plus two from the UI Pack (a current pack, so no new pack was
brought in): tap-a for every button, switch-a for the gem's choice and a restock. ZzFX, built
into LittleJS, for what the packs lack: the blast, a bomb set down, the ICBM's launch.
- MP3, not the packs' Ogg: older Safari versions can't decode Ogg Vorbis, so iPhones a few years
old would have been silent. Converted with ffmpeg (
-q:a 4), same names. - Timed with the animation: sounds go off when what they belong to shows — a blast and its
kills at
fx.blastAt(an ICBM's on landing), a pickup when the walk ends — not when the sim settles the turn. A chain is one louder boom, not several at once. - Vibration where the browser has it:
navigator.vibrate, which Android browsers have and iOS Safari doesn't (no web page can vibrate an iPhone); the menu's switch only shows where it works. Short buzzes for blasts (longer for chains), a hit, a broken shield, pickups, a cleared level. - Switches in the menu, applied at once and remembered in this browser; both on by default.
- Silent demo and clips: they loop by themselves and would be noise; only turns the player plays make sound.
- No music, and no walking sound (every turn would tick). The ZzFX parameters were chosen without listening (the shell has no audio): to tune by ear.
2026-10-24 — A one-line Developer description
User request: with Developer mode on, its long description crowded the dialogs. In the game it now reads only "A special gem lies next to you on every level." (the New run dialog, and the modes tag's explanation). The details — every power-up not held and every charge, the level's own gem still hidden, the red border on achievements — stay in the README.
2026-10-24 — Android Chrome at half a frame a second
User report: on Android Chrome the game ran at about 0.5 fps, locally and on GitLab Pages, while
Firefox on the same phone ran smoothly. The likely cause: the CRT's PostProcessPlugin was on all
the time, a pass-through in play, and every frame it uploaded the whole WebGL canvas into a
texture (texImage2D from glCanvas). Chrome does that by reading the frame back from the GPU
and sending it up again, which stalls the GPU each frame — at a phone's 3× pixel ratio, ~10 MB a
frame. (Headless Chrome warned "GPU stall due to ReadPixels" too.)
- Fix, part 1:
postProcess.enabled(LittleJS 1.26.1): the plugin is made once with the CRT shader and is enabled only while the demo runs, so play does no copy at all. - Fix, part 2, the demo: LittleJS 1.27.1 copies the frame on the GPU (
copyTexImage2D) instead of uploading the canvas. Upgraded to the latest, 1.28.0 (2026-10-20, 4 days old), at the user's request ("let's pick up the latest version … it slows down the entire phone"): an explicit exception to the two-week pinning rule of 2026-10-19, which otherwise stands. The breaking changes of 1.26–1.28 (video capture and the Electron example removed; 3D cameras moved to a plugin) touch nothing here. - Checked by counting calls in Chromium: in play, no canvas copies or readbacks per frame (was one upload a frame); in the demo, one GPU-side copy a frame and no upload.
- Still to watch: a dialog's clip copies its room from the WebGL canvas into its own 2D canvas
with
drawImageeach frame (ui/clip.ts). Chrome may do that on the GPU or by reading back, depending on the canvas; only while a clip shows. - Not yet checked on the phone (no Android device here): the user is to confirm.
- Follow-up (same day): still slow on the phone with 1.28.0. User request: an FPS count. Added
ui/fps.ts, a meter in the bottom left (innpm run dev, or?fps): frames a second, the slowest frame, the game's own update-and-draw time per frame (so a slow frame with little game time points at the browser or GPU), the canvas size and pixel ratio. And three switches to narrow it down on the device:?gl=0(Canvas 2D),?ratio=1(no high-density canvas),?noaa(no WebGL antialiasing). In headless Chromium (software GPU) the default ran at about 44 fps and each switch at 60, so antialiasing is one suspect; the phone will tell. - Found (same day): the user's measurements on their Android phone in Chrome, with the meter:
12 fps as it was (game code 0.7 ms a frame, so the time was the browser's), 60 with
?noaa, 52 with?ratio=1, 60 with?gl=0, the same in the demo and in play; Firefox on the same phone, and Chrome, Firefox and Safari on the desktop, ran at 60 throughout. So it was WebGL's multisample antialiasing, which that phone's Chrome couldn't afford at full resolution. Antialiasing is now off (glSetAntialias(false));?aaturns it back on, for comparing. The board is sprites on a square grid, which antialiasing hardly smooths: the screenshots look the same. The meter and the switches stay, for the next slow device. - After the fix (same day): smooth on the phone. The meter no longer shows by itself in
npm run dev(user request), only with?fps. No automatic tuning to the device (user question, answered): the game draws a few hundred sprites, which any phone of the last years manages at 60 fps once antialiasing is off; the cost was one setting, not the hardware's limits. Such tuning (e.g. dropping the pixel ratio after a few slow seconds) is to add only if a slow device turns up, measured first with?fpsand the switches.
2026-10-24 — Sprinter, Ghost, Wisp and Hunter
User request: "can you add more enemies next?" Built the four of PLAN.md §10 (the Reaper stays for later), with the sprites §13 had chosen (mouse, block slime, fire slime, frog). The table's speeds had to fit plans of one move per turn, which the arrows, the danger overlay and Proximity alert all read:
- Every other turn is a rest, a planned move to the tile it is on (a dot among the arrows; eyes closed). Plans keep alternating as they are topped up; with nothing kept, the turn's evenness and the enemy's id decide, so a group doesn't all move on the same turns. A Freeze holds the plan as it is, rests included.
- A Sprinter's move is a dash of 2 tiles in a line (drawn as two arrows), 1 if the second tile is blocked. The middle tile counts: a bomb there stops the dash, and the player there is caught (the Sprinter stops on them). Auto-move's safety check doesn't step onto either tile.
- Ghosts and Wisps go through crates, as the NES's Kondoria and Ovapi went through soft blocks; drawn see-through inside one. Flame stops on a crate and burns it, so it kills one inside the first crate it reaches but not one behind it.
- A new type every 3 levels, not 6, so a 20-level run meets all seven by level 19. This moves the Seeker to level 4 and the Drifter to 7.
- Drops follow the table's words: Sprinter and Wisp mostly a charge, Ghost coins, Hunter both.
SIM_VERSION 28. Tests in test/new-enemies.test.ts.
2026-10-24 — Five proposals written into the plan (PLAN.md §19)
User request: think through five ideas, propose, and add them to the plan, to prioritise once the plan is agreed. Written as §19, each with its open questions: a balance lab (bots and paired experiments on the deterministic sim, since a full DFS/BFS of whole games is beyond reach: about 10^50 leaves; search is used a few turns ahead instead), bigger scrolling boards, hazards and objects (oil, barrels, bushes, metal crates, spikes, then acid, water, conveyors, ice, cracked floor, springs), the Acid bomb that corrodes pillars and brings the roof down, and a Gauntlet mode. Two conflicts with earlier choices are flagged there, not settled: bigger boards undo the one-size board of 2026-10-23, and bushes that hide things would break "everything visible" (§4). The Splat Pack, which the user named, would be a new art pack.
2026-10-24 — Answers on the §19 proposals
The user's answers, recorded in PLAN.md §19:
- Balance lab: the full version, with bots.
- Boards: a fixed view with the same number of tiles on every screen (bars beside it on a wide one), boards bigger in both directions, a view that follows the player, and edge indicators. On the user's question, a "concentration" scheme was proposed: enemies weighted by danger, divided by open tiles, rising along a curve fitted by the lab's bots to a target difficulty; board size then set by how long a level takes. The largest size is left to that measurement.
- Objects: all wanted; the Splat Pack is approved. On hiding things in bushes, five approaches were set out with their pros and cons; recommended: bushes hide drops and the player (from chasers), and enemies inside show faint — to choose.
- Rubble: crushes what is under it and stays impassable for good. Two new deaths: crushed, and trapped (the level can no longer be won), which costs a life at once.
- Gauntlet: one life back per cleared level up to 3, endless, and no passes in a loadout. The passes in the whole game are to leave the power-ups or become temporary charges: the balance lab's first experiment decides which. - Final answers (same day): bushes B + D + E as recommended; the 9 × 13 view; one acid bomb cracks a pillar and a second hit brings the roof down 4 turns later. The plan is agreed; the balance lab comes first.
2026-10-24 — Proposal: one charge at a time (PLAN.md §19.7)
User idea: carry one charge only; picking up another asks to sell the new one for coins or swap it for the one held. Worked out as a proposal, with three questions for the user: whether the swapped-out charge is sold or dropped, whether Freeze and Decoy become carried charges, and whether a charge should be used deliberately (a second bomb button) — which would also fix the Nuke trap the balance lab found. Not built. - Answers (same day): the swapped-out charge is sold; Freeze and Decoy become carried charges; charges are used deliberately with their own button (Shield stays passive). To build right after the balance lab's first report.
2026-10-25 — The balance lab, and its first report
Built as agreed (PLAN.md §19.1): tools/balance/, bundled with Vite into .balance/lab.mjs (the
sim is TypeScript; Node can't import it directly) and run on worker threads, one per core but one.
Choices made while building:
- Three skills of one bot, not three bots: they differ in how many turns ahead they check they
can survive (careful: as far as the enemies' plans and the bombs' fuses, up to 6; average 2;
sloppy 1) and how often they slip (0, 3%, 8% of turns: some action that doesn't kill at once).
The survival check plays the real rules (
step()), moving and waiting only. - What it is after: the open exit; else an uncovered gem or a drop; else a tile next to where a bomb would catch a crate or an enemy's route, with a walk out of the blast in time. A tile it keeps returning to costs a little more each time, so it moves on rather than waiting for ever.
- Only what a player sees: the hidden exit and gem aren't used.
- Paired experiments: the same seeds per arm; differences with 95% intervals.
- Gem and shop: the bots take the first thing a gem offers, and don't shop (coins aren't used).
The first report's findings are in PLAN.md §19.1. The biggest: Teleport and Flame Pass turn a weak player into a winner; Proximity alert costs a strong one 3 levels, by turning the defensive bomb into a trap; and Nuke charges can make a level impossible (the Nuke trap).
2026-10-25 — One charge at a time, used on purpose (built)
Built as agreed (PLAN.md §19.7, §3.3). Choices made while building:
- The slot is
state.charge(kind and uses left);boostsandshieldare gone. Shield is a charge in the slot that the hurt check spends. - Uses: the charge button (key V, after B for Bomb, since C is a diagonal step) stands for
charged(a Pierce, Power or Nuke bomb),icbm,freeze(acts at once, like Wait) anddecoy(on the player's tile or a free one next to them). It is hidden with no charge, or a Shield, and greyed while it can't be used (no room for another bomb). ICBM left the Bomb button. - The choice is
chargeOfferwithsellCharge/swapCharge, taking no turn, like the gem's. A second different charge picked up in the same turn, while one is waiting, is sold. A death that turn drops the choice (the retry has the charge lying there again). From a gem or the shop the charge goes straight in, the old one sold: those are chosen on purpose. - Price: half the shop price, rounded up, scaled by the uses left (2–3 coins for a full one).
- Drops: with every charge now carried to the next level, the last enemy may drop any charge (it dropped only the lasting ones); a Decoy, Freeze or Shield isn't dropped while one lies on the board.
- The demo skips any turn that would ask the choice (no gesture plays a dialog), and its captions name the charge button.
- A fix on the way: a run resumed with a choice open (a gem's, a charge's, or the shop) lost its dialog when the opening demo ended; it now opens again.
?charges=nis now?charge=<kind>.SIM_VERSION29.
2026-10-25 — Proximity alert: only an enemy next to the bomb
User request, after the balance lab found Proximity alert cost the careful bot 3 levels: a bomb
goes off only when an enemy is on one of its 4 neighbouring tiles, not anywhere in its reach. A
bomb dropped between you and an enemy coming down a corridor now waits until the enemy is at it.
- Follow-up (user report, 2026-10-25): in Developer mode a Decoy chosen from the rainbow gem
replaced a held Nuke without asking. Now every charge that comes along — picked up, chosen from a
gem, or bought (the shop waits for the answer, then comes back) — asks when another kind is held.
And the charge button is always shown (user request): the charge and its uses, or an empty dashed
slot; greyed out while it can't act. "What is that?" on it explains the button, carrying one at a
time and the sell-or-swap choice; to make that possible, "What is that?" now works on greyed-out
buttons too.
- Shield and Decoy (user request, 2026-10-25): "why does shield work by itself?" — now it is
raised with the charge button like the rest. Raised, it stays up 3 turns (the turn included;
SHIELD_TURNS, my choice, to tune) and absorbs the next hit; carried, it does nothing. Without a
limit it would be the old passive Shield behind one tap. The Decoy goes up to 3 tiles away in
a straight line, each tile on the way free (floor, no bomb, enemy or flame), as a slide or kick
stops at whatever is in the way; no longer on the player's own tile. SIM_VERSION 30.
- No time limits (user request, 2026-10-25): 3 turns was very short for both. A raised Shield
now stays up until a hit lands, however many levels later (shieldUp, carried with the run). A
Decoy lasts until the level ends, or until it takes a single hit: an enemy reaching its tile,
or flame. SIM_VERSION 31.
2026-10-25 — The demo's title on top; a compact menu
Built from the queued UI items (user request). The demo reads top to bottom: the title EXPLODORAMA! in its own bar, the TV (top bar included, moved down under the title), then the caption; title and caption go when the game starts. The menu: Resume and New (was "New run") side by side, then Almanac, Scores, Demo and Bug as one row of small word buttons (thinner edges so ALMANAC fits a quarter row on a phone). Words rather than icons: they say what they do at a glance, and the Kenney icons for an almanac or a bug report would need explaining.
2026-10-25 — A demo scene for the charge swap
User question: "is there already a demo written for the charge swap?" There wasn't: the demo
skipped every turn that would ask, since its finger only played the board and the action bar. Now a
chargeSwap scene starts carrying a Pierce with a Nuke lying near; when the pickup asks, the real
dialog opens over the scene (not dimming the caption, as "What is that?" does) and the finger taps
Take Nuke. A new gesture, choice, plays it (swapCharge / sellCharge); in a dialog's clip,
which has no dialog, the choice just happens. Only this scene keeps turns that ask. No separate
charge-button scene: each charge's scene already shows the finger pressing it.
2026-10-25 — Bigger boards, a fixed view, enemies by concentration (built)
PLAN.md §19.2, as agreed. Choices made while building:
- Sizes: 9 × 11 for levels 1–3, then a step every 3 levels to 15 × 21 from level 16 (growing in both directions, more in height, as phones are tall). The largest is a first guess; the lab is to measure how long levels take.
- The view: 9 × 13 tiles everywhere, the bars round it the page's dark ink (
#view-mask, a box whose shadow covers the rest). A tap outside the view does nothing, so nothing unseen is tapped. On a phone the 9 columns set the tile size, so the bars are above and below; on a desktop, beside. - Following is eased (a fifth of the way each frame) and stops at the board's edges.
- Indicators: enemies and bombs with a red arrow, the exit and gem with a white one; a picture on a pale disc just inside the edge, toward the thing.
- Enemies by concentration: threat per open tile, from 1/26 (2 Blobs on level 1, as before) to 1/11 on level 20; the weights by type are guesses for the lab to fit. Par grows with the board: 10 + 0.3 a tile + 8 an enemy (41 on level 1, as before).
- Screenshots: the tool asks the game where a tile is (
window.tilePoint), since the board scrolls; and?shop(with?level=3) opens the shop directly, since the key sequence that played through level 3 stopped fitting the new enemy counts. SIM_VERSION32.
2026-10-25 — Bigger boards, a fixed view, enemies by concentration (built)
PLAN.md §19.2, as agreed. Choices made while building:
- Sizes: 9 × 11 for levels 1–3, then a step every 3 levels to 15 × 21 from level 16 (growing in both directions, more in height, as phones are tall). The largest is a first guess; the lab is to measure how long levels take.
- The view: 9 × 13 tiles everywhere, the bars round it the page's dark ink (
#view-mask, a box whose shadow covers the rest). A tap outside the view does nothing, so nothing unseen is tapped. On a phone the 9 columns set the tile size, so the bars are above and below; on a desktop, beside. - Following is eased (a fifth of the way each frame) and stops at the board's edges.
- Indicators: enemies and bombs with a red arrow, the exit and gem with a white one; a picture on a pale disc just inside the edge, toward the thing.
- Enemies by concentration: threat per open tile, from 1/26 (2 Blobs on level 1, as before) to 1/11 on level 20; the weights by type are guesses for the lab to fit. Par grows with the board: 10 + 0.3 a tile + 8 an enemy (41 on level 1, as before).
- Screenshots: the tool asks the game where a tile is (
window.tilePoint), since the board scrolls; and?shop(with?level=3) opens the shop directly, since the key sequence that played through level 3 stopped fitting the new enemy counts. SIM_VERSION32.
2026-10-25 — Flame Pass and Teleport to become charges (scheduled)
From the lab's numbers (PLAN.md §19.1), the user decided: Flame Pass becomes a charge counted in flame hits — it saves the player from 3 fire hits, then runs out (not a number of turns, as proposed) — and Teleport a charge of 3 jumps. Crate Pass and Bomb Pass stay power-ups for now, to be playtested more. Scheduled as the next task, after the bigger boards (being playtested by the user). Backlogged in PLAN.md §18.
2026-10-25 — Shield and Freeze: a confirming tap
User request: the Shield went up the moment the charge button was pressed; add a confirmation.
Shield and Freeze are now aimed like the other actions, at one tile — the player's own: the charge
button picks them and lights that tile, and a tap on yourself uses them. Pressing the button again
also confirms (for these two only; for other actions a second press puts the action away), so the
keyboard needs only V, V. It is the same two steps as every other action, with nothing new to
learn. SIM_VERSION 33 (the actions now carry a tile).
2026-10-25 — Auto-move's way as a line of arrows
User request: the blue tiles of auto-move's way weren't enough; arrows on them, forming a line.
Now a line runs from the player through the middle of each tile of the way, with an arrow on each
tile pointing the way that step goes (drawPath in render/board.ts). A darker blue than the tiles,
so it reads over them; the first step's line and arrow turn red when that step isn't safe, as its
tile already did. Drawn under the pieces, so the player and enemies stay on top.
- Follow-up (user report, same day): with Run, every tile of the way lit up though only every
other one is landed on. The way drawn is now where auto-move will land, move by move
(autoRoute in sim/path.ts: autoMoveAction played out on the player's position, the first move
only where it is safe now): a Run's landings, a Teleport's, or each step. The walk itself
(playerPath) still decides auto-move's moves and when it ends. When the first move can't be
made now, the walk is drawn as before, its first tile red.
2026-10-25 — The shop's coins stay in view
User report: scrolling the shop's items moved "You have N coins" out of view. The coins line now sticks at the top of the shop's body, and the clip sticks just under it, so both stay while the items scroll.
2026-10-25 — Dragging the board moves the view
User report: a swipe on the board, meant to look around, teleported the player and a turn went by (the press landed on one tile and the release on another, which with nothing picked is a tap on that tile). Now, with nothing picked, a press that moves more than 12 CSS pixels is a drag: it moves the view (kept inside the board) and letting go does nothing, nor does a long press arm a bomb. The next turn brings the view back to the player, eased. With an action picked, sliding still changes the tile chosen, as before (the commit preview). When the view is dragged off the player, an edge arrow points back to them. Every player can drag the same way, so the fixed 9 × 13 view stays fair.
2026-10-25 — Cards show what they change, in numbers
User request: when a gem or the shop offers, say, Fire +1 to a player who already has one, show its
impact ("fire 2 → 3"). Each card of the gem's offer and the shop now has a line under the name:
Blast 2 → 3, Bombs 1 → 2, Fuse 3 → 4, Lives 3 → 4, Coins 5 → 15, a Star's turns
when one is running, and a charge's uses when the same kind is carried. The current value, then
the new one: one step, since an offer or a shop never holds two of the same thing. Abilities and
passes have no number, so no line (changeOf in ui/info.ts).
2026-10-25 — TNT, and a limit on every stat
User request: "make tnt a power-up that gives us 2 increases", and a maximum, "to limit
ridiculousnesses like board-spanning bombs". TNT raises the blast by 2 (its icon: the fireball
with Fire +1's badge; weight 6 against Fire +1's 20, price 7 against 4). Bombs, blast and fuse
each stop at 5 (first guesses, to tune). A stat power-up that would be cut short isn't offered by
a gem or the shop, so nothing is half-wasted; cards and the top bar's explanations show the limit.
On cards the user asked whether to show the history ("1 → 2 → 3"): the current value and the new
one, with the limit, say what matters for choosing ("Blast 2 → 4 (max 5)"). SIM_VERSION 34.
2026-10-25 — The almanac's tabs fit, and stay on top
User report: on a phone the three tabs ran off both ends of the row, and scrolled entries showed through them. The tabs now share the row equally at a smaller size (10 px Kenney Future), and the sticky tab row sits above the entries scrolling under it.
2026-10-25 — Auto-move uses Diagonal
User report: with Diagonal, auto-move walked round corners it could cut. The walk (playerPath)
now takes diagonal steps too when the player has Diagonal — squeezes included, as the action
allows — and a diagonal first step is taken as the Diagonal action. Among equally short ways the
search keeps its order (straight steps first), so the number of moves is what is saved.
2026-10-25 — Flame Pass and Teleport become charges; a death drops the charge; boards fitted
The user's order: stages 1–3 of the plan.
- Flame Pass and Teleport are charges. Flame Pass is raised with the charge button and a tap on yourself, like the Shield, and then takes the next 3 fire hits — a blast, a chain or lingering flame each use one; every flame in that turn counts as that one hit — whatever the level, until used. While raised the player may also walk into flame (each turn there uses a hit). Teleport gives 3 jumps from the charge button, with its reach as before (Run 4, Diagonal a square). It left the Move group, the meaning of a tap (a far tile only Teleport reached was a teleport) and auto-move, so a jump is only ever spent on purpose. Both power-ups are gone from the gem's offers and the shop's power-ups; the shop sells the charges for 7.
- The boards, fitted with the lab (40 seeds, careful bot). Up to 15 × 21, late levels took 100–145 turns; capping at 13 × 17 (and stepping later) brings every level to about 75–95, in the 80–120 aimed at. A sparser board (concentration 1/11 at level 20) made the bot hunt its last enemy for long (stalls 8%); 1/9 keeps it at 3% and wins at 93%. The average bot still dies early (about 5 levels), as before: the careful bot is no measure of difficulty for people.
- A death drops the charge carried where the player fell, with what was left; the retry has it lying there (or on the nearest open floor, if the player fell inside a crate with Crate Pass). A raised Shield or Flame Pass is kept. A pending choice now carries how much is left too.
SIM_VERSION 35. The lab now also measures each charge carried from level 1.
2026-10-25 — Measured: Diagonal without squeezing
User question: how would a no-squeeze Diagonal change things? A lab-only rule switch
(RunOptions.lab.noSqueeze, never set in play; the game is unchanged) makes a diagonal step need one
of the two tiles beside it open, for the action and for auto-move's walk alike. On 300 seeds: the
average bot gains +3.4 levels with it instead of +7.7 (wins +1% instead of +11%); the careful bot
+0.3 instead of +1.0, with more stalled runs (9%). So the squeeze is most of Diagonal's strength.
Left to the user to decide.
- Decided (user, same day): no squeezing is the rule. A diagonal step needs one of the two
tiles beside it passable, for the action and auto-move alike. The lab keeps the old rule as a
variant (options.lab.squeeze) to compare against. SIM_VERSION 36.
2026-10-25 — Board objects (first batch) and the Acid bomb; the worn look switched off
The user's order: "implement 2, 1, 3" — the worn dungeon, the first hazards, the Acid bomb.
- The worn dungeon was built (
render/decor.ts: Splat Pack stains in two of six looks per level, denser along walls) and then switched off at the user's word: the splats looked too alike and not the look wanted. The code stays, off, until another approach is chosen. - Bushes, metal crates, oil, spikes, oil barrels (PLAN.md §19.3), from levels 2, 3, 5, 8 and
- Choices made in building (the plan left them open): bushes smoulder a turn before catching; enemies avoid spikes entirely rather than planning round raised ones; barrels burst into oil and a reach-2 blast the next turn, not counting to the player's bombs; metal is placed only where everything stays reachable.
- Acid (PLAN.md §19.4): one bomb per charge; any later blast breaks a cracked pillar; the pillar's tile becomes floor and the 8 round it fall 4 turns later; crushed and trapped ignore Shield and Star (rubble would leave the player inside a wall).
SIM_VERSION 37. The balance lab (40 seeds, careful bot): wins 80%, 19.5 levels, 0.45 lives lost;
stalled runs 15% (9% before), no deaths on spikes; one trapped (the bot used a dropped Acid).
2026-10-25 — Shield and Flame Pass work while carried
User request: "while they are in the charge slot, they are active. No activation needed. When the
appropriate damage hits the player, the charge is used up, and the charge slot is once again empty."
So the shield and flamePass actions, shieldUp and flameHits are gone: the charge held is
the protection. A Shield's use goes on any hit but a falling ceiling; a Flame Pass carries
FLAME_PASS_HITS (3) uses, one per fire hit (and per turn walked in flame), the slot emptying with
the last — the 3 hits the user chose earlier the same day, kept. A death can still drop one (only a
ceiling or being trapped kills through a Shield). The charge button is greyed out while one is
carried; its "What is that?" says it works by itself. SIM_VERSION 38.
2026-10-25 — A Shield pushes the player out from under a falling ceiling
User request: under a falling ceiling, the Shield pushes the player to an empty spot; with none,
they die. Empty: open floor with no bomb or enemy, among the 8 tiles round them, the 4 straight ones
first (up, down, left, right), then the diagonals. The Shield is used up, and
absorbs the rest of that turn's hits as usual. A Star still doesn't help. Within SIM_VERSION 38
(not yet released).
2026-10-25 — Danger "later": a bomb's tiles shown from the start
User report: with a long fuse, a bomb's tiles weren't marked until its last turns (the overlay showed
only this turn and the next). predictDanger now also returns later: playing on from the next turn,
the player waiting and enemies left where they are, every tile burned (chains, oil, bushes included)
or crushed until nothing is left to go off (at most 12 more turns), minus what is already now or
next. Drawn as a red outline, so it reads as "not yet" beside the two fills. Not used by
auto-move's safety check or the bots, which still look at now only. No rule change.
2026-10-25 — Board scenes, the almanac's Board tab, and the lab's stalls
User request: work on the open items.
- Demo scenes for the objects:
bush(stepping into one),bushFire(a smouldering bush catching),metal(a flame stopped short at one),oil(a spill burning at once),spikes(stepping on them while down),barrel(a burst barrel going off). A room gets a few of the object by the player (placeObjectsin demo.ts); a scene's reset to turn 0 shifts the spikes' phase so they keep their rhythm. Found for every test seed like the rest. - The almanac's Board tab:
BOARD_TEXTin ui/info.ts, shared with "What is that?" so the two can't disagree. Four tabs don't fit a phone's row: two rows of two below 440 px. - The lab's stalls (15% of runs since the objects): the traces (the board printout now shows
the objects) found two causes. (a) The bot never bombed an oil barrel, so barrels shutting in the
exit or the last enemy (with a metal crate on the other side) stopped it: a bot fault, fixed in
the bot (barrels count as crates in its plans). (b) Its last Teleport jump landed in a one-tile
pocket between two crates: nowhere to step, nowhere to put a bomb, stuck for good — a trap a
person could fall into too. Rule: the last jump of a Teleport charge may only land on a tile
with a passable tile next to it (or next to the player's own); earlier jumps may, since the next
gets out (replaced the next day by "boxed in costs a life", below).
SIM_VERSION39. Now: stalls 3% (1 of 40, the bot dithering by an enemy, its old weakness), wins 90%, 19.1 levels, 0.53 lives lost.
2026-10-26 — Measured: bombs under the player (the NES rule)
User question: how would the original's placement — the bomb on the player's own tile — change
things? A lab-only rule switch (RunOptions.lab.underfoot, never set in play): Bomb (and a charged
bomb) goes on the player's tile; the player walks off it and can't walk back, enemies can't enter
it. The bots stand on the spot instead of next to it; nothing else of theirs changed.
npm run balance -- underfoot (reports in docs/balance/underfoot-*.md), 200 seeds:
| Bot | Levels | Won | Lives lost | Turns per level | Turns in danger | Stalled |
|---|---|---|---|---|---|---|
| careful, next to (now) | 19.32 | 93% | 0.24 | 83.4 | 5.4% | 4% |
| careful, under (NES) | −0.56 ± 0.61 | −6 ± 5% ✱ | +0.12 ± 0.13 | +12.3 ✱ | +5.0 ✱ | 8% |
| average, next to (now) | 4.11 | 0% | 3.00 | 75.5 | 3.4% | 0% |
| average, under (NES) | +0.62 ± 0.47 ✱ | 0 | −0.02 | +9.2 ✱ | +9.3 ✱ | 1% |
Read: slower (a bomb starts with you in its blast, and getting out takes turns), about twice the
turns spent in danger, own-blast deaths up for the careful bot; the weaker bot does a little better
(enemies can't step onto you while you stand on your bomb, and a bomb can't shut your only way
out). Half the careful bot's extra stalls are its refusal to spend a life: walled in by rubble
next to a crate it waited for ever, where a person would bomb out. That case is a soft-lock under
the current rule too: no free tile to put a bomb on, and the crate means it doesn't count as
trapped. Left to the user.
- Decided (user, same day): bombs stay next to the player, and the summary goes in the README
(Measured: bombs under the player). The variant stays in the lab. With it: boxed in costs a
life (boxedIn in actions.ts, death cause boxedIn): at the end of a turn, nothing available
but Wait, and no bomb, flame, falling ceiling, smouldering bush or crumbling crate left that could
change it. A Shield or Star doesn't help. It replaces the Teleport restriction of the day before
(the last jump couldn't land in a dead end): the user would rather the player pay for the
mistake, and it covers rubble boxing the player in next to a crate too. Lab: stalls 0% (40
seeds), wins 93%.
2026-10-26 — Bug reports to an address on fuzzyworld.net
The project moved off GitLab (to code.fuzzyworld.net and apps.fuzzyworld.net), so GitLab Service
Desk is going away. BUG_EMAIL is now bugs-k7q4+explodorama@fuzzyworld.net: one forward-only
address shared by Pavel's apps, with +explodorama telling them apart. The random part makes the
address rotatable if it draws spam. (The old address was the fuzzyworld project's Service Desk,
not this game's.) The email itself is unchanged.
2026-10-26 — No tap-to-wait; deadly moves marked, and refused by the shortcuts
User request: a tap on the player waited, which could be deadly; so could a tap in front of an
enemy or into flame. Asked: remove the tap-yourself shortcut, and "mark or disallow" moves that
guarantee a death, a button in red if pressing it would.
- A tap on yourself does nothing. Wait is its button (Space). The Freeze charge's
confirming tap on yourself, with the charge button picked, stays.
- Deadly means the player loses a life this turn, found by playing the action on a copy
with step() (isDeadly, sim/danger.ts), so it can't disagree with the rules: a Shield or Flame
Pass taking the hit, or Immortal, isn't deadly. Only one turn ahead: "every follow-up dies too"
(a dead end a blast fills next turn) would need a search, and later enemy moves aren't known yet.
- Both mark and disallow, split by how deliberate the input is. Shortcuts (a tap with
nothing picked, direction keys with nothing picked, the long press, auto-move) refuse a deadly
move, with a red flash and a buzz. Deliberate inputs only mark it: Wait turns red, a picked
button's deadly tiles are red with a cross, and both still act. Disallowing those too could
leave a player with no move at all (every choice deadly, e.g. boxed in), and dying on purpose
is the player's call.
- The demo's gestures follow the same rule (a deadly step is shown as Move then the tile), so
what it shows still works in play. No SIM_VERSION change: the rules are the same.
2026-10-26 — Acid becomes an area weapon
User report: an acid bomb placed diagonal to pillars left them untouched, since its flame was a
plain cross and only the first pillar an arm ran into cracked. Asked for "an area weapon, like the
nuke". Offered three readings (cracks in the Nuke's diamond with the usual cross of flame; the full
Nuke area; cracks in the 8 tiles round it); the user chose the full Nuke area: the acid bomb
burns every tile within NUKE_RANGE (2) steps, through anything, and every pillar in that diamond
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.
2026-10-31 — Semver releases and an in-game list of changes
User request (2026-10-31): the game gets semver versions, git-tagged releases, and a "what's new
since you last played" dialog with demo scenes and text. Decided with the user: 0.1.0 is the
base (no history is rebuilt; the list starts there); the last version played is kept in
localStorage (explodorama.lastVersion); Claude keeps the version, UNRELEASED and the tags
consistent, Pavel decides when a release happens. SIM_VERSION stays as the rules version
(shown as "rules 40" beside the release) and is independent of it. The data lives in
src/changelog.ts (releases, each change with kind, date, text and optional clip); a first
visit only records the version and shows nothing; the dialog opens when the demo ends. Tapping the
version in the menu lists every release. A change reaching players goes into UNRELEASED as it
is finished; tagging moves them into a new release and bumps package.json (checked by
test/changelog.test.ts).
2026-10-31 — Choices on a tap, crate approach, fireCharge, demo keys and shuffle (0.2.0 work)
User requests, built together: (1) a tap on a crate walks to the tile beside it (approachTile;
with Run it did nothing); (2) a tap or arrow key on a bomb that two actions could answer asks which
(tapChoices, tapMeaning 'choose'): kick, walk onto it (Bomb Pass; a tap on a bomb with Bomb Pass
alone now walks too, it was refused), set it off. Walking onto an oil barrel stays a button-only
move. (3) The charge-swap dialog's third answer, fireCharge: use the charge held, then take the
new one (what remains of the held one is sold). Only a charge with nothing to aim can be used there,
which today is Freeze (aimed at the player's own tile); bomb charges, Decoy, Teleport and ICBM need a
target, so the third button is not shown for them (open: aim first, then swap?). No SIM_VERSION
bump: only a new action, which no old run holds. (4) Keys no longer end the demo, only a click or
tap; Tab/Enter/Space still work its buttons. A shuffle button (the dice, explodorama.demoShuffle) plays the
scenes in a random order (scenePlan), the choice kept across visits.
2026-10-31 — Released and next copies; early adopters
User questions, 2026-10-31: how to try main while everyone else stays on the tagged release, and
an incentive to do so. Decided: two deployed copies. Released: fw deploy --ref <tag> to
/explodorama/. Next: fw deploy --as explodorama-next from main (fuzzyworld-tools gained --as
and the vite plugin reads FW_APP, uncommitted at the time of writing). The copies share an origin,
so each has its own localStorage keys (src/channel.ts); one key, explodorama.earlyRuns, is
shared. The incentive is a teaser and a link, not a gameplay reward: the released menu shows "Try it
early", the Changes dialog "Coming next" (from the next copy's next.json), scores finished on the
next copy are marked "Early adopter" and counted. Rejected: giving the Developer rainbow gem on the
next copy (scores would stop being comparable; Developer stays a separate option for either copy).
Scores are per copy, as storage is.
2026-10-31 — Scripted-or-found demo scenes: found, with a push; timeline bar
User question: should demos be scripted rather than found by random play? Kept: scenes are found by
random play on a random room (varied, and always by the real rules), but a scene can now start
from a set-up state (prepare: rubble starts with a pillar already cracked), ask for a longer
run (maxSteps) and play a tail after its moment (tail: waiting turns, while safe). Scripting
a whole sequence (Acid, then a second bomb, then waiting) was not needed once the set-up did the
first half. The Almanac's Rubble clip now plays the rubble scene, not acid. A timeline bar
(ui/timeline.ts) under the demo caption and every clip shows turns played, with a mark at the
scene's moment; chosen: normal speed, turn-by-turn fill, on both.
2026-10-31 — Oil makes the player and the basic enemies slide
User request (Pavel, 2026-10-31), details decided with him the same day: landing on oil, the player
and the basic enemies (everything but the flying Ghost and Wisp) slide on, over oil only, and stop on
the last oiled tile of the line; a slide that ends in flame kills. Chosen where it was left open:
(1) the slide goes the way the step went and is straight, it does not follow a bend in the spill;
(2) it stops short of a bomb (unless Bomb Pass) and of an enemy for the player, rather than running
into them (a slide into an enemy would be an unforeseen death; walking in still is one); flame on
the way does not stop it, as with the Slide power-up, so it burns; (3) a diagonal step, Slide and
Teleport don't start a slide (a diagonal one would need squeeze rules); (4) the action's target stays
the neighbouring tile, so validation and replays are unchanged: the slide is a consequence worked
out in step() (player) and planning (enemies). Auto-move (path.ts) plans on tiles it lands on,
so it keeps off any step that would slide (slips), else it could bounce past its destination
forever or slide onto the open exit (reviewer, 2026-10-31): a tile in the middle of a spill is not
an auto-move destination, a single tap next to you still moves and slides. Walking onto an enemy on
oil is a death although the slide carries on. Known, left: slides pass over raised spikes and the
Decoy without effect; (5) an enemy's slide is
announced like any move: its plan holds the tile where it stops, so the arrows and the danger
overlay stay truthful (a doubled arrow, as for a Sprinter's dash). SIM_VERSION 41.
2026-10-31 — The Kenney credit leaves the in-game menu
User request (Pavel, 2026-10-31). This reverses the 2026-10-21 decision that the credit lives in the menu. Kenney's packs are CC0, so no credit is required; the README's Attribution section stays as the credit (and still thanks Kenney and links his donation page).
2026-10-31 — Two gems on one turn offer one after the other
User report: a slide (oil, or the Slide power-up) over two gems used the first gem's offer and
dropped the second unseen. Now the second waits in waitingGems and its offer is drawn when the
first is answered (and any charge choice from it), so it knows what the first gave (a held ability
is not offered again). Event gemQueued makes the second gem rise away at pickup (sound too); offerAgain opens its dialog. It works when the first gem's slide ends on the open exit, too (the level is cleared, the offers still come). A death the same turn drops the queue;
the retry has the gems back. SIM_VERSION 42.
2026-10-31 — Developer mode's test scenes
User request: to check edge cases by eye (two gems on one turn, and the like), add test-only demos
and almanac entries to Developer mode. They are fixed scenes (SceneDef.fixed, built by
sim/testScenes.ts): a hand-drawn room from sim/picture.ts (the tests' board, moved into src/;
% is a gem) and a scripted game, not a random rollout, so they are exact and repeatable. Shown on
the almanac's Tests tab in a Developer run (each with "what to expect") and in the landing demo
with ?dev. In the demo the gem and charge choices open as real dialogs (new gesture pick); in
an almanac clip they are answered unseen. test/test-scenes.test.ts asserts each scene's outcome,
so the same cases are checked without looking. To add a case: a name in TestKind and a TEST_SCENES entry; the
demo table and icons follow from the list. Not in the 0.2.0 change list (a developer aid).
2026-11-01 — Which version is this: a box in the menu
User request: make it obvious in the menu which version is playing and how to switch. The menu now
opens with a box: Official version / Early adopter version, a button to the other and both
addresses. Addresses come from package.json (homepage, new nextHomepage) through vite.config.ts
(VITE_HOME_URL, VITE_NEXT_URL) into src/channel.ts, one source for the app and the README. The
old "Try it early" / "Back to the released version" links in the menu's bottom line are gone (the
Changes dialog's teaser keeps its link). The released copy shows the button only once next.json
answered. Not shown in npm run dev (channel local). Kenney's credit stays out of the menu
(since 0.2.0 work, 2026-10-31); CC0 needs none.
2026-11-01 — A toast for a charge picked up again
User request: a charge picked up for the second time in a run is simply taken (no dialog, as the
player knows the idea), but the icons are small, so say what it was. New ui/toast.ts: a note with
the charge's picture and name above the action bar, fading after ~2 s, one at a time. Shown for any
charge pickup that opened no dialog (not the first of its kind, not a sell-or-swap); not in the
demo. The toast is the general mechanism for other "you got X" notes later.
Follow-up, same day: the toast says how many were picked up and the total held ("Picked up 3 × Nuke",
"5 charges in all"; the second line only when more is held than was picked up). It is a popover, so
it also shows over dialogs. Rule: each new kind of UI piece like this gets a sample on the
almanac's Tests tab ("Interface pieces", tap to show; TOAST_SAMPLES in ui/almanac.ts), so
Developer mode can show it without reproducing the situation.
2026-11-01 — Clips show the choices; almanac tabs wrap
User report (screenshot): five almanac tabs ran over each other (the Tests tab made five); the tabs are
now a grid of as many equal columns as fit, none under 104 px. And a clip of "slide over two gems"
only showed the slide, not the two dialogs. A clip now draws the open choice over itself
(showChoice in ui/clip.ts): the gem's cards with the scripted pick lit and "another gem is
waiting", or a charge's keep/take; held 2 s, then the pick plays. (The real dialog isn't opened in a
clip: it would cover the clip.) Only test scenes have such choices.
Timing, same day: the choice comes after the turn that led to it has come to rest (a ~1.1 s pause in a
clip, 1.2 s in the ?dev demo), shows ~2 s, goes, and the second follows after a pause (the clip's
own gap after the first goes, then 0.2 s), rather than at once over the slide.
2026-11-01 — A dragged view stays put through turns
User report: the view snapped back to the player after every turn, a sudden jump, even when they
had put it there on purpose. Now a drag "holds" the view (held in camera.ts): a turn moves the
player but not the view (keepView shifts the offset by the move). It follows the player again
when a turn would take the player out of the view (checked after the turn, not while dragging, so a
far look stays put) and on a new level, a new run or the demo (resetView). Supersedes "the next turn brings the view back" in the 2026-10-25 entry above.
2026-11-01 — Controller support: pure core first (Q29, stage 1)
Controller support is mandatory before Steam. It works like the keyboard plus a way to aim at tiles (first a tile cursor, then, in stage 2, a target among the blue tiles), and
calls the same functions (tapTile, direction, action buttons, inspect), so every rule stays shared.
Stage 1 is two pure modules with tests, nothing wired into the game yet: ui/padMap.ts (a gamepad
snapshot to intents) and ui/spatial.ts (which element to focus next in a direction).
Map (W3C standard mapping; face buttons and shoulders superseded by stage 2 below): D-pad and left stick move (stick in 8 sectors, d-pad can give diagonals,
the caller filters by the Diagonal power); A confirm; B cancel; X Bomb; Y Wait; LB/RB previous/next
(charge/move on the board, tabs and scenes elsewhere); View: What is that?; Start: menu; R3: reset
view; right stick looks around. A held direction repeats after 300 ms, then every ~110 ms; the left
stick turns on at 0.5 and off at 0.35; the right stick has a 0.2 deadzone. The first frame of a
fresh pad only records what is held, so the press that wakes the pad in Chrome does nothing.
Dialogs: Cross/Triangle/Square pick buttons directly in small dialogs (Q42, to be settled in
stage 4). Desktop wrapper for Steam: Electron (Q41). Full plan lives in the queue (Q29).
Stage 2 (board play, wired in; ui/gamepad.ts): the pad calls the same functions as keys and taps
and never fakes pointer events. First attempt, a free tile cursor (the d-pad moved it, A tapped
it), was replaced the same day by Pavel's design, which reuses the action bar: the face buttons are
Cross Move/Go, Circle Wait, Square Bomb, Triangle Charge (the intent also carries the raw face
index, since dialogs will map them differently); LB cancels, RB and View ask "What is that?", Start
is the menu, R3 is resetPan (the view follows the player again, not the full resetView). RB or View
turn "What is that?" on and aim at the nearest tile with something to explain
(padTarget.explainableTiles: what tileInfo explains, not plain pillars or the outer wall);
the d-pad jumps between them, the label says "Find out what this is", RB or View again explains
the tile (the call a tap makes in inspect mode), LB cancels.
Pressing a face button picks that action as its action-bar button does, so the blue tiles show
(pickTargets, the same list a tap uses), and aims at the nearest of them (padTarget.ts
firstTarget), drawn with a dashed square from Kenney's Crosshair Pack, breathing slightly. The
d-pad and left stick then jump between those tiles (nextFocus, board coordinates, nothing wraps);
the right stick looks around. The same face button again does it (tapTile, so choices, the
deadly check and Freeze work as for a tap); another one switches action; LB (cancel(), shared
with Esc) puts it away. Wait, and Go (Move with a flag set), have no tile: they aim at the
player's own tile and the same button does them. With nothing aimed, the d-pad and left stick walk
as the arrow keys do (a diagonal only with the Diagonal power). A one-line label over the action
bar, only while the pad is in use, says what confirming does (ui/targetLabel.ts, covering
Move/Run/Diagonal/Slide/Kick, Bomb/Throw/RPG/Detonate, each charge, Wait and Go, and "costs a
life!"). The view moves for the target only near its edge (revealTile) and then stays put; the
aim ends when a turn is played, a level or run starts, or a mouse, touch or key acts. LittleJS's
own gamepad reading is disabled (setGamepadsEnable(false)). The pad is ignored while the window
is unfocused, a standard pad is preferred, and a non-standard one only gets one "not recognised"
toast. In the demo the d-pad left/right switch scenes, at most every 400 ms, and Cross, Circle,
Square, Triangle, LB or Start end it.
Action-bar glyphs (Q45): while body.pad is set, each of the four action buttons shows its face button
(ui/padGlyph.ts, Kenney Input Prompts, the colour variants, Default size) where the key hint sits,
and the hint is hidden (not removed, so the button keeps its size). The family comes from
padFamily when the pad connects (controls.setPadFamily): PlayStation gets Cross, Square,
Triangle and Circle; Xbox and any other standard pad get A, X, Y and B. They revert with the
existing removal of body.pad on a pointer or key.
Stage 3 (dialogs and the banner; ui/padDialogMap.ts pure and tested, ui/padDialog.ts DOM glue,
Pavel approved the layout): a dialog's footer buttons map to face buttons: the primary one to
Cross, the others left to right to Triangle then Square, and the button that dismisses (by label:
Close, Cancel, Back, Got it, Keep playing, Resume; only if the dialog is not required) to Circle.
Circle with no such button closes via dismissDialog() (what Esc does). If the buttons do not fit
(more than three besides the dismissing one), or the body has controls to move between (menu,
almanac, New run, the changes list), "selector mode" applies: a d-pad selector (nextFocus over
getBoundingClientRect) covers the body's buttons, links, switches and the footer, and Cross
clicks the focused one. Cards (.offer-choice: gem offer, shop) are not "controls": moving onto one
clicks it (selects it, plays its clip) and Cross presses the footer's primary (gem offer) or the
selected card's Buy (shop, where Cross is thus taken and Restock and Next level get Triangle and
Square; a plain Cross there would have left the shop). The body is watched with a
MutationObserver, so any rebuild (shop purchase, almanac tab, Back) re-applies glyphs and the
selector (same index; the shop and gem cards prefer the one with aria-pressed); a new dialog
(event dialogopen) starts the selector on the footer's first primary button in selector mode
(Resume, Start), else the aria-pressed item (the almanac's current tab), else the first element.
In selector mode Circle presses the footer's dismissing button when there is one (so Back runs its
action), else closes. The shop's Buy always uses the card marked aria-pressed at press time. Moving
the selector scrolls the body clear of its sticky tabs, clip or coin line. LB/RB click the previous/next [data-tab]; the
right stick scrolls #dialog-body (900 px/s at full push). A text field is never selectable (a
pad cannot type): the seed stays for keyboard. The banner's visible buttons use the same mapping
(first Cross, then Triangle, Square). Known gap: Back from an almanac entry or a change returns to
the almanac's current tab, not to the entry that was opened (the scroll position is kept). View
(like RB) also switches tabs, as the pad cannot tell them apart.
After Pavel's first pad test (board only): the right-hand face buttons stay. The left stick is off
(LEFT_STICK_ENABLED = false; mapPad takes a leftStick argument so its tests still run). The
d-pad with no aim does not walk any more: it aims the neighbour tile (aim.button = 'step'), and
the label and the later confirm both come from directionPlan / diagonalPlan, the very function
the arrow keys run, so every keyboard rule (kick, run, Bomb Pass choice, the deadly refusal) holds;
a step that can do nothing shows "Can't go there" and the crosshair without the blue tile. Cross
confirms any aim (confirmAim); Circle backs out of any aim (padBack: the pick it made goes, the
flag stays) and, with none, aims Wait (so Wait needs Cross: one slip no longer ends a turn).
LB / RB are prev / next (LB no longer cancels, RB no longer inspects; View alone does): on the
board they cycle through one list (padCycle.ts): for Move, Bomb, Charge, Wait in that order, each
button's pickTargets tiles (nearest first), or a single item for a button that goes at once (Wait,
Go); it is built when the first press finds no cycle aim, kept in the aim, and thrown away with the
aim at the end of a turn. The label gets "CURR/ALL · ". Cross picks the item's button as a face
button would and taps its tile (tapTile, so a choice dialog asks; a deadly item is still played, the label having warned "(costs a life!)", as with a picked action by touch: only the d-pad step and a tap with nothing picked refuse). The d-pad does nothing
while cycling. In dialogs LB / RB switch tabs; View does nothing there. The demo is ended by LB no
more (only face buttons, Start).
The cycle aim looks as if its action were picked without picking it: drawBoard takes pick (the
action whose tiles are lit blue, and which of them cost a life, computed when the aim is made) and
ghost (an action and tile whose result the existing commit preview draws faded: bombs with their
badges and blast, a decoy, the moved player). The d-pad step aim uses ghost alone. No separate
sprite table was needed, since the preview plays the action on a copy of the state. Not previewed:
Wait (nothing changes), and a Freeze or ICBM beyond what the commit preview already draws.
Tile first (supersedes the aim, d-pad step and LB / RB cycle models above, 2026-11-01, Pavel after
testing them). The pad now has a cursor, a tile among the actionable ones (ui/padTiles.ts,
pure and tested): the player's own tile (Wait) and every tile pickTargets gives for Move, Bomb or
Charge. All of them are lit blue (View.lit) and the cursor is the dashed square. The d-pad moves
it with nextFocus on board coordinates; the first press with none showing puts it on the nearest
actionable tile that way, or on the player's own tile. It is dropped when a turn ends, a level or
run starts, a dialog opens or a mouse/key takes over, and on any other change it stays if its tile
is still actionable, else goes to the nearest one that is, else goes (refreshCursor). The face
buttons act on the cursor's tile AT ONCE, through the path a tap with that button picked takes
(selected set, then tapTile; Wait is pick('wait')): a choice dialog for a bomb still asks.
There is no second press to confirm. A button's availability on the tile comes from resolvePick
(Wait: only the player's tile). A button is marked deadly by isDeadly of the action it resolves
to there ({id, target}), and of waiting for Wait: the same test as the board's red tiles and the
Wait button's red. A deadly action is played, since the button was marked; only a tap or key with
nothing picked refuses. Greyed ones flash when pressed. With no cursor, a face button only shows the
cursor on the player's own tile. The label lists the possible buttons with their names; the ghost
is the first possible of Move, Bomb, Charge (none on the own tile). LB backs out; RB and View are
"What is that?" (aim kind inspect, unchanged); in dialogs LB / RB (and View) switch tabs. Removed:
padCycle.ts, the cycle and step aims, same-button-confirm and Circle-backs-out. The keyboard's
directionPlan stays (the arrow keys use it).
Glyph layout and families: the action bar's picture now sits beside the icon on one row (36px icon,
32px picture, both able to shrink on a narrow bar; the key hint is hidden under body.pad), and
the cursor's label draws the button picture before each line instead of its name. A Nintendo
family (padFamily: ids with nintendo, switch, pro controller or vendor 057e) uses the Switch
set (plain: the pack has no coloured variant); glyphs are chosen by physical position, so the
bottom button (index 0, Move) shows B, left (Bomb) Y, top (Charge) X and right (Wait) A. The cursor
tile is drawn red when every action possible there would cost a life (cursorDeadly); if only
some would, it stays blue and the red-marked buttons say which.
Cursor always on (Pavel, 2026-11-01; supersedes "the first d-pad press shows the cursor" and "a face
button with no cursor shows it" above): while a pad is in use and the board is being played (no
dialog, not the demo, not "What is that?", status playing) the cursor is always shown
(cursorWanted, ensureCursor each frame). It rests on the player's own tile (restingCursor)
whenever it was cleared: a turn ending, a new level or run, a dialog closing, "What is that?"
ending, an action done. The d-pad moves it from there with moveCursor (no "nearest tile that
way" special case; firstCursor is gone). A face button always acts on the cursor's tile or
flashes if it cannot. L1 clears "What is that?" first, then a leftover pick and the flag, and
returns the cursor to the player's tile. A key or mouse still removes the cursor and the pad
styling (padOff); the next pad input brings it back on the player's tile, and that waking
face press only shows it (so a stray Circle does not wait a turn).
2026-11-01 — A screen shows the whole board when it fits (supersedes the fixed 9 × 13 view)
Decided by the user, knowing it changes the earlier rule that no screen sees more than another (PLAN.md §19.2, decisions.md 2026-10-24/25: the view is 9 columns × 13 rows). From the UI rework (Q52) on, the number of tiles shown depends on the screen: the view grows with the space, up to the whole board, and a screen too small for the board scrolls as before (phones keep the 9 × 13 view or whatever fits at a playable tile size). So early boards (9 × 11) always show fully, and a large screen shows a late 13 × 17 board whole. Trade-off accepted: a big screen sees more of the board than a phone; scores from different screens are therefore not strictly like for like. Rules and SIM_VERSION are unchanged (the view is render and UI only). Still to do when the camera part is built: update PLAN.md §19.2 and the README, and decide whether the score board notes the screen class. Not built yet.
2026-11-01 — The level finishes itself once the last enemy is gone
Superseded 2026-11-02 by "Autopilot replaces the hoover (Q73)"; the hoover action no longer exists.
User request: on a controller, tapping each coin, gem and charge and then the exit was a pain.
Decided with the user: (1) the gathering is a new sim action hoover taking no turn (like buy),
so runs record and replay it and old records are untouched (no SIM_VERSION bump); (2) the walk to
the exit costs turns as ever, par and turn count included; (3) gems queue their offers one after
another and a charge choice pauses the gathering; (4) it applies to every input, with a setting
("Finish the level for me", on by default); (5) auto-move's safety rules apply, no way to the exit
is the existing boxed-in life loss, and when the only way is unsafe it waits for the player.
Triggered once per level by the UI (autoFinish in main.ts), ended by the player's own move or
cancelling the flag.
2026-11-01 — Fullscreen from the first tap, click or key
User request: start fullscreen. Browsers only allow requestFullscreen() after a user gesture, so
the game cannot do it on load: the first pointer or key event does it, once (leaving it with Esc or
F11 is respected). A controller press is not a gesture, so a pad-only player stays windowed until
touching the screen or a key. A setting, "Start in fullscreen" (on by default), turns it off. No
in-game toggle key (F is the RPG bomb; F11 is the browser's own). The Electron build (Q30) will
simply start fullscreen. iPhone Safari offers no Fullscreen API for pages, so nothing happens there.
2026-11-02 — Fullscreen only with a controller; a button; a menu switch (Q60)
User request, amending 2026-11-01. Decided: (1) the first-gesture auto-fullscreen now happens only
while a controller is connected (shouldAutoFullscreen, ui/fullscreenRule.ts, pure): the pad
player has no pointer to press a button with, a phone or mouse player does, and being pushed into
fullscreen annoyed them; "connected" is a pad in navigator.getGamepads() or one the pad input has
seen (Chrome lists a pad only after its first press); the "Start in fullscreen" setting stays and
is worded for it; (2) a visible fullscreen toggle in the top bar, only where document.fullscreenEnabled,
following fullscreenchange (so F11 and Esc update it); on a phone's portrait bar three stacked
round buttons would grow the bar, so all three shrink to 31 px then (the bar's height is
unchanged); icons are Game Icons larger / smaller; (3) for a controller, a "Fullscreen" switch
in the menu's Settings, no pad button (every one is taken); a pad press is no browser gesture, so
in a plain browser the request can be refused: the page calls it anyway and, if it rejects, shows
"Press F11, or use the fullscreen button". The Electron wrapper (Q41) will make it work.
2026-11-02 — No automatic fullscreen; the cursor stays on the destination while walking (Q70)
User request after testing. Decided: (1) the game never goes fullscreen by itself. This supersedes
2026-11-01 "Fullscreen from the first tap, click or key" and the Q60 rule that made it happen only
with a controller (shouldAutoFullscreen and ui/fullscreenRule.ts are gone, with the "Start in
fullscreen" setting and its stored value, which is simply no longer read). What stays from Q60: the
top bar's fullscreen button, the menu's "Fullscreen" switch (with the toast "Press F11, or use the
fullscreen button" when a pad press is refused, since it is no browser gesture) and the
fullscreenchange syncing; (2) bug report: with a pad, after the first step of an auto-move walk the
cursor went back to the player's tile, where only Wait was offered, though the route was still drawn.
Now the cursor starts each turn on the destination while a walk is on (cursorStart in
ui/padTiles.ts, pure; ensureCursor in main.ts; the destination is a faded candidate, label
"Keep walking · N steps"), so Go continues at once; when the walk ends (arrived, cancelled, blocked,
new level) it starts on the player again. The keyboard cursor shares the code.
2026-11-02 — The demo's input button (Q61)
User request: a demo button cycling touch, mouse, controller (ps5, xbox, switch, generic). Decided:
(1) the pick overrides detection for the demo only, session only (demoForced, not stored), and
the scene restarts for it (a switch between the two scene lists is the existing between-scenes
switch, from the button it is immediate); (2) what clears it: the demo ending, or the controller setting changing (one plugged in or unplugged,
dropDemoPick); amended the same day at the user's request, an earlier rule also dropped a touch or
mouse pick on a real pad press. Nothing else clears it: no mouse move, click, tap or button press, so
the button keeps cycling and the pick stays wherever the user stopped (a mouse player must be able to
watch the controller demos, and a pad player the touch ones); only holding the confirm button, which
ends the demo, leaves it; (3) mouse is the touch scenes with a pointer in the finger's place and "Click anywhere
to play"; the pointer is cursor_none from Kenney's Cursor Pack (assets/kenney-cursor; user supplied the
pack, 2026-11-02, replacing a stopgap sword); the touch
captions still say "finger"; (4) the Q59 hint "Hold {move} to play" and the pad family drawn in
captions and on the virtual controller follow the effective input, not the real pad; (5) clips
keep following the real input. No rules change (no SIM_VERSION bump).
2026-11-02 — Controller demos: the pad's own demo and clips (Q58)
User request: with a pad in use, the demo and the dialogs' clips show the controller, not the finger.
Decided: (1) the virtual controller (ui/padOverlay.ts) is CSS shapes with Kenney Input Prompts
glyphs on them, no drawn art (superseded by the later Q63 entries: it is now a line drawing from OpenGameArt) (art-provenance #16 notes the outline as layout, like the old-TV look);
(2) the input is switched only between scenes (and a clip at its next loop), never mid-script, so
a scene is never half finger and half pad; (3) switching to the pad starts its list at scene 1, and
switching back resumes the touch list where it was left: the two lists keep separate saved places;
(4) the pad's list is its own scenes (cursor, L1, R1, the stick, Start) plus the controls and
gameplay scenes without the touch-only ones (long-press bomb, auto-move, the top bar, "What is
that?" by tap); (5) chargeSwap and any dialog choice are deferred through PAD_LATER
(sim/demo.ts): the pad cannot show a dialog's answer yet, and padPlan returns null for a choice,
a pick, or a tile where no single face button does the action; (6) the stick scene only lights the
stick, because the demo shows a room whole and there is nothing to scroll; (7) the Start scene
opens the real menu over the scene, showing the run's own state, not the scene's; (8) the turn
plan (ui/padDemo.ts) is pure and works from the concrete action, so every plain move, bomb, charge
and wait has a pad version without per-scene scripts. No rules change (no SIM_VERSION bump).
2026-11-02 — Hold to confirm on the pad (Q59)
User request: introduce holding a button to confirm it, with a ring filling clockwise from 12
o'clock; the action happens on release and only if the ring filled; on the demo screen the player
holds the bottom face button to play. Decided: (1) pad only: touch, mouse and keyboard already have
deliberate gestures, and the pad's face buttons are easy to hit by accident; (2) the pad's four
face buttons only (board actions, the face-button choices in dialogs and on the banner, and the
demo's start); the d-pad, shoulders, View, R3 and Start stay instant, since they move, cancel or
look and cost nothing to hit by mistake; (3) 0.5 s as one constant, HOLD_SECONDS in
ui/padHold.ts, long enough to be deliberate and short enough not to slow a turn; (4) fire on
release, not on completion, so the ring filling is a warning that can still be backed out of by
letting go early, which does nothing; (5) one hold at a time, a second face button is ignored
until the first is released; a swapped or unplugged pad and a window that loses focus cancel; (6)
in the demo the bottom button (Cross/A, Move) is the way in and other face buttons do nothing, so a
stray press cannot throw the demo away; (7) the waking press (see 2026-11-01) still only shows the
cursor, with no ring; (8) the ring is a CSS conic gradient, no art, and one floating element is
moved over the glyph (ui/holdRing.ts, inside the open <dialog> because it is in the top
layer); it does not animate, so reduced motion changes nothing. The demo scenes' own scripted
presses call the game directly and show no ring. No rules change (no SIM_VERSION bump).
2026-11-02 — Nintendo pads: A confirms, B backs out (amends the 2026-11-01 position-only mapping)
User request: on a Nintendo Switch pad, A is the "go ahead" button and B is "back", but the face
buttons were mapped by position only (bottom Move, left Bomb, top Charge, right Wait), so on a
Switch pad the bottom button, labelled B, confirmed, against a decade of habit. Decided: (1) only
the Nintendo family differs; Xbox, PlayStation and generic keep the position mapping, because their
bottom button is already the confirm one; (2) on a Nintendo pad Move/Go and every dialog
"confirm" (primary footer button, shop Buy, the selector's press, the banner's main button, the
demo's hold-to-play) are the right button (A, W3C index 1), and Wait and every dialog
"dismiss" are the bottom button (B, index 0); Bomb stays left (Y, index 2), Charge top (X,
index 3); (3) the rule lives in the pure modules, not in main.ts: padMap.ts has one table
(FACE_ACTIONS) with faceOf, faceActionOf, confirmFace and cancelFace; mapPad takes the
family and says what each face button means (the intent still carries the raw index); padGlyph.ts
names a face button's picture by its position (faceGlyph) and an action's picture as the face
button that does it (glyphFor); padDialogMap.ts (assignFaces, faceAction) takes the family;
(4) the action bar's data-face (where the hold ring looks) follows the family, and a dialog or
banner already open is assigned again when the family becomes known; (5) the demo's virtual
controller puts the Move picture on the right and Wait at the bottom for Nintendo (CSS class
.nintendo on the overlay), so the picture sits where the button is. Also (same day): a pressed
button on the virtual controller stays lit for PAD_LIT_SECONDS = 0.7 s (was 0.3 to 0.35), and the
demo's and the clips' beats were respaced so presses never overlap (a d-pad press is lit, lets go,
then the cursor hops and rests PAD_AFTER_SECONDS = 0.3 s). No rules change (no SIM_VERSION bump).
2026-11-02 — The virtual controller is laid out from measured data (Q63)
Pavel disliked the CSS-drawn pad outline (rounded boxes and two tilted grips), and its hand-placed
percentages put buttons only roughly where they are on a real pad. Decided: (1) each family has a
measured layout (ui/padLayouts.ts, generated by tools/make-pad-layouts.py, not edited by
hand): a 1000-wide box, the body as an SVG path, and the centre and radius of the d-pad, right and left
stick, four face buttons and Start, and the shoulder boxes; (2) ui/padPlaces.ts (pure, tested) turns a
family into each lit part's place and size, with the face position taken from faceOf (so Nintendo's Move
on the right and Wait at the bottom need no special case; the .nintendo CSS is gone); (3) padOverlay.ts
draws the body, the left stick (never lit) and the shoulders in an inline SVG and lays Kenney's pictures
over it in percent of the box, so one size variable (--pad-size) still scales everything; setFamily
swaps layout and pictures at once; (4) the Xbox layout was measured from a CC BY-SA 3.0 photo by mliu92
(attribution in the README), so the layouts carry that licence, and generic pads use it; the PS5 and
Switch Pro ones are from Pavel's own photos; (5) the SNES / N64 Switch Online pads are not covered.
The outline is hand-drawn by Claude from measurements, so it stays on the hand-made list (art
provenance #16, Q39).
2026-11-02 — The virtual controller uses real line-art pictures (Q63, supersedes the outline and photo parts above)
Pavel rejected the hand-drawn SVG outline. Decided: (1) the body is a picture, one per family, from
OpenGameArt's "Controller style 1" (Xbox, also generic), "5" (PlayStation) and "3" (Nintendo), CC BY 4.0
by xUMR (https://opengameart.org/users/xumr); tools/make-pad-layouts.py scales them to 1200 px wide and trims them into
assets/controller-art/ (README Attribution, art provenance, LICENSE.txt there); "Controller style 4"
(DualShock-style) is not used; (2) the drawings already hold every button, both sticks and the shoulders,
so padOverlay.ts shows only the <img> (lines made visible by a CSS drop-shadow) and Kenney's pictures
over the drawn buttons; the layout lost outline and lstick; (3) the positions are measured by Claude
from the drawings (the script's table), so mliu92's CC BY-SA 3.0 Xbox photo and Pavel's PS5 and Switch Pro
photos are no longer used and the CC BY-SA licence note is gone; (4) the hand-made outline is gone from
the Q39 list. Size is still the one --pad-size variable.
2026-11-02 — Virtual controller polish, stick demos, time bar on the TV
After Pavel tried the demo: (1) the white pictures (d-pad, stick) are not dimmed at rest, since the dimming made them read grey and then "turn white" when pressed; the d-pad and stick show a press by the red arm already in their own picture, shoulders and Start by a scale and a glow; only the four coloured face buttons have an idle (opacity 0.75, brightness 1.6) and a lit look. (2) The pictures are drawn under the line drawing, which is on top, and each face picture is clipped to its drawn circle (clip-path: circle(--clip), --clip = 50% / GLYPH_SCALE; lit, the clip shrinks by the 1.15 scale so the circle stays put); hold rings stay above the drawing. (3) Reduced motion keeps no scale and the plain clip. (4) The stick scene needs a view that can scroll, but the demo shows rooms whole, so updateCamera got zoom (with whole): the scene shows 1/2 of the room, the stick calls panView + endPan (eased), and pressing it resetPans. demoZoom is reset in resetView (so every new scene and the end of the demo) and in skipTo; the view mask and the action bar's place follow the zoomed view. A second scene, padLook, pushes the stick toward an enemy of the enemies room (padDemoOrder: the stick, look, Start). Chosen over a taller board because it works for any room and costs only the zoom parameter. (5) The time bar #demo-time is a fixed element on the TV's bottom edge, inside the glass (above the CRT, under the caption panel), instead of in the panel; the panel is shorter by it, and --demo-panel / the TV's size math measure the panel as before, so nothing else changed.
2026-11-02 — Attribution policy
User decision: the deployed game always links to the repository (the menu's "Source code" link, REPO_URL
in main.ts, in the live and the next copy), and the repository's README Attribution section lists every
credit. That is the way the game satisfies CC BY 4.0's attribution for xUMR's controller drawings
("in any reasonable manner", with a link to where the credit information is; section 3(a)): the README
names the author with a link to their OpenGameArt profile and each work's page, names the licence with
its link, and says what was changed (scaled and trimmed); assets/controller-art/LICENSE.txt repeats
it beside the files. Conditions: the repository stays publicly readable, and the link stays in every
build. Not legal advice. Kenney's CC0 art needs no credit (the README credits it anyway); no credit in the menu itself.
2026-11-02 — The keyboard gets the controller's cursor, beside the plain arrows (Q65 part A)
An audit found the keyboard could not play everything: tile targets only along the player's four straight
lines (nearest valid tile: Teleport, Decoy, Detonate only partly usable), ? then an arrow moved the player,
Enter on the banner pressed its first button whatever had the focus, Space waited with a button focused, no
pan, dialogs opened with no default. Decided, a hybrid: (1) the plain arrows / W A S D keep directionPlan
(2026-11-01 recorded that the keyboard deliberately keeps it; it is fast and a habit), now documented as
"B, then a direction"; (2) Shift + arrows wake a keyboard cursor that is the pad's tile-first cursor
(padTiles.ts, padTarget.ts reused untouched), so every blue tile is reachable. The wake press only shows
the cursor. While it shows, the action keys use padDo on its tile, Enter does the first possible action
(never Wait, nothing on the player's tile) or confirms a picked one, Esc backs out stepwise (cancel, cursor
home, hide); (3) padActive as the cursor's switch became inputMode ('pad' | 'keys' | null, ui/keyCursor.ts):
the cursor key presses, modifiers, Alt-pan and Home keep the keyboard's mode; a plain arrow or any key or mouse
press otherwise hands the board back, as the pad rule says; cursorWanted is unchanged (main.ts passes
inputMode !== null); (4) ? aims over explainableTiles and Enter or ? explains it, so far auto-move comes through the info
dialog's "Walk there"; the aim's keyboard mode ends with "What is that?"; (5) Alt + arrows panView (0.5 tile a press,
repeating), Home resetPan; (6) Enter and Space leave a focused button, link or input to the browser
(targetTakesKey), and a closed dialog blurs the button that got the focus back; a dialog panel answers
Enter with its green (or only) button; (7) the label and hint draw key caps (kbd.key-cap) instead of pad
pictures. The pad's behaviour is unchanged. Part B (the demo for keyboards) is separate.
2026-11-02 — The demo plays for the keyboard too (Q65 part B)
The demo's input button gains keyboard as its third entry (touch, mouse, keyboard, ps5, xbox, switch,
generic; the pick rules of Q61 unchanged). The keyboard demo is the controller demo's shape, not the touch one:
the touch scenes minus the touch-only ones (long-press bomb, auto-move, top bar tour, "What is that?" by tap) and
the charge swap, with its own saved place (demoAtKeys), a key overlay (ui/keyOverlay.ts: Kenney's
Keyboard & Mouse glyphs, Default style, on a CSS grid; white at rest, a pressed key lit yellow for
PAD_LIT_SECONDS, so presses never overlap) and a dashed board cursor when the keys use Shift. Decided:
(1) each turn is played with the fewest key presses the game accepts (ui/keyDemo.ts keyPlan): a plain arrow
for a step, an action key then an arrow ("B, then a direction"; also M and V), the same key twice to confirm Freeze
on yourself, else Shift + arrows over cursorPath then the action key. The first Shift + arrow only wakes the cursor on
the player (as in play), so it counts as a press and repeats the first hop's direction. Every candidate is
replayed through the game's own pure rules (runKeys: keyButton, moveCursor, tileActions, resolvePick, tapChoices,
isDeadly) and only one that ends on the action's tile with the same action id is used; the test does the same
on every demo scene and seed. runKeys mirrors main.ts directionPlan (without its questions) rather than
sharing it; a change there needs one here. (2) Each turn is assumed to start with the cursor asleep. In play, a Shift + arrow right
after another cursor turn hops at once; the demo always wakes it, which only costs it a press. (3) Only M B V Space,
the arrows, Shift, Alt, Enter, Esc, ? and Home are drawn; R K T P F I L X G and Q E Z C have no cap, since the cursor
plus M B V reaches the same tiles; W A S D are named in the first caption, not drawn. (4) New scenes: the Shift
cursor, ? then an arrow then Enter, Alt + arrows (the 2x zoomed look view of the stick scene) then Home, Esc opens
the menu. Captions are SceneDef.keyCaption with {arrows}, {M}, {shift} ... tokens (keyCaptionHtml, throws on
an unknown one). (5) The hint reads "Press Enter or Esc to play"; the demo's key handler already ended it on those
and swallows every other key. (6) Clips keep following the real input. The key pictures were copied from the pack
(15 files), art-provenance and README Attribution counts updated.
Correction (2026-11-02): "swallows every other key" in (5) is too strong. In the demo ← and → change scenes, Esc and Enter end it, and Tab, Enter and Space work the demo's own buttons; the rest does nothing. The keyboard cursor's wake (Shift + arrow, ?) is also off while the demo plays or a dialog is open (modeAfterKey's locked).
Known gaps (left as they are): the demo's runKeys mirrors main.ts directionPlan instead of sharing it (a shared pure directionPlan would remove the double upkeep); the keyboard demo's ? scene does not jump the aim to the enemy as the real game's aim does.
2026-11-02 — Hold to confirm acts when the ring fills (amends Q59 decision 4, "fire on release")
User request (Pavel tried it): waiting for the button to be let go felt late. Decided: (1) the action
happens the moment the ring is full, while the button is still down; decision (4) of the Q59 entry
("fire on release, so the ring is a warning that can be backed out of") is replaced: letting go
before the ring is full is still the way to back out, and the full ring is now the point of no
return; (2) the button is then used up (HoldState.done in ui/padHold.ts): it cannot fire again
and, since a new hold cannot start while one is held or used up, it starts none in whatever the
action opened (a dialog, the demo ending into the game, a level change, the banner) until it has
been let go; a second face button is ignored meanwhile, as before; (3) the ring (hold callbacks)
reaches 1 at completion and stays full (green) until release, or goes at once if the place the
buttons act in changes (holdEnd; the button stays used up); unplug, blur, a swapped pad and a pad
error end the used-up state too; an instant button (Start, a bumper) does not; (4) holds that have not
completed keep every Q59 cancel rule (early release, context change, instant button, blur, unplug,
swap, error); (5) the demo's hold-to-play starts play at completion and the same held button then
does nothing in the game; the waking press rules (2026-11-01) are unchanged. No rules change (no
SIM_VERSION bump). Not verifiable without a real pad: how it feels, and that a held button across
the demo-to-game change shows no ring.
2026-11-02 — The hold ring is anchored to its originator (Q72)
Bug (Pavel, with a pad): the ring stayed on screen a moment after the dialog that began it closed.
Cause: the ring was positioned only inside hold() calls from the pad poll, and the dialog stays
open for a 150 ms pop-out (closing) after a button closes it; the ring, a child of the dialog,
stayed at the old spot meanwhile, and holdEnd came only when the poll saw the context change.
Decided: the ring belongs to the picture it was shown on (holdRing.ts keeps it as origin),
follows it every animation frame, and hides in the frame the picture is not connected, not drawn
(checkVisibility, zero size) or inside a dialog that is closing or closed (ringVisible, pure,
tested). A completed hold's button still counts as used up until release (Q68); only the ring goes.
The demo's ring lives inside the demo controller, so it goes with it (and releaseAll). Not
verifiable without a real pad: that it feels immediate on the actual hardware.
2026-11-02 — Face buttons grey at rest, coloured when pressed
User request: on the demo's virtual controller a pressed A/B/X/Y was hard to tell from an idle one
when both were coloured (the earlier brightening of the idle colours made it worse). Decided: the
face glyphs are grey at rest (grayscale(1), brightened so they read on the dark demo) and take
their colour only while pressed (.pad-part.face.on: full colour, a little saturation and
brightness, the scale pulse); the filter transition makes the change gradual. Amends the same
day's "brighter idle colours" point. The white pictures (d-pad, stick, shoulders, Start) are
unchanged. CSS only (style.css, .pad-overlay .pad-part.face).
2026-11-02 — Faded blue: auto-move's reach, for the pad's and keyboard's cursor (Q69)
Pavel's idea: next to the bright blue (single actions) show a second, faded blue on every tile
auto-move can take you to, so the cursor can hop there and confirm. Decided: (1) farTiles
(ui/padTiles.ts) is the set a tap would walk to (tapMeaning = 'walk') with a safe, not deadly
first move (autoMoveAction), minus the bright tiles and the player's own, with the walk's length
for the label; with a destination set, only it (so Go can be pressed from the cursor).
(2) The cursor's candidates are the bright plus the faded tiles, one set for moveCursor and
keepCursor; no special order, nearest in the direction wins. (3) On a faded tile only Move/Go is
possible, and it taps the tile with nothing picked, so every tap rule and safety check applies;
the ghost shows nothing there. (4) Faded tiles are drawn only with the pad's or keyboard's cursor
and when nothing is picked; mouse and touch stay as they were (Pavel was asked; answer pending).
(5) No modifier button: the d-pad repeat is faster instead (250 ms, then 75 ms; shared with menus
and dialogs). Keyboard Shift+arrow repeat is the OS's. Cost: a scan of the floor, 40 ms on a
13 x 17 level, once per state (currentFar). Demo scenes for it come later.
Added the same day (Pavel approved): the way is previewed before it is chosen. With the cursor
on a faded tile, or the mouse (pointer type mouse, no button down; never touch) over a far walkable
tile, with nothing picked, no way set, no dialog and no demo, the board draws auto-move's way as the
real one is drawn (routePreview in ui/routePreview.ts, pure: the same autoMoveAction,
autoRoute, isDeadly rules, first tile red when unsafe; View.preview; one drawing block in
render/board.ts). Nothing is set and no step taken; Move/Go, M, Enter or a click then commits it
as before. The result is cached per tile, state and danger (previewNow). Click and tap behaviour
is unchanged; mouse hover is the only mouse change (the faded tint itself is still pad and keyboard only).
2026-11-02 — Autopilot replaces the hoover (Q73)
Supersedes "The level finishes itself once the last enemy is gone" (2026-11-01, Q56). User
request: the turnless hoover made gathering free, which is not the game's economy, and it was
not a walk the player could watch. Decided with the user: (1) the hoover action is removed
entirely (types.ts, turn.ts: the branch, canHoover, hoover); (2) in its place, once the last
enemy is gone, an autopilot walks the character with ordinary turns (move, run, diagonal, wait), so
replays need no new action; the sim part is the pure sim/autopilot.ts (autopilotTargets,
autopilotPlan), the UI part follows separately; (3) the route is the one with the FEWEST TOTAL
TURNS, not nearest-first: pairwise walking distances from playerPath, then the cheapest order
through every reachable coin, charge and uncovered gem ending at the exit (exhaustive over subsets
for up to 10 targets, nearest-first beyond), a target lying on the chosen leg counted as collected
free, ties broken by fewest direction changes, then smaller y, then smaller x (deterministic, so
replays agree); (4) it is re-planned every turn from the current state, using auto-move's safety
rules (safeStep); unreachable targets are skipped; a gem under a crumbling crate, or no way to the
exit while crates crumble, means waiting; (5) the autopilot's turns count, par bonus included (no
par protection; accepted). (6) SIM_VERSION 42 to 43, because a record holding hoover can no
longer replay. Autopilot's wording, speed, note and stop controls are decided in the UI part.
UI part (same day, decided with the user): (7) the note is #autopilot, top centre below the top
bar, a pill like the toast but not a popover (dialogs cover it), text role="status": "Autopilot:
cleaning up", "Autopilot: waiting for the crates", "Autopilot: off to the exit". It shows at once
on the last kill, even if a dialog from that turn is open; the walk starts after a 1.0 s beat
(and after the answer). (8) Speed: 2.5 moves a second (AUTOPILOT_STEP_SECONDS 0.4, was 0.25 = 4 a second until Q89, when a bug report found it too fast; auto-move
stays at 0.5), no new animation; a Run only when owned. (9) Stop: Esc, pad L1 (its picture is on
the note) and a Stop button on the note for touch and mouse; stopping ends it for the level; so
does turning the setting off. A stall (6 waits in a row outside the crates phase, or no safe turn)
stops it with the toast "Autopilot stopped / Your turn". (10) While it walks the board takes no
input (boardLocked): taps, long-press, the action bar (dimmed), movement and action keys, ?,
Shift + arrows, the pad cursor and face buttons; it still takes dialogs (a gem or charge pauses
the walk until answered), the menu, fullscreen, the top bar's info taps and looking around. A hold
begun before the lock is cancelled through the pad's context string. (11) The setting keeps its
stored key autoFinish so players' choices survive the rename.
A controller's selector shows what to hold (Q74, 2026-11-02)
Bug report: with a controller the menu's switches (Sound, Autopilot) could not be changed. They
could: a hold of Cross toggles them. But a short press did nothing and nothing showed that it
should be held, because in selector mode only the footer's dismissing button had a picture, so
the hold ring (which goes round a .pad-glyph[data-face]) had nothing to go round. Decided: the
selected item carries the confirm button's picture (added on selection, removed when the selection
moves; skipped where the item has a picture already, the dismissing button's Circle and a shop
item's Buy), so the ring shows on it; a confirm button let go early shows the toast "Hold to
choose" once per dialog; the almanac's neighbouring tabs show LB / RB. The 0.5 s rule is
unchanged. Same day, the autopilot (decision 4 above) also waits for a ticking bomb or burning
flame that cuts it off from a target (phase bombs), and such waits are not stalls.
Move on a tile that is also a bomb target walks (Q75b, 2026-11-02)
Bug report (controller): the player stood two tiles from an empty floor tile in a straight line,
holding an RPG. The cursor on it said "Fire an RPG bomb to this tile", Bomb was possible and Move
greyed, so the player could bomb a tile but not walk there. Cause (confirmed): the tile is bright
blue (an RPG target), farTiles leaves bright tiles out of the faded set, and tileActions offers
Move only where Move itself can target (next to you, or 2 straight with Run). tapMeaning does say
'walk' for such a tile (nothing picked), so a tap would have walked. Decided: the rule is "wherever
auto-move can walk, Move is offered". New pure walkTo / cursorActions in ui/padTiles.ts: on a
bright tile where Move is not possible but a safe, non-deadly walk exists, the Move entry becomes
the walk (far: true, "Walk here · N steps", "Keep walking" for the destination), and the other
buttons stay as tileActions has them. Pressing it takes padDo's existing far path (nothing
picked, tap the tile), so it walks. Tiles Move can target keep their plain Move; faded tiles are
unchanged. The faded tint still leaves bright tiles out (they are blue already). No SIM_VERSION
change. Test: test/q75.test.ts.
The cursor still reaches every faded tile while a walk is set (Q75, 2026-11-02)
Bug report (controller): a coin an enemy dropped, in a pocket beside a bomb target, could not be
reached by the cursor, and no faded tile showed anywhere; the screenshot had L1 "Cancel" on, which
means an action was picked or an auto-move destination was set. Investigated: the rules are right
(test/q75.test.ts: tapMeaning says 'walk', the coin is a farTiles entry, the first step is
not deadly, and the d-pad hops from the RPG tile down to it). The cause is Q69/Q70's rule that,
with a destination set, farTiles lists only the destination: any other walk was out of reach of
the cursor and nothing was tinted, with only "Cancel" to get out. Decided: currentFar in
main.ts no longer passes the destination, so every faded tile stays a candidate and tinted while
a walk is set; the cursor starts on the destination as before (cursorStart), and pressing Move
on another faded tile sets a new destination (tapTile). farTiles' destination parameter stays
for its tests. With an action picked, faded tiles are still hidden, as the picked button's targets
are the point. No SIM_VERSION change.
2026-11-02 — Moving the mouse no longer takes the board off the pad or keyboard cursor
User request: the cursor mode switched back to the mouse whenever the mouse moved (more than 3 px),
which made screenshots difficult while testing with a controller. Decided: only a click or tap
(pointerdown) or a key hands the board back (handBack); a plain mouse move does not. Amends the
Q65 keyboard-cursor rule 'a mouse move of 3 px or more hands the cursor back, like the pad'. The mouse's
hover route preview only shows while no pad or keyboard cursor is active, so it does not appear while
the mouse merely moves over a pad-driven board; a click on the board is the way back.
2026-11-02 — Q79-Q82: cursor and aim fixes (Pavel's pad tests)
- Q79: Wait pressed with the cursor on the player starts the next turn's cursor on the player, even
with an auto-move walk set (
cursorPinned); other actions still restart on the walk's destination or the player. - Q80:
cursorStartonly picks the walk's destination if it is still a candidate tile (a flame can make it unsafe); a d-pad press from a tile with no action jumps to the nearest that has one (never stuck). - Q81: modifier keys alone and anything with Cmd held (
ignoredForMode) never hand the board back from the pad (screenshots). A click still does. Amends the 'any key hands the board back' rule. - Q82: the "What is that?" aim now includes plain pillars and the outer wall (
explainableTiles(s, true); the demo keeps the old list), but starts on the nearest tile that is not a plain wall (aimStart). Up from the top row of tiles goes on to the top-bar items (Left/Right along them, Down back to the board;Aim.hud, outlined with.pad-aimed); the confirm button explains the item (hudInfo). Supersedes 'not plain pillars or the outer wall' above. - Q83: while "What is that?" is on (pad), the action bar's four buttons are greyed and the label reads "Hold to find out what this is" beside the confirm button's picture, which carries the hold ring.
- Q82 detail: Up from the top row lands on the top-bar item nearest the aimed tile's column on screen.
- Q84: the L1/R1 legend (
#pad-hint) sits at the top of the board under the top bar; L1 shows an undo arrow, R1 a magnifying glass (Game Icons), with full wording (IDLE_L1_TEXT). - Q85: inspection with the pad follows the hold scheme: R1 on, d-pad moves, hold Cross (confirm) views, hold Circle (cancel) leaves; L1 only brings the aim back to the player's side. R1 while on does nothing (dimmed in the legend). The keyboard's Esc still leaves it. Supersedes 'RB again explains, LB leaves it' (2026-11-01).
- Q86: "What is that?" can rest on the player's own tile (
tileInfosays "You"); L1 in the mode puts the aim there. The demo's list (explainableTiles(s)) and the aim's start still leave the player out. - Q87: after the aim explains something (pad, keyboard), "What is that?" and the aim stay on; leaving is a held Circle, Esc or the dialog's "Walk there". Touch/mouse taps still end it (open question for Pavel).
- Q88: in "What is that?" the pad's Cross (explain) and Circle (leave) act on a single press, no ring (
PadTargets.instant); amends Q85's hold.INSPECT_HOLD = falseis the switch for the later option (with Q71's expert options). - Q88 detail: the info dialogs opened by the aim (
inspectDialog) also take a single press; every other dialog keeps hold-to-confirm (the menu has New, which must not fire on a tap). - Q88 detail 2: the single press covers everything under an inspection action: dialogs opened from the info dialogs inherit
inspectDialoguntil no dialog is left open. - Q89: autopilot step 0.25 s -> 0.4 s (bug report: too fast). Touch got a Cancel button (a picked action or auto-move destination had no way out but Esc/L1): round, over the bar's top right, absolute so the bar's height (the board's inset) is unchanged; hidden for the pad, the demos and while inspecting. The "demo became really tall" screenshot (touch, scene 5/57, the board drawn as one thin column, sprites squeezed, no bar) could not be reproduced yet (open question to Pavel).
- Q90: bug report (seed 395025326, level 5): 'Couldn't move'. Replay:
offer(5 gems) andchargeOffer(Acid) both pending after a slide;isAvailablerefuses all actions while either is set, and no dialog was showing. Cause of the missing dialog unknown (offer dialog isrequired); suspects: a dialog opened over it (menu / bug report) replacing it, or a timing race between the pickup timers. Fix is a safety net:reopenPendingChoicereopens after 1.5 s without a dialog. - Menu Changes button (2026-11-02, Pavel: the version link was easy to miss): a fifth small button, Changes, opens the same list as the version link (which stays). The small buttons now sit on a six-column grid: Almanac, Scores, Changes (two columns each) in one row, Demo and Bug (three each) under them, because five in one row do not fit a phone.
- Demo controls on a controller (2026-11-02, Pavel: shuffle/restart had no button): in the demo the d-pad up = shuffle, down = back to the first scene (left/right were already scene before/after), R1 or View = cycle the Input button; the same 400 ms throttle as left/right so a held d-pad does not flip shuffle repeatedly. Each demo control shows its pad picture on its corner while the demo is drawn for a controller (
syncDemoGlyphs, Q67 rule). Tried only with a mocked pad in a browser: no unit test (the logic is in main.ts). - The banner is a panel (2026-11-02, Pavel: the level-cleared screen looked unfinished, 'should it be a dialog?'): restyled, not migrated.
#bannerkeeps its own elements and the pad code inpadDialog.ts(a<dialog>would mean moving the status-driven refresh, the replay clip and the hold ring); it now has a.panelchild with the dialog's parchment 9-slice, the dialog's backdrop and.ui-buttonbuttons (primary green, the others plain). Side effect: the oldbutton.secondary/button.linkstyles are gone. Checked in a browser at phone and desktop width with a mocked pad (the glyphs sit on the three buttons).