diff --git a/HANDOFF.md b/HANDOFF.md
index cdf6c48..98d80bc 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -358,6 +358,45 @@ trail only says where it has been.
`jet`/`trailR` were added to the WEAPONS rows without being added to that list — so the rocket flew
with no plume at all and nothing errored. **Any new pellet field has to be added in both places.**
+## Session log: fire, ash, and the third laser pass (2026-08-03)
+
+**A BURNT BODY DOES NOT BURST.** Fire kills by cooking, so there is nothing left to spray: a body
+that dies while alight — flamethrower or flame ball — leaves **dark grey ash** instead of blood. Same
+decal, same mound, same particles, all recoloured and a little smaller, plus a puff of smoke and a
+dull thud instead of the wet splat and the headshot crack. `killZombie` reads `zb.burnT > 0 ||
+zb.ashes`; **the flame BALL has to set `zb.ashes` itself**, because that kill happens before the body
+ever catches. `gore`/`piles`/`parts` all carry an `ash` flag and render off `ASH_COLS`.
+
+**The flamethrower is a lighter, not a gun.** `dmg: 0, hp: 0, pierce: true` — it does NO impact
+damage at all and only IGNITES; the burn does the killing, so a lit crowd walks a few steps and goes
+down together. Reach `life` 1.15 s x `speed` 23 = **26 m against a 22.8 m court**, so it crosses to
+the far baseline. Watch the guard in the pellet loop: `hp -= 0` fell straight through to
+`killZombie`, which made the flame cone delete the crowd on contact — a machine gun that happens to
+be orange.
+
+**Every burst weapon had no progress bar, and the cause was the spend.** Arming spent the tin
+immediately, so the rack entry vanished — and the countdown bar draws on the slot holding `G.gunId`,
+which no longer existed. The tin is spent when the burst ENDS now (`endBurst`), so the icon, the
+count and the bar are all there while it runs. **Every path that ends a burst must route through
+`endBurst`** — the duel, the boss fight and death all force `G.mgT = 0` on their own, and any one of
+them missed would hand out free ammo. The soak asserts `gunId` is never set with `mgT` at zero.
+
+**Auto-fire movement, again.** The previous fix set the pose from `moving`, but `moving` was
+`mx || mz || assist`, and finger-steering zeroes `mx`/`mz` — so a player sliding left and right under
+auto-fire still counted as standing still and never ran. `mgMoving` is in that expression now.
+
+**The oval on his back was the mg racket.** `drawMgRacket` put its head at `(P.x, 0.95, P.z - 1.35)`,
+which at this camera projects almost exactly onto the player sprite. Offset by `P.facing` it reads as
+a racket held out beside him.
+
+**The laser's far edge is pinned in SCREEN space.** Running out to `beamLen` on the ground landed it
+on the ads' TOP line; it should die at their middle. `c3.y = c4.y = proj(0,0,-6).y - AD_H / 2` — the
+x's keep their perspective, the y is pinned. That holds in both orientations and leaves `beamLen`
+free to be the KILL range instead of a number tuned to one camera.
+
+**Weapon sounds** are a `sfx` field on the WEAPONS row: `thump` (beacon, `boom`), `swoosh` (rocket,
+`whoosh`), otherwise the gun's every-other-round tick.
+
## TEST MODE — triple-tap the scoreboard
A sandbox for trying new content without playing twenty games to reach it. Three taps on the
diff --git a/config.js b/config.js
index 07d2aa0..45b1367 100644
--- a/config.js
+++ b/config.js
@@ -465,12 +465,16 @@ export const WEAPONS = {
// shot and dragging its colour behind it. The showiest thing in the game and the one you meet first.
beacon: { label: 'BEACON', icon: 'w_beacon', tin: 1, kind: 'burst', len: 11, rate: 1.6,
r: 1.2, dmg: 2, hp: 4, arc: 1, hue: 1, trail: 1, trailR: 0.035, glow: 3.2, speed: 19,
+ sfx: 'thump', // it LOBS — a soft low thump as it goes, not a rifle crack
desc: ['IT THROWS ITSELF', 'TWICE THE BALL, EVERY COLOUR'] },
// "make this one way more pronounced": twice the rounds a second, a wider and longer cone, fat
// glowing rounds that throw their own embers, and every one of them lights what it touches.
+ // It REACHES THE FAR BASELINE (life 1.15 s x 23 m/s = ~26 m), it PIERCES, and it does no impact
+ // damage at all: dmg/hp 0. Fire is not a bullet — being hit sets you alight and the burn does the
+ // killing, so a lit crowd walks a few steps and goes down together. That is the whole weapon.
flamer: { label: 'FLAMER', icon: 'w_flamer', tin: 1, kind: 'burst', len: 6, rate: 30,
- r: 1.15, dmg: 2, hp: 9, speed: 15, life: 0.7, ignite: true, igniteR: 2.6,
- trail: 2, col: '#ff8420', glow: 2, spread: 3.1, // life 0.7s x 15 m/s = a ~10 m cone
+ r: 1.15, dmg: 0, hp: 0, pierce: true, speed: 23, life: 1.15, ignite: true, igniteR: 2.6,
+ trail: 2, col: '#ff8420', glow: 2, spread: 3.1, sfx: 'flame',
desc: ['A WALL OF FIRE', 'AND THE FIRE SPREADS'] },
// The normal ball gesture, made spectacular: pull, aim, release — and instead of a ball you get a
// blue plane that widens down the court and cuts through everything standing in it.
@@ -483,7 +487,7 @@ export const WEAPONS = {
// Lobbed, self-throwing, and it detonates like a bomb where it lands. Deliberately a LATE pickup
// (see pickBarrelWeapon) — Oliver: "best later in the game when there is really a lot going on".
rocket: { label: 'ROCKET', icon: 'w_rocket', tin: 1, kind: 'burst', len: 9, rate: 1.1,
- r: 0.75, dmg: 3, hp: 9, arc: 1, boom: true, jet: 3, speed: 38,
+ r: 0.75, dmg: 3, hp: 9, arc: 1, boom: true, jet: 3, speed: 38, sfx: 'swoosh',
col: '#ffd050', glow: 1.8,
desc: ['IT LAUNCHES ITSELF', 'AND EVERY ONE IS A BOMB'] },
};
diff --git a/gameplay.js b/gameplay.js
index e924b19..72f13bb 100644
--- a/gameplay.js
+++ b/gameplay.js
@@ -592,7 +592,7 @@ export function startDuel() {
if (!zombies.some(z => z.variant === 'r')) spawnRacketZombie(); // the schedule guarantees an opponent
for (const z of zombies) if (z.variant === 'r') z.dueled = true; // a veteran can be challenged again — this marks the fresh duel
G.weapon = 0; // tennis rules for the duel: plain balls, no tins
- G.mgT = 0; pellets.length = 0; // ...and a burst in progress is over. The umpire has restored order.
+ G.mgT = 0; endBurst(); pellets.length = 0; // ...and a burst in progress is over. The umpire has restored order.
G.interlude = { introT: 2.4 }; // the intro beat: serving blocked while the umpire announces
G.boy = null; // ...and the mop boy is waved off the court (sendBoy is barred for the duration)
ball.active = false; setupPoint();
@@ -614,7 +614,7 @@ export function startBossFight(chapter) {
if (G.bossFight) return;
G.bossFight = chapter;
zombies.length = 0; pellets.length = 0; extras.length = 0; shots.length = 0; // a clean stage
- G.horde = null; G.mgT = 0; G.clearing = false;
+ G.horde = null; G.mgT = 0; endBurst(); G.clearing = false;
ball.active = false; setupPoint();
spawnChapterBoss(chapter);
zombieDuck(true); // the music drops out for it
@@ -934,7 +934,10 @@ function tryArmGun() {
if (!w || w.kind !== 'burst' || slotStock(G.weapon) <= 0) return false;
G.gunId = id;
G.mgT = w.len ?? TUNE.mgPickupLen;
- spendSlot(G.weapon);
+ // The tin is spent when the burst ENDS, not when it starts. Spending on commit removed the rack
+ // entry immediately, so the countdown bar — which draws on the slot holding G.gunId — had no slot
+ // to draw on and every burst weapon ran with no progress bar at all. Oliver spotted it on the
+ // flamethrower; it was true of all four.
G.mgCool = 0;
ball.active = false; G.phase = 'rally';
P.pose = 'idle'; P.poseT = 0; P.swingT = -1;
@@ -953,6 +956,15 @@ export function serveToss() {
if (score.server === 'P' && G.phase === 'pretoss' && !ball.active) tossBall(P);
}
+// The burst is over: NOW the tin is spent. Called from updateZombies when mgT runs out.
+export function endBurst() {
+ const id = G.gunId;
+ G.gunId = null;
+ if (!id) return;
+ const slot = [0, 1, 2, 3, 4].find(i => slotId(i) === id);
+ if (slot !== undefined) spendSlot(slot);
+}
+
// The laser fired from a TAP (the keyboard/two-stage path). The drag path solves its own direction
// and goes through hitServe; this one has no arc to read a lane off, so it takes the held flick aim
// if there is one and otherwise fires straight up-court.
@@ -1168,12 +1180,11 @@ export function update(dt) {
// to what the camera can see (portrait's world bounds are wider than its viewport)
const mgMode = G.horde || G.mgT > 0;
const xVis = Math.min(P_BOUNDS.x, (W / 2 - 14) / (TUNE.camF / (TUNE.camZ - P.z)));
- let mgSteer = false;
+ let mgSteer = false, mgMoving = false;
if (mgMode && touchPX() !== null) {
mgSteer = true;
const tx = Math.max(-xVis, Math.min(xVis, touchPX() * xVis));
- if (Math.abs(tx - P.x) > 0.04) { P.facing = tx > P.x ? 1 : -1; P.runT += dt; P.pose = 'run'; }
- else P.pose = 'idle';
+ if (Math.abs(tx - P.x) > 0.04) { P.facing = tx > P.x ? 1 : -1; mgMoving = true; }
P.x = tx;
}
const slow = P.swingT >= 0 ? 0.35 : 1;
@@ -1202,7 +1213,10 @@ export function update(dt) {
const xLim = Math.max(xVis, COURT.W / 2);
P.x = Math.max(-xLim, Math.min(xLim, P.x));
P.z = Math.max(P_BOUNDS.zMin, Math.min(P_BOUNDS.zMax, P.z));
- const moving = mx || mz || assist !== 0;
+ // mgMoving MUST be in here. Finger-steering zeroes mx/mz (they are the keyboard axes), so a
+ // player sliding left and right under auto-fire counted as standing still and never ran —
+ // "when he's moving left and right the run animation is not applied".
+ const moving = mgMoving || mx || mz || assist !== 0;
P.runT += moving ? dt : 0;
// Auto-fire used to be excluded here, which left the pose stuck on 'idle' — and render then
// faked a run cycle off G.idleT so he was not a statue. That fake is what read as "this hectic
diff --git a/index.html b/index.html
index 6e25eee..260ac12 100644
--- a/index.html
+++ b/index.html
@@ -179,6 +179,6 @@
-
+