diff --git a/HANDOFF.md b/HANDOFF.md
index 2b76612..be0128e 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -460,6 +460,62 @@ Known generator quirks, all still true:
- **Colour-match any regenerated frame** of an existing pair before shipping: fit a per-channel
linear map onto the original's mean and sd, then verify with a mean-difference number.
+## Session log: Oliver's playtest of the barrel weapons (2026-08-03, same day)
+
+Everything below is a fix to what shipped that morning. All four notes were real defects, which is
+now seven playtests in a row where "this feels odd" was a bug and not taste.
+
+**"The bigger guys were way too easy to kill at the beginning."** It was a bug, and an old one. The
+ball-vs-body chain handled boss, `g`/`m`, `s`, `f`, `a`/`b` — and **everything not named in it fell
+through to "splat-quality kills outright"**. So the STEWARD has been dying to one serve since the day
+he shipped with `stewardHp: 5` and a comment calling it "a brute's worth of hp", and the ball boy,
+bouncer and roller man inherited that the moment they existed. Pellets and melee always read `hp`;
+only the BALL skipped it. It is one generic `hpMax > 1` branch now — hp is the only thing that says
+how many hits a body takes — and measured after: shambler 2, steward 5, brute 5, ball boy 3,
+bouncer 5, roller man 7, each exactly its hp. **Anything that adds a body type must NOT need a new
+branch here; if you find yourself writing one, that is the bug coming back.**
+
+**"I have seen all of them by round 10."** The new three moved 3/8/15 -> **5/12/20**. The original
+four keep their gates exactly: the curve through ~game 12 was already tuned and the calibration rule
+says not to touch it. Their hp came down with the fix landing (9 was a fine number while nobody
+felt it and a long grind once they did).
+
+**"The laser fired only when I tapped, that was confusing."** The whole `kind: 'shot'` idea is gone.
+A barrel weapon is now exactly one of two things, and this is the rule to hold: **either it is the
+normal ball gesture made spectacular, or it fires itself for a while.** Nothing waits for a tap.
+- **laser** is a `serve` now: pull, aim, release. **The RAY is the weapon**, so there is no ball
+ anywhere in it — the preview is the WEDGE itself (`drawBeamWedge` at low alpha, not a ballistic
+ band with a target ring), the tap path has its own `tryLaser` so it never tosses a ball it is not
+ going to hit, and `fireBeam` kills everything inside the cone (`beamW0` 0.35 m at the racket to
+ `beamW1` 4.2 m at the far end). Measured: 8 of 11 bodies in one release.
+ Three things Oliver corrected on the second look, all of them "it must read as light he fired":
+ **it leaves the RACKET** (`dragContact()`, y 2.2, settling onto the court as it goes out — drawn
+ flat from his feet it read as a shape lying on the grass); **the release ERASES it along the path**
+ (the plane is whole at the flash and is wiped away from the racket outward behind a bright front,
+ so the shot reads as energy travelling down the lane rather than a decal fading where it lay); and
+ **the edges are hairlines** — at 2.5 px they were heavier than anything else on the court and read
+ as a drawn outline instead of as light.
+ *Gotcha for testing:* `TUNE.pullMin` is 18 **PIXELS**, and `aim.fy` is NEGATIVE for the down-pull.
+ A synthetic `{fx, fy, mag: 0.8}` is silently below the threshold and `previewServe` returns null,
+ which looks exactly like the preview being broken.
+- **beacon** and **rocket** are `burst` and **LOBBED** — `arc` on the WEAPONS row turns a flat pellet
+ into a thrown one (solved onto the nearest body ahead), and landing is itself a hit, so a rocket
+ that misses still goes off where it lands. The beacon changes colour every ball and drags that
+ colour behind it.
+- **flamer** got "way more pronounced": 30 rounds/s, a ~10 m cone, fat glowing rounds with embers.
+**The pale blue washed out to grey over the lawn** — a beam colour has to be saturated (`#3f74ff`),
+which is the same lesson as the barrel's halo, from the opposite direction.
+
+**"I got the laser almost every time."** A flat 1-in-4 over four items does not feel uniform. It is
+weighted and gated now (`BARREL_WEAPONS` carries `weight` and `from`), never repeats twice running,
+and the beacon is what you meet first. Measured: laser 16-28% depending on the game, rocket unlocked
+at game 12 so it is genuinely a late find.
+
+**The barrel's pink is procedural and can be as pink as it likes** — Oliver asked whether it could
+still have a pink halo given the key eats magenta out of PNGs, and yes: `drawBarrel` paints it at
+draw time and never goes near the keyer. Three rings plus a pool on the lawn now, because one flat
+disc read as a smudge.
+
## Session log: a rack you fill yourself, and barrels that fill it (2026-08-03)
The second half of the same ask. **The rack no longer has fixed slots.** It used to be five hardcoded
diff --git a/config.js b/config.js
index 34c735c..1740706 100644
--- a/config.js
+++ b/config.js
@@ -230,9 +230,12 @@ export const TUNE = {
mgPelletR: 0.5, // m of extra hit radius on a pellet: a fat round. "Bigger ball = more damage."
dmgPelletHp: 2, // hp a pellet takes off a body — shamblerHp is 2, so the horde gun one-shots them
mgPickupLen: 8, // s the machine-gun tin (4th delivery) auto-fires outside hordes
- shotHoldLen: 14, // s you stay braced with a TAP-fired weapon (laser, rocket) before the window
- // closes on its own. Running the magazine out ends it earlier, which is the
- // normal way; this only stops a forgotten rocket freezing the serve loop.
+ // ---- THE LASER's wedge: aim and release like a serve, and a plane of light widens down the lane
+ beamLen: 26, // m it reaches — the whole court, so a lane you aim is a lane you clear
+ beamW0: 0.35, // m half-width at the racket...
+ beamW1: 4.2, // ...and at the far end. It is a CONE: the spread is the point of it
+ beamT: 0.42, // s the wedge stays drawn
+ dmgBeam: 3, // hp the beam takes off anything it catches (dmgBomb is 3, and it reads the same)
// ---- the weapon barrel: the only way to get the four weapons that are not on the duel ladder
barrelEvery: 3, // games between barrels being rolled on (like the ball machine's machEvery)
barrelRoll: 2.6, // m/s it trundles across the far court — fast enough to have to be led
@@ -249,9 +252,12 @@ export const TUNE = {
// ---- the late roster. Three bodies that arrive as the run goes on, filling the gap between the
// 2 hp shamblers and the 5 hp brute and then extending past it. Each has one wound skin per hit it
// can still take, so the damage is readable without a bar (the rule the boss's missing bar set).
+ // These are now REAL: the ball path used to one-shot anything it had no branch for, so all three
+ // died to a single good serve no matter what these said. Retuned once they actually bite — 9 was
+ // fine as a number nobody felt and is a long grind as a number you do.
ballboyHp: 3, // the ball boy ('e') — a stock-sized body that takes three, with two wound skins
- bouncerHp: 6, // the bouncer ('h') — 2.4 m, shoves through the crowd instead of shambling round it
- rollerHp: 9, // the roller man ('k') — 3.2 m, hauling a court roller. The heaviest thing on court
+ bouncerHp: 5, // the bouncer ('h') — 2.4 m, shoves through the crowd instead of shambling round it
+ rollerHp: 7, // the roller man ('k') — 3.2 m, hauling a court roller. The heaviest thing on court
rollerStride: 0.62, // m he covers per lurch frame — longer than the brute's 0.5: a bigger body,
// a longer stride, or his feet slide the way the brute's used to
// fire: the flame ball no longer just kills what it touches, it starts an epidemic.
@@ -422,11 +428,15 @@ export const ZWOUND = {
// out (SLOTS, slotStock, WDESC, drawRack's three parallel arrays, and deliverTin's index mapping).
// The rack no longer has a fixed weapon per slot: G.rack holds up to four of these in the order you
// GATHERED them, and slot 0 stays BALL. A weapon is defined entirely by its row.
-// kind 'serve' — arms the next serve (the four original tins)
-// kind 'burst' — commits to a braced auto-fire window: `len` seconds at `rate` rounds/s
-// kind 'shot' — same braced stance, but each TAP fires one and `tin` is the magazine
-// The pellet fields (r, dmg, pierce, boom, ignite, col, speed) are stamped onto the round by
-// firePellet, so all four new weapons are the ONE existing pellet loop with different numbers.
+// kind 'serve' — arms the next serve: you aim and release it like a normal ball
+// kind 'burst' — commits to a braced window that FIRES ITSELF: `len` seconds at `rate` rounds/s
+// There is no third kind. A tap-per-round weapon shipped once and Oliver read it as broken —
+// "the laser fired only when I tapped, that was confusing" — so a barrel weapon is now always one
+// of exactly two things: the normal ball gesture made spectacular, or something that fires on its
+// own for a while. Nothing in between.
+// The pellet fields (r, dmg, hp, pierce, boom, ignite, arc, trail, col, speed, life) are stamped
+// onto the round by firePellet, so every auto-firing weapon is the ONE pellet loop with different
+// numbers — `arc` is what turns a flat round into a lobbed one.
export const WEAPONS = {
bomb: { label: 'BOMB', icon: 'w_bomb', tin: 3, kind: 'serve',
desc: ['DETONATES ON CONTACT', 'CLEARS A WHOLE CROWD AT ONCE'] },
@@ -438,24 +448,43 @@ export const WEAPONS = {
r: 0.5, dmg: 0.34, hp: 2, speed: 26,
desc: ['HOLD THE LINE AND SPRAY', 'A BURST, NOT A SERVE'] },
// ---- the four that come out of a barrel. Won by shooting one, never delivered.
- beacon: { label: 'BEACON', icon: 'w_beacon', tin: 1, kind: 'burst', len: 10, rate: 3,
- r: 1.1, dmg: 3, hp: 9, speed: 17, col: '#7fefff', glow: 1,
- desc: ['A SLOW BEAT OF BIG', 'GLOWING SHELLS'] },
- flamer: { label: 'FLAMER', icon: 'w_flamer', tin: 1, kind: 'burst', len: 5, rate: 16,
- r: 0.8, dmg: 2, hp: 9, speed: 11, life: 0.55, ignite: true, col: '#ffa040', glow: 1, // life 0.55s x 11 m/s = a ~6 m cone
- desc: ['A CONE OF FIRE', 'AND THE FIRE SPREADS'] },
- laser: { label: 'LASER', icon: 'w_laser', tin: 10, kind: 'shot',
+ // A LOBBED tennis ball twice the weight of yours, thrown on its own beat, changing colour every
+ // 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: 3, glow: 1.6, speed: 19,
+ 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.
+ 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
+ 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.
+ laser: { label: 'LASER', icon: 'w_laser', tin: 6, kind: 'serve', beam: 1,
// NOT magenta: the icon art is chroma-keyed and the key eats every pink/purple
// (min(r,b) > g+40), so a magenta laser came back as a hollow outline. Pink stays
// the BARREL's identity, where it is a procedural glow and never goes near the key.
- r: 0.55, dmg: 3, hp: 9, speed: 70, pierce: true, col: '#8ab6ff', glow: 1,
- desc: ['TEN SHOTS, AND EACH ONE', 'GOES THROUGH THE WHOLE LANE'] },
- rocket: { label: 'ROCKET', icon: 'w_rocket', tin: 3, kind: 'shot',
- r: 0.6, dmg: 3, hp: 9, speed: 22, boom: true, col: '#ffd050', glow: 1,
- desc: ['THREE ROCKETS', 'EACH ONE TAKES THE CROWD'] },
+ col: '#3f74ff', // saturated: a pale blue washed out to grey over the lawn
+ desc: ['AIM AND RELEASE', 'A WIDENING WALL OF LIGHT'] },
+ // 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: 0.85,
+ r: 0.75, dmg: 3, hp: 9, arc: 1, boom: true, trail: 2, speed: 17,
+ col: '#ffd050', glow: 1.4,
+ desc: ['IT LAUNCHES ITSELF', 'AND EVERY ONE IS A BOMB'] },
+};
+// What a pink barrel can be carrying — the four above, which you cannot win any other way. NOT a
+// flat 1-in-4: on that, Oliver "got the laser almost every time" and never once saw the beacon.
+// Weighted, gated, and it never hands you the same thing twice running (see pickBarrelWeapon).
+// weight relative chance once it is unlocked
+// from games finished before it can appear at all
+export const BARREL_WEAPONS = {
+ beacon: { weight: 4, from: 0 }, // the showpiece, and the one you should meet first
+ flamer: { weight: 3, from: 2 },
+ laser: { weight: 2, from: 6 },
+ rocket: { weight: 3, from: 12 }, // the late-game answer: it should never turn up in game three
};
-// what a pink barrel can be carrying — the four above that you cannot win any other way
-export const BARREL_WEAPONS = ['beacon', 'laser', 'rocket', 'flamer'];
export const RACK_MAX = 4; // gathered weapons the rack holds; a 5th rides slot 0 until it is spent
export const MACH_H = 1.55; // was 1.15 — waist high read as a prop you could ignore. Still furniture
diff --git a/gameplay.js b/gameplay.js
index fb08f1e..656d68d 100644
--- a/gameplay.js
+++ b/gameplay.js
@@ -5,7 +5,7 @@ import { P, O, ball, score, crowd, G, other, deuceSide, setupPoint, sendBoy, res
import { audio, pock, swish, whoosh, applause, voice, sfx, aww, zombieMusic, zombieDuck, menuDuck } from './audio.js';
import { axisX, axisZ, takeFlickAim, touchPX, resetInput } from './input.js';
import { W } from './render.js'; // runtime-only cycle (render imports gameplay), same pattern as input/zombies
-import { updateZombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, zombies, gore, explode, meleeSwipe, extras, parts, pellets, shots, playerGore, eatenBleed, resetZombies , firePellet , spawnBarrel } from './zombies.js';
+import { updateZombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, zombies, gore, explode, meleeSwipe, extras, parts, pellets, shots, playerGore, eatenBleed, resetZombies , firePellet , spawnBarrel , fireBeam } from './zombies.js';
// ------------------------------------------------------------- ballistics ----
// Launch ball from (x0,y0,z0) so it arrives at (tx, tz) with horizontal speed s under gravity g.
@@ -264,6 +264,15 @@ function hitServe(sv, quality, aim = null) {
// rack's call now. GUN (slot 4) never reaches here; it starts a burst before the serve.
const wsel = G.mode === 'splatter' ? G.weapon : 0;
const wid = wsel === 0 && !G.temp ? 'ball' : slotId(wsel), armed = slotStock(wsel) > 0;
+ // THE LASER fires instead of the ball, down the lane this serve was aimed at. Everything else
+ // about the gesture is untouched — the pull, the aim, the release — which is the whole point of it.
+ if (wid === 'laser' && armed) {
+ spendSlot(wsel);
+ fireBeam(ball.vx, ball.vz);
+ ball.active = false; G.phase = 'over'; G.overT = 0.5;
+ sv.pose = 'serve'; sv.poseT = 0;
+ return;
+ }
ball.bomb = wid === 'bomb' && armed;
if (ball.bomb) spendSlot(wsel);
ball.flame = wid === 'fire' && armed;
@@ -878,21 +887,18 @@ function racketAI(rz, dt) {
}
}
-// A GUN-like weapon isn't a ball — committing to one braces you at the baseline for a window (the
-// same mode a horde uses) and the serve gesture spends it instead of serving. Generalised from
-// tryGunBurst: 'burst' weapons auto-fire at their own rate for `len` seconds, 'shot' weapons hold
-// the same stance but fire one per TAP until the magazine is out. G.mgT stays the mode timer for
-// BOTH, which is why the eighteen `!(G.mgT > 0)` guards across five files did not have to change.
+// A BURST weapon isn't a ball — committing to one braces you at the baseline for a window (the same
+// mode a horde uses) and it fires ITSELF for `len` seconds while your finger only steers. G.mgT is
+// the mode timer, which is why the eighteen `!(G.mgT > 0)` guards across five files never changed.
+// There was briefly a third kind that fired one round per tap; Oliver read it as the weapon being
+// broken, so a barrel weapon is now either this or a serve. Nothing waits for a tap.
function tryArmGun() {
if (G.mode !== 'splatter') return false;
const id = slotId(G.weapon), w = WEAPONS[id];
- if (!w || (w.kind !== 'burst' && w.kind !== 'shot') || slotStock(G.weapon) <= 0) return false;
- const slot = G.weapon;
+ if (!w || w.kind !== 'burst' || slotStock(G.weapon) <= 0) return false;
G.gunId = id;
- if (w.kind === 'burst') { G.mgT = w.len ?? TUNE.mgPickupLen; G.gunShots = 0; spendSlot(slot); }
- // a 'shot' weapon spends per ROUND, not on commit, so the magazine IS the stock: hold the stance
- // for as long as it could take to empty it, and gunShots ends the window early when it runs out.
- else { G.gunShots = slotStock(slot); G.mgT = TUNE.shotHoldLen; }
+ G.mgT = w.len ?? TUNE.mgPickupLen;
+ spendSlot(G.weapon);
G.mgCool = 0;
ball.active = false; G.phase = 'rally';
P.pose = 'idle'; P.poseT = 0; P.swingT = -1;
@@ -900,22 +906,7 @@ function tryArmGun() {
G.flash = { text: w.label, t: 1.0 };
return true;
}
-// a tap while a 'shot' weapon is braced fires ONE round. Returns true if it consumed the tap.
-export function tryGunShot() {
- const w = WEAPONS[G.gunId];
- if (!(G.mgT > 0) || !w || w.kind !== 'shot' || G.gunShots <= 0) return false;
- firePellet(w);
- G.gunShots--;
- // the magazine lives in the slot it came from, so a half-spent laser you never finish stays half
- // spent on the rack rather than silently refilling
- const slot = [0, 1, 2, 3, 4].find(i => slotId(i) === G.gunId);
- if (slot !== undefined) spendSlot(slot);
- if (G.gunShots <= 0) G.mgT = 0; // out of rounds: the window closes, updateZombies resumes serving
- return true;
-}
-// slingshot serve, stage one: the pull-back starts the toss animation. The ball rises while
-// you aim (dotted line, render.js); the release (dragServe) fires it. Only from a fresh pretoss.
export function serveToss() {
// A NEW WIND-UP ENDS THE LAST SHOT'S FOLLOW-THROUGH. Without this he latched: hitServe sets
// pose 'serve' with poseT 0, that only expires after POSE_TIME, and the pull frame is only drawn
@@ -926,6 +917,22 @@ export function serveToss() {
if (score.server === 'P' && G.phase === 'pretoss' && !ball.active) tossBall(P);
}
+// 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.
+function tryLaser() {
+ if (G.mode !== 'splatter' || G.interlude) return false;
+ const w = WEAPONS[slotId(G.weapon)];
+ if (!w || !w.beam || slotStock(G.weapon) <= 0) return false;
+ const flick = takeFlickAim();
+ const dx = flick ? flick.fx : 0, dz = flick ? -Math.abs(flick.fy) : -1;
+ spendSlot(G.weapon);
+ fireBeam(dx, dz || -1);
+ ball.active = false; G.phase = 'over'; G.overT = 0.5;
+ P.pose = 'serve'; P.poseT = 0;
+ return true;
+}
+
// release of the slingshot aim: an instant serve to the reticle (quality fixed — placement
// is this serve's power; the toss ritual keeps the PERFECT-timing power ceiling)
export function dragServe(aim) {
@@ -975,10 +982,6 @@ export function keyPressed(code) {
swish(0.3);
return;
}
- // A braced 'shot' weapon (laser, rocket) fires ONE round per tap — that is the whole difference
- // between it and a burst weapon, and it has to sit above the auto-fire guard below, which would
- // otherwise swallow the tap as a swish.
- if (tryGunShot()) return;
if (G.horde || G.mgT > 0) return; // auto-fire modes: a tap only swishes, never serves
if (G.changeover) return; // the breather is mandatory — the melee swish above still works
if (G.interlude && G.interlude.introT > 0) return; // let the umpire finish announcing the duel
@@ -992,7 +995,10 @@ export function keyPressed(code) {
if (flick) { dragServe(flick); return; } // dragServe handles GUN (burst) and the ball check
}
if (G.phase === 'pretoss' && score.server === 'P') {
- if (tryArmGun()) return; // a GUN-like weapon selected: the gesture arms it, no ball
+ if (tryArmGun()) return; // a burst weapon selected: the gesture arms it, no ball
+ // ...and a LASER never tosses one either. The ray IS the weapon, so a two-stage toss ritual was
+ // throwing a ball into the air and then firing something else entirely.
+ if (tryLaser()) return;
tossBall(P); return; // plain tap: the two-stage toss ritual (tennis, and the duel)
}
if (G.phase === 'toss' && score.server === 'P') {
diff --git a/index.html b/index.html
index 77edb27..b08f571 100644
--- a/index.html
+++ b/index.html
@@ -179,6 +179,6 @@
-
+