Files
splatter-tennis/src/config.js
T
wuwiandClaude Opus 5 7ff8671a22 The aim cloud goes quiet, and a mistimed flick falls short of the line
Hidden behind TUNE.aimCloud rather than deleted: the reading it gives — how
much width the swipe is buying, before the ball leaves — is the only teacher
the gesture has, and the panel can flip it back live for the next look at it.

And "a little harder to hit the out" is a number: flickReach 0.62 -> 0.52,
which halves the worst-case rate from about 15% to about 7%, with the test
tightened so it cannot drift back. The shape is untouched — a perfect flick
still never sails, a badly timed full swipe still can — but a mistimed one now
lands nearer the middle than the line, which is the only half of that trade a
player who cannot see his own timing can do anything about.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 07:58:47 +02:00

1385 lines
111 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// All tuning and constants. TUNE is live-editable in game via the T panel;
// the exported consts below are structural values that code shares but the panel doesn't show.
export const TUNE = {
camZ: 42, camY: 9, camF: 406, horizon: 40, // camF/horizon are per-orientation, set in fitViewport
gravity: 9.2, // slightly floaty vs 9.8 for readability
spinFactor: 0.9, // extra dive on quality shots (fake topspin)
bounceY: 0.58, bounceXZ: 0.78,
playerSpeed: 5.4, aiSpeed: 4.3,
reach: 1.3, aiReach: 1.6, // tight contact radius: the assist positions you, the tap must be honest
fallSlideDist: 0.5, // s of run the collapse travels: total slide = the body's speed x this
fallSlideMix: [0.12, 0.35, 0.53, 0], // how the total is shared across fall1/fall2/fall3/rise, so the
// landing SKIDS. THE RISE GETS NOTHING — "the slide must be finished before
// the rise": a body pushing itself back onto its feet is planted, and still
// drifting while it stands up reads as the court moving under it. The 0.15
// it used to take is redistributed across the first three in proportion, so
// the total travel is unchanged. Tuned in #bench with the FALL button.
proneSink: 0.20, // how far a fall3 frame drops, as a fraction of its own drawn height:
// its lowest ink is the RACKET, so bottom-aligning it floats the body
// (see the note at drawZombie's drawStatic call)
swingWindup: 0.06, // s before contact window opens
swingWindow: 0.16, // s contact window length
// ALL FOUR PACE NUMBERS ARE 1.2x THEIR ORIGINALS (16/12 and 19/22) — the whole ball, tennis and
// splatter, his and theirs, moved together so nothing about the relative feel shifts.
baseShotSpeed: 19.2, qualityShotSpeed: 14.4, // horizontal m/s = base + q*quality; perfect timing = real pace
// the slingshot's pace range. Weighted hard toward QUALITY so a full draw is a smash you can
// see: 26 m/s floppy at the minimum pull, 49 m/s at full — the draw nearly DOUBLES the pace,
// which is the "harder pull, faster ball" relationship, and it is in serveQualitySpeed's share
// of the pair. (baseShotSpeed is the rally volley — shared with tennis and the CPU.)
serveSpeed: 22.8, serveQualitySpeed: 26.4,
// THE TOSS STRIKE HAS REAL STAKES NOW (Oliver: "the golden mark is very easy to hit and hitting
// below it does not necessarily make a worse serve... no option to serve harder").
apexSnap: 0.05, // m of |ball - apex| that still counts flush (was a hardcoded 0.12 — at toss
// gravity that was ~0.3 s of PERFECT, which is why the mark felt free)
apexFall: 0.55, // m of miss at which quality hits the floor (was 0.9) — mistimes cost pace
// ...and the strike gesture AIMS it: a flick as you hit sways the target and buys pace.
serveAimX: 1.8, // m of sway a full flick puts on the target — centre box to the T or the line
serveHard: 9, // m/s of extra pace at full flick strength — the harder first serve
serveHardSpin: 0.45, // extra dip the hard serve carries. NOT free style: without it the loft
// loop cut the +9 m/s BELOW a plain flush serve (measured 47.2 vs 49.3) —
// the dive is what buys the pace its net clearance
serveHardErr: 1.6,// how much a hard serve multiplies the timing scatter: pace is paid for in
// faults, which is what makes tap = safe second serve a real decision
runN: 0, // frame-count override for run cycles (0 = probe per character sheet) — bench tuning knob
runHz2: 3.4, // stride cycles/s for TWO-frame pairs (the opponents). Tuned live on the bench
// with Oliver 2026-08-14: 4.5 on six frames was strobe, 1.7 on two read as jogging,
// and 3.4 with the stride-synced bob is the one he called "way better". (6.8 was
// tried and reverted on sight — the ceiling is real.)
runHz: 1.9, // stride cycles/s for the rolled 6/8-frame run sets. The old hardcoded 4.5 was
// tuned on 2-4 near-duplicate frames; on eight real phases it strobed — and so
// did 2.4 on the back-view re-roll (Oliver: "this looks like roadrunner")
faceFlip: 0.13, // s an auto-steered direction must persist before the body mirrors (earnFlip).
// The rolled cycles hold a real racket, and an un-earned flip teleports it
// across the body — the "racket flashing left and right" report
trailSpeed: 13, // m/s of horizontal pace at which a rally ball earns the ghost streak in every
// mode (the pow>0.8 gate alone was only ever reached by the gore slingshot)
oppBright: 1.3, // brightness baked into every OPPONENT sprite copy at load (render.brighten) —
// the far bodies read dark against the crowd. Load-time: reload to retune
serveSpin: 1.3, // extra gravity a full-power serve carries (was a fixed 0.35). Without it the
// net-clearance loop ate the whole smash: a flat fast ball can't get over the
// tape AND come down on a target, so it got throttled back to walking pace.
// Heavy topspin lets it leave flat and DIVE, which is what a smash actually is.
smashBoost: 0.42, // extra pace on a FORWARD flick (aim.az < 0), scaled by timing. It does not
// send the ball longer — solveShot still lands it on the same spot — it
// gets there flatter and sooner, which is what a smash is.
aimError: 2.4, // m of scatter at quality 0
// NOT `aimReach` — that name is taken further down by the slingshot's aim-ray LENGTH (22 m), and
// the duplicate key silently won: every aimed shot reached 22x its width until a test caught it.
flickWidth: 3.95, // m off centre a PERFECTLY timed flick reaches. The singles line is 4.115,
// so this is the corner with 17 cm to spare. Raising it past the line does
// not make the flick punchier, it makes a perfect one go out.
flickReach: 0.52, // how much of an aimed shot's width a MISTIMED flick gets (1 = all of it).
// 0.42 made going out IMPOSSIBLE, which fixed the 8-in-10 bug and then
// overshot it: "flicking: too easy. i must be able to play out." At 0.62
// a mistimed flick sailed ~15% of the time at quality 0 and a perfectly
// timed one never could — the risk was back, and timing rather than a
// coin toss decided it. 0.52 keeps that shape and halves the worst-case
// rate to ~7% ("make it a little harder to hit the out"): a badly timed
// flick now lands NEARER THE MIDDLE rather than nearer the line, which
// is the only half of the trade a player who cannot see his own timing
// can do anything about. THIS is the out dial — aimError is shared with
// the opponent's returns and with every aimed shot in the gore modes.
// Reach x quality must stay under the sideline once aimError is added on
// top, or the flick goes out — which is exactly the bug this fixed:
// 3.3 * 0.45 + 2.4 = 3.89 against a 4.11 m line. Raise it and the flick
// gets punchy again AND starts sailing wide on sloppy timing.
// THE LENGTH OF THE SWIPE IS THE STRENGTH OF THE AIM (px of finger travel; see gameplay.swayOf).
// The rally flick used to be read as three LANES: 0.38 of its own length sideways bought the whole
// corner, a hair under it bought dead centre, and nothing lived in between — so the same gesture
// came out "very strong" or "barely anything" and which one you got was invisible (Oliver: "a lack
// of agency, the player can't really tell if there was skill or luck"). The serve already read its
// flick as a continuous strength; the rally does now too, on the same shape of numbers.
flickMin: 28, // px before a drag counts as a flick at all — input.js's own threshold
aimCloud: 0, // 1 draws the live aim cloud under the moving thumb (render's G.swipe
// block); 0 hides it. OFF after its first playtest — "hide the indicator
// for now" — kept as a switch rather than deleted because the reading it
// gives (how much width the swipe is buying, before the ball leaves) is
// the only teacher the gesture has, and the panel can flip it back live.
flickFull: 130, // ...and px of travel that buys the FULL width. About a third of a phone
// screen: a short swipe is a safe half-court ball, and only a committed
// one goes hunting the line, which is what makes going OUT a chosen risk.
perfectMs: 45, // |offset from window center| for PERFECT
// The tennis CPU only exists in the OPENING match (after the transition, tennis never comes
// back — duels use racket* dials), so these three are the "how long until the first game is won"
// knobs. Testers needed several games to get past him: softer cap, twice the whiffs, and
// aiFlubLoss rubber-bands him — every game HE takes adds that much flub, so a struggling player
// faces an opponent that visibly starts falling apart rather than a wall.
aiQualityMin: 0.45, aiQualityMax: 0.72, aiFlub: 0.18,
aiFlubLoss: 0.08,
// ...and in CLASSIC TENNIS the same CPU has to last a whole match rather than one game, so the
// rubber band is turned off there (aiFlubLoss would have him in pieces by the second set) and the
// cap is raised: a mode whose whole promise is "real tennis" cannot be won by attrition.
classicSets: 2, // sets to win the match — best of three
classicQualityMax: 0.86,
classicFlub: 0.10,
aiReaction: 0.45, // s before the CPU reads your shot — well-aimed corners are winners
autoAssist: matchMedia('(pointer: coarse)').matches ? 1.15 : 0, // auto-run speed factor (mobile)
// ---- THE SIDESTEP. Free walking stays 'rally'-only (see the movement block in gameplay), which
// left you rooted to the spot through a whole boss fight — the drag is the slingshot and there was
// no gesture left over that could mean "move". A dodge is not a walk: it is one committed step
// with a cooldown, so it answers a volley without turning the serve stance into a joystick.
dodgeDist: 1.9, // m of ground the sidestep covers
dodgeT: 0.18, // s it takes — fast enough to be a reaction, slow enough to read as a step
dodgeCool: 0.5, // s before another. THE anti-spam knob: shorter and you skate across the court
dodgeIFrame: 0.28,// s untouchable, covering the step and a hair after. Beats BOTH damage channels
// (a boss volley and the instant-death body), which is the whole point of it
zSpeed: 0.7, // zombie shamble speed m/s
// ---- THE DIFFICULTY DIRECTOR. Every dial below used to bottom out by about game 12, which is why
// a 50-game run stopped changing (games 12..50 were arithmetically identical). They are all keyed
// on G.runGames now and all keep moving; the ceilings that remain exist for perf or fairness, not
// because the curve ran out. See PROJECT.md "The difficulty director".
// Calibrated to TRACK the old curve through roughly game 12 and then keep going, rather than to
// replace it: the first pass at this made games 1-30 easier, which is the opposite of the ask.
zSpawnEvery: 3.0, // s between spawns in game 1
zSpawnAccel: 0.16, // hyperbolic tightening: gap = zSpawnEvery / (1 + games * this). g5 1.67s,
// g11 1.09s (old floored at 1.0 here and stopped dead), g20 0.71s, g30 0.52s
zSpawnMin: 0.35, // hard spawn-gap floor, safety only — reached around game 41
zMax: 3, // shamblers on court in game 1
zMaxPerGame: 0.9, // cap growth per finished game (was a flat +1 into a wall at game 11)
zMaxCap: 24, // absolute shambler cap — readability ceiling, reached ~game 24
zSpeedPerGame: 0.13, // shamble-speed ramp per finished game. Was TIME-based (splatT/70) and hit its
// ceiling after 105 seconds of splatter, i.e. during game 2 — so shamble speed
// was never a progression lever at all. g12 matches the old top speed (1.75
// m/s), and it climbs to 2.7 m/s by game 22.
// THE LATE GAME HAD STOPPED GETTING HARDER (Oliver, 2026-08-15: "it now gets too easy to just
// play through — the splatter gore should be unbelievably hard towards the end"). Every ramp in
// this block used to reach its ceiling by about game 25 and sit there, while the barrel economy
// above kept climbing — so a run got RICHER after game 25 and never more dangerous. The body
// COUNT still caps (zMaxCap is a readability ceiling on a 270 px screen and stays), but what
// those bodies are and how fast they arrive now keeps climbing well past it.
zSpeedMax: 3.6, // cap on that ramp (top speed = zSpeed * 4.6), reached ~game 28 instead of 22
nastyPerGame: 0.03, // share of spawns drawn from the advanced roster, per finished game
nastyMax: 1.0, // ...and by game 34 there are NO ordinary shamblers left in the mix at all:
// every body that walks out is off the advanced roster. That is the wall the
// end of a run is supposed to be.
hordeGrow: 0.2, // horde length AND spawn rate both scale by this per set already banked
zHordeCap: 34, // hard body ceiling DURING a horde. A horde ignores the director cap on
// purpose (that is the point of it), but hordeGrow means a late wave now tries
// for ~120 spawns where the old fixed 12 s x 2.5/s could only ever ask for 30,
// and a 40-body court on a 270px-wide portrait screen is mush, not menace.
// ---- PLAYER HEALTH. A SECOND, SEPARATE damage channel: only ranged attacks touch it. A shambler
// reaching you is still instant permadeath (G.mode='eaten') — that decision is settled, see PROJECT.md.
// There is no HP bar and there never will be: the state shows as blood on the man.
playerHp: 4, // hits from ranged attacks you can absorb before you go down
hurtGrace: 1.1, // s of invulnerability after a hit — a burst must not delete you in one volley
healPerDuel: 2, // hp back for winning a sudden-death duel. THE reason to want the duel.
// ---- the rogue ball machine ('m'): real tennis furniture that shoots at you. It never shambles,
// so it never crosses your baseline and never scores — it is a standing threat you choose when to
// deal with, which is the target-priority decision the game was missing.
machEvery: 4, // games before the first machine can appear, and games between them after
machMax: 2, // machines allowed on court at once...
machMaxPer: 18, // ...plus one more for every this many games survived, up to machMaxCap. A third
machMaxCap: 4, // and fourth gun is the one kind of late pressure that does NOT crowd the
// ground: they hold position and shoot, so the court stays readable while the
// number of things that can reach you goes up.
machHp: 6, // splat-quality hits to wreck one. It is a steel cart, not a body — and it now
// shows the damage on the way down (z_machw1 dented, z_machw2 split open)
machRoll: 1.6, // m/s it trundles when it repositions. It CANNOT fire while rolling, which is
// both honest (a rolling cart cannot aim) and the window you use to close on it
machRepo: 7, // s between repositions, jittered. "Furniture that occasionally wheels itself
// somewhere else" — it stops being a landmark you can ignore once it is aimed away
machCycle: 3.4, // s between its shots
machWind: 0.9, // s of TELEGRAPH before each shot — the fairness contract, same idea as shriek()
machSpeed: 13, // m/s it fires at
machDmg: 1, // player hp a shot costs if you don't return it
shotHitR: 0.42, // m — how close a hostile ball must ACTUALLY be to hit you. Measured: the old test
// (0.55 m with the parry's 0.8 z-weighting) fired while the ball was still 0.68 m
// up-court and drawn up around his head — "visually the ball is not yet at the
// player but somehow the player already is hit". Depth is NOT weighted here.
shotHitY: 1.75, // m — and it has to be at body height, so a lob passing overhead never counts
parryPow: 0.85, // shot quality of a RETURNED machine ball — a parry is a real weapon
// The parry gets its OWN generous reach and window, not the normal swing's. Returning a shot is the
// intended answer, on a phone, with one thumb, against something aimed at you — it has to be
// forgiving. parryWindow must stay <= swingWindup + swingWindow*VOLLEY.window (0.348 s) or the
// swing clock in update() expires before the window closes.
// TIGHTENED after Oliver's second playtest: "I can tap pretty early and it returns the ball".
// At parryArm 6.0 / window 0.34 / reach 2.4 the shot flew INTO reach during the window, so any tap
// once it was vaguely close worked and the timing stopped mattering. At 13 m/s a 3.4 m arm is about
// 0.26 s of "the tap counts", which is a real reaction test and still landable one-thumbed.
parryArm: 3.4, // m — an incoming shot this close makes the next tap a SWING instead of a serve
parryReach: 1.7, // m — how far off the racket a return still connects (normal volley reach is 1.82)
parryWindow: 0.2, // s the return window stays open after the tap
// A return is a GLANCING blow: contact off the middle of the racket face throws it that many
// degrees off straight up-court. Returns used to mirror the incoming line — straight back at the
// thrower — so a boss handed you a free wound with every volley. Meeting it dead centre still
// sends it back at him; that is now a placement you earn by moving your feet.
parryDeflect: 55, // ° of deflection at the edge of the reach
parryJitter: 8, // ° of scatter on top — a bat-away is never surgical
// ---- chapter bosses: one every bossEvery games, escalating. The racket zombie is demoted to an
// occasional opponent (racketEvery) so the boss is the only thing that reads as a wall.
bossEvery: 10, // games between chapter bosses
bossHp: 20, // hp of the FIRST one; each later chapter adds bossHpPerChapter. 10 was far too
// soft — three banked flame balls took chapter 1 down (2 impact + 2.4 burn each).
// 16 -> 20 alongside the movement below: a boss that holds its ground and dodges
// is only harder if it also lives long enough for the dodging to matter.
bossHpPerChapter: 10,
bossScale: 1.5, // body height multiplier at chapter 1 (a shambler is 1.8 m)
bossScalePer: 0.18, // ...growing per chapter, so chapter 4 towers
bossSpeed: 0.42, // m/s it advances — slow, deliberate, unstoppable
bossCycle: 2.6, // s between its ranged attacks (it uses the machine's projectile)
bossVolley: 3, // shots per attack — STAGGERED IN TIME, not fanned across space. Fanning them
// +-1.9 m meant only the middle ball could ever reach you and the other two were
// decoration; Oliver called that out. Three real threats in a rhythm instead.
bossSalvoGap: 0.42, // s between the shots of one volley
bossWind: 0.5, // s of telegraph before a boss throws — shorter than the machine's 0.9 on purpose:
// he asked for a tighter reaction time, and a boss should be harder than furniture
bossDeathT: 0.7, // s of GAME time the chapter card holds off after the boss dies. The card used to
// land on the same frame as the kill and bury it — you never saw the thing you
// spent a whole fight earning. Game time, so the slow motion below stretches it
// to roughly a second and a half on the clock.
bossSlowT: 1.2, // s of REAL time the world runs slow while a boss comes apart
bossSlowMo: 0.35, // ...and how slow
bossShotSpeed: 15.5, // m/s a boss throws at (the machine's is machSpeed 13)
// ---- boss MOVEMENT. A boss used to have no AI at all: it fell through to the crowd's aimless
// ±0.15 m/s wobble and crawled forward until it simply arrived on top of you. Each design now owns
// a movement style (BOSS_ART.move) and these are the shared numbers it spends.
bossStrafe: 1.7, // m/s sideways — bossSpeed above is only the FORWARD crawl
bossPaceX: 3.4, // m either side of the centre line a pacing boss turns at
bossShadowX: 1.7, // m either side of YOUR lane a 'shadow' boss turns at — its beat follows you
bossZMin: -1.0, // the band a boss holds. Without a clamp a fight ends by the boss walking into
bossZMax: 5.6, // you (~57 s at bossSpeed) rather than by you killing it — and every extra hp
// above just made that MORE likely, not less. Only 'charge' breaks the band.
bossChargeV: 4.2, // m/s the physio rushes at once it commits
bossChargeT: 2.2, // s ONE RUSH takes, start to arrival — the charge speed is derived from it and
// from how far away it is (bossAI), so the run-up reads the same every time and
// is long enough to see coming. Was 1.5 s at a flat 4.2 m/s, which only reached
// you at all because the boss crept nearer every cycle until it was on top of you.
bossStrike: 0.4, // m SHORT of you the rush stops — its STRIKE LINE, reached exactly as the rush
// timer runs out, so the boss turns and withdraws on the same frame it arrives.
// A rush that stopped ON you left it standing there afterwards, and no amount of
// sidestepping gets you out from under that (dodgeCool 0.5 s vs a 0.28 s
// i-frame). It cannot run PAST you either: a body over the baseline is deleted
// and scores a point (see updateZombies), which quietly despawned the boss
// mid-fight the first time this ran through. At 0.4 m the contact test still
// reaches about 0.85 m either side of the lane, and the sidestep covers 1.9.
bossChargeRest: 2.2, // s of held ground between charges — the window you get to hit it
bossHopT: 0.55, // s per mascot hop (it moves in arcs, not in a slide)
bossHopX: 2.0, // m of ground one hop covers (the eased arc lands it at about 2/pi of this)
racketEvery: 3, // games between racket-zombie (plain opponent) spawns — was EVERY won game, which
// is why he read as "the boss" and why a real boss would not have stood out
matchAsideEvery: 15, // ...but 55 s is a whole match game. In the match his asides are tied to
// things that happen every point or two — a limb, your blood — so the gore
// spacing muted him almost entirely. Same ONE cooldown mechanism, a value
// that fits the mode's own event rate.
asideEvery: 55, // s between the umpire's sarcastic asides in splatter (jittered +-40%). He never
// acknowledged that the match had changed; now he comments on it, disapprovingly.
racketHpPerGame: 0.12, // the racket zombie stiffens as the run goes on (floored into whole hp)
// CAMERA FLASHES IN THE STAND (render.tickFlashes). MEAN seconds between them — the actual gap is
// jittered 0.35x..1.95x so they never tick, and halved again while the crowd is cheering. Turn
// flashEvery UP to quieten them; it is the one knob. Life is deliberately shorter than a frame
// pair at 60 fps would need: a flash you can look AT is a light, a flash you only catch is a
// photograph being taken.
flashEvery: 5.0,
flashLife: 0.16,
// ---- damage. Bodies with hp (racket boss 'r', brute 'g') take damage in these units, so a big
// boss is just a bigger hp number and every weapon stays proportional. Fractions are legal: they
// bank on zb.chip and only whole points come off hp (the wound SKINS switch on hp thresholds).
dmgBall: 1, // a splat-quality ball or serve
dmgSide: 1, // one TRIPLE side ball — used to pass straight through the boss doing nothing
dmgPellet: 0.34, // one machine-gun round — three chip a point off. Also used to no-op on the boss
dmgBomb: 3, // caught in a blast. Was an instant kill that ignored hp entirely
dmgFlame: 2, // a direct flame-ball touch (and it sets the boss alight, which it didn't before)
bossBurnMul: 0.4, // burnDps multiplier while a boss body cooks — it burns, just slower than a body
bossHitCool: 0.2, // s a boss body is immune to further BALL contact after taking a hit. A flame ball
// has full pierce (it is not consumed on contact, so it can cut a lane), and a boss
// is not removed when hit — so the same ball sat inside its radius and re-wounded it
// EVERY FRAME. That is why one flame ball killed a 16 hp chapter boss.
changeoverT: 5.0, // s the changeover breather lasts (after odd games, as the rules demand)
boyMopSpeed: 2.2, // m/s a MOPPING ball boy crosses at (a fetching one still sprints at 7.5)
boyMopDwell: 1.8, // s he stands on a stain pushing the mop before moving to the next one
boyHurry: 4.6, // m/s he covers ground during the MATCH mop beat — brisk, so the 5 s window
// is spent scrubbing rather than walking into shot
boyMaxJobs: 4, // stains one boy will clean in a round — a kill streak must not employ him forever
matchMopT: 5, // s the mop beat holds the court between match games — the boy visibly cleans
// before the next opponent walks on (skipped when there is nothing to mop)
boyStride: 0.28, // m of travel per animation frame. Frames are driven by DISTANCE, not time, so
// the feet cannot slide: change a boy's speed and his legs keep up on their own.
gameBeatT: 1.6, // s the game-end beat lasts on even (non-changeover) games
eatenR: 0.9, // m a shambler must close to eat you — matches the visual sprite overlap
zSnap: 0.9, // m of lateral slack at zero range — the aim cone's base width (fat-thumb forgiveness)
killsPerPoint: 3, // splatter: zombie kills to score ONE tennis point (1 = every kill scores, old behavior)
zSplatQ: 0.5, // min shot quality to splatter; below = stagger pushback
zPushback: 1.4, // m a staggered zombie is knocked back
splatterOverT: 0.1, // s the spent-ball 'over' beat lasts before the next serve is armed
stuckT: 6, // s the deadlock watchdog waits before letting go of a frozen latch (see update)
tinCardT: 2.8, // s the won-duel weapon card holds (it does NOT wait for a tap). The swipe-in
// and swipe-out eat ~0.55 s of this, so every card number below is REAL time on
// screen minus that — 2.0 left the old static hold, and read as a flicker.
// the slingshot: the pull's DIRECTION picks a lane, its LENGTH is power. Deliberately DECOUPLED —
// reaching a far shambler must not cost a longer drag than the screen has room for. The old
// mapping (pull length = reach) is why you had to drag off the bottom of the phone.
pullMin: 18, // px of pull below which the release cancels — no shot, nothing spent
pullFull: 130, // px of pull for a full-power shot. Keep this well inside one thumb's travel — but
// 95 was so short that EVERY pull maxed out by accident and the meter never
// meant anything. A full draw should cost a deliberate stretch of the thumb.
// Capped short of ~150 because a pull that STARTS low on the screen has to
// still be completable downward before the finger runs off the glass.
pullQMin: 0.12, // shot quality at pullMin, scaling to 1.0 at pullFull. Below zSplatQ it only
// staggers, so a lazy flick can't kill: THE anti-spam knob.
perfectBand: 1.28,// ...and the PRAISE is a separate, narrower thing. Quality SATURATES at pullFull
// — every pull past 130 px is exactly 1.0 — so "quality == 1" meant "you pulled
// far enough", and the game shouted PERFECT at almost every serve. Oliver:
// "it displays perfect a lot even when nothing special happened". Praise now
// needs the pull to STOP in the band pullFull..pullFull*this (130-166 px), which
// is a target you can miss. The pull clicks when you enter it (see input.js) and
// goes quiet when you overshoot, so the window is learnable by feel.
praiseEvery: 14, // s between praise words, on the umpire's-asides model (asideEvery below). The
// band alone was not enough: you learn to hit it, and then the HUD talks on
// every other serve again. The CLICK is the reliable feedback for a good pull —
// the word is garnish, and garnish on every plate is just food.
aimCone: 0.10, // lane half-width added per meter of range. This is what makes the pull's ANGLE
// mean something: at 0.45 the cone was ±10 m at the far baseline — the whole
// court — so every downward pull picked the same body and the angle felt inert.
// 0.10 gives ±2.9 m out there: you have to actually point at a lane.
pullHintAfter: 1.5, // s of doing nothing in the serve stance before the pull gesture demos itself
flickHintAfter: 4, // returns hit with no aim at all before the flick demos itself. Not 2 like the
// tap lesson: hitting flat is a legal shot, not a failure, so it takes a RUN
// of them to read as "he does not know the gesture exists"
aimTie: 0.12, // how much worse-aimed a NEARER body may be and still win the lane (0..1 of the
// cone half-width). 0 = pure best-aligned wins; 1 = the old "nearest in the
// lane always wins", which is what made the magnet grab the wrong shambler.
aimReach: 22, // m the aim ray runs when no shambler stands in the lane
// How the camera moves while you're drawing the slingshot. NEGATIVE (the default) drops it and
// pushes in — you crouch into the shot. POSITIVE lifts it and leans back for a steeper, more
// top-down look, which reads the depth behind the net better but feels less like aiming.
// Both 0 = the old fixed camera.
aimCamY: 0, // m of camera height change (negative drops you into the shot)
aimCamZ: 0, // m of camera dolly (negative = closer). Both 0 = the camera holds still,
// which is where Oliver landed: the aim graphics carry the read on their own.
// FAR-BODY READABILITY. Bodies (never the court) draw off a flattened scale curve:
// s' = s * (sNear/s)^farK, anchored at the player's deepest stance so HE is untouched. The court
// lines carry the perspective; an oversized far body reads as "legible", not "wrong" — the
// 16-bit tennis trick. It buys what the lens could not: the camZ-55 note above fitViewport
// measured the court being spent (umpire clipped, top-down look) for the same far pixels.
farK: 0.25, // 0 = pure projection. 0.25 grows the far-baseline opponent ~21%.
// BALL-FOLLOW LEAN (tennis + match only): while the ball is on the FAR half the camera eases
// toward camY+farCamY / camZ+farCamZ — a broadcast-style lean into the deep court. It pivots
// about the near player's feet (see fOff in render.js), so his stance and the pull room the
// apron math promised never move. Both 0 = the camera holds still, which is where Oliver
// landed ("the camera movement is distracting") — the same verdict the aimCam pair got. The
// flattened body curve above carries the far-court read on its own.
farCamY: 0, // m of camera rise while the ball is far (steepens the look-down)
farCamZ: 0, // m of camera dolly while the ball is far (negative = closer)
backstopZ: -1.8, // z of the far backstop — must sit IN FRONT of the -2 retire line, or balls die before they reach it
backstopKeep: 0.55, // pace a ball keeps after the rebound — still lethal, slower
backstopPop: 1.8, // m/s of lift off the fence, so the rebound travels back instead of dying at the net posts
meleeCool: 1.0, // s between racket swishes — melee is the panic button, not the default
meleeCrowd: 0.38, // ...but the panic button PANICS. The cooldown shortens with how many bodies are
// actually on top of you, so a late horde stops outrunning your only close-range
// answer: cool = meleeCool / (1 + (near-1)*meleeCrowd), floored at meleeCoolMin.
// 1 body 1.00s, 4 bodies 0.47s, 8 bodies 0.28s.
meleeCoolMin: 0.22,
meleeCrowdR: 3.4, // m — the radius that counts as "on top of you" for that scaling (> meleeRange,
// so the swish speeds up as they close in, not only once they are already on you)
zPierce: 0.75, // pace a ball keeps after a splatter — hot balls plow through lined-up pairs
// ---- SLICE. Not a tin and not a rack slot: it is what a PERFECT serve DOES. Cut the ball and
// each body it kills strikes it on toward the next one instead of it plowing straight through.
// Every other weapon is something you are handed; this one is a technique, which is why it is
// earned on the timing window (the toss ritual's 45 ms, or a pull that STOPS inside perfectBand)
// rather than won out of a barrel. Only a KILL carries it on — a wound ends the chain — so it is
// a crowd skill shot that does nothing to a brute, a steward or a boss, and it hands the plain
// ball a route to the DOUBLE/TRIPLE KILL ladder only a bomb or a lucky line-up could reach.
// It is ONE new velocity per kill and then ordinary ballistics: see sliceCarom for why the ball
// must never steer, whatever it costs in prettiness.
sliceHops: 2, // extra bodies one perfect serve is struck on to. 2 => TRIPLE KILL is the ceiling
sliceR: 6.5, // m it will reach for the next body. Wider reads as the game aiming for you;
// much narrower and a spread-out crowd never chains at all
sliceArc: 120, // deg: widest bearing off the ball's heading it will take. A carom can physically
// go anywhere, but one that doubles back down-court reads as a stray deflection
// rather than as a cut, and the chain should be walking INTO the crowd anyway
// The schedule calls the DUEL, not the tin: weapons are only ever won, but a duel comes round
// on its own so there is always a chance at one. Fires off the next hand-cleared wave.
duelFirst: 2, // game after which the first scheduled sudden-death duel is called
duelEvery: 3, // games between scheduled duels after the first
// How many a fresh tin holds now lives on its WEAPONS row (`tin`), because the rack is no longer
// four fixed types — bombMax/tinTriples/tinFlames/tinGuns lived here and nothing read them after
// the table landed, which is a value you can tune in the panel all day for no effect.
wCap: 99, // per-type cap on the courtside rack. Effectively NO cap: at 9 a late run spent
// every duel win on a full tin, and the answer to game 70 is a bigger stack, not
// a rationed one. 99 rather than Infinity only because the count is drawn as
// text in a rack slot and '×999' does not fit.
rackH: 30, // px height of the bottom weapon rack (feel knob: how much court it covers).
// 22 logical px is ~30 CSS px on a phone — under half Apple's 44px target, which
// is why switching tins felt fiddly. rackHit also pads the touch band (render.js).
bombRadius: 4.2, // m blast radius of a bomb ball — 2.5 barely cleared the bodies stood on the crater
hordeWarnT: 2.4, // s of THEY KEEP COMING breather between the set card and the wave landing.
// The horde used to start on the same frame as its own announcement, which reads
// as no announcement at all — Oliver stopped noticing hordes were being called.
hordeLen: 12, // s a horde wave lasts
hordeRate: 3.5, // zombies spawned per second during a horde. Raised WITH the gun buff below: the
// wave is meant to leave a standing crowd on your half when it ends, not to be a
// twelve-second interruption that clears itself.
mgRate: 12, // machine-gun pellets fired per second during a horde. It scales with hordeGrow too
// (see startHorde): at 6/s single-target the gun simply could not out-kill a grown
// wave — Oliver died on the second horde of every run.
mgSpread: 6, // ± degrees of pellet spread — a cone that sweeps bodies, not a needle up the middle
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
// ---- TEST MODE (triple-tap the scoreboard). A sandbox for trying the new content: a steady
// MEDIUM wave regardless of how far into a run you are, and every barrel weapon on the rack,
// kept topped up so nothing runs dry mid-test. It overrides the difficulty director rather than
// editing it — G.runGames keeps climbing underneath and the run is still a real run.
testZMax: 16, // bodies on court. Was 8 and the sandbox kept running dry between kills — a
// crowd weapon needs a crowd to test it against
testZSpeed: 1.15, // m/s shamble — medium, and FIXED (no per-game ramp)
testSpawnEvery: 0.9,// s between spawns: they keep coming
testLevel: 20, // the roster level pickVariant pretends to be at, so every body type can turn up
// ---- THE LASER's wedge: aim and release like a serve, and a plane of light widens down the lane
beamLen: 23, // m it reaches — the DRAWN length as well as the kill length. It used to be only
// the kill: the drawn length was solved each frame so the far end landed on the
// middle of the ad strip, which meant turning this knob did nothing you could
// see. One meaning now. Tuned WITH beamY: a lower plane reaches the ads sooner,
// so dropping one wants the other shortened. Oliver's numbers, 2026-08-03.
beamY: 0.95, // m — the height the plane HOLDS down the court, and the line it cuts bodies at.
// It used to slope to the ground by about 20 m out, which meant that across the
// whole far half it lay flat on the grass: nothing to cut, and the split would
// have been invisible exactly where the crowd stands. Chest on a shambler, belly
// on a brute, thigh on the roller man — so a bigger body visibly takes it lower.
beamOriginLift: 0.44, // m the apex sits ABOVE beamY. It is a lift, not an absolute height, so
// lowering beamY brings the near end down with it — set as an absolute, dropping
// the plane left the apex hanging at chest height with the beam sloping away
// beneath it. It leaves the PLAYER, not the racket head: emitted from the racket
// (2.2 m) the apex floated above him and read as detached.
beamRise: 0.14, // fraction of the length it takes to settle from the origin to that height
beamW0: 0.08, // m half-width at the racket — very nearly a point
beamW1: 3.1, // ...and at the far end. It IS a cone — but a gentle one. At 5.0 the world width
// grew so much faster than the projection shrank that on screen it opened out
// while the court receded, and the eye read that as a flat shape stuck to the
// glass. Growing slowly, the two roughly cancel and it lies in the world.
// A/B, live from the T panel on the phone. 1 = draw the laser the way the BALL's aim band is
// drawn: a path down the lane whose width comes from each point's OWN projection scale, so it
// tapers into the distance and sits in the world. 0 = the solid widening cone.
// The cone is a real projection, but it GROWS in world width (0.06 m -> beamW1), so on screen it
// opens out while everything else recedes — which is what reads as a flat sticker.
beamBorder: 0.18, // px of the two outer edge lines — a hairline. At 1 they were the heaviest
// thing on the court and boxed the cone in.
beamBand: 0, // 0 now: the CONE won, once it stopped ballooning (see beamW1). >= 0.5 is ON —
// the panel steps by 0.02 and a nudge to 0.02 used to flip the renderer silently
beamBandW: 0.30, // world half-width of that band — CONSTANT, which is what makes it taper
beamT: 0.42, // s the wedge stays drawn
dmgBeam: 8, // what it takes off a BOSS. An ordinary body is not damaged by the beam, it is
// DELETED by it (see fireBeam); bosses "must suffer severely" but survive.
// ---- the weapon barrel: the only way to get the four weapons that are not on the duel ladder
// HALVED, AND FLOORED (Oliver, 2026-08-15: "in splatter gore it now gets too easy to just play
// through, maybe we should reduce the barrels again — pretty early in the game every won game
// gives a barrel"). He is right and the arithmetic says so: at 3/8 the gap hit ONE at game 16 and
// stayed there, so the whole back half of a run handed you a weapon for every game you won, which
// is the opposite of a difficulty curve. The ramp is still there — the late game does need more
// inventory than the early one — it is just slower and it stops.
barrelEvery: 5, // games between barrels being rolled on (like the ball machine's machEvery)
barrelRamp: 14, // ...and the gap SHRINKS by one for every this many games finished
barrelFloor: 3, // ...but never below this. g14 every 4, g28 every 3, and that is as good as it
// ever gets — a barrel is a reward for surviving, never an income.
barrelRoll: 2.6, // m/s it trundles across the far court — fast enough to have to be led
barrelHp: 1, // anything pops it: ball, pellet, bomb, flame, swish. It is a target, not a body
barrelH: 1.15, // drawn height (a body is 1.8)
// ---- THE TRIAGE DRILL (src/drill.js) — the cognitive mode, fourth design. ONE RULE: always
// kill the body that would reach you first. Nothing is called and nothing is shown — the court is
// the puzzle, and the skill is reading speeds against distances (a runner at the far baseline
// beats a roller at the service line). The streak multiplier is the hook; the score is what you
// come back to beat.
drillMaxOnCourt: 7, // bodies on court at once — the queue you are reading. Past ~7 it is noise
drillSeed: 4, // bodies placed at the start so there is a queue to read from the first second
drillSeedGap: 2.6,// m of depth between those seeded bodies
drillSpawnZ: 0.6, // z they enter at (the far baseline is 0)
drillSpread: 3.4, // m either side of the centre line the lanes fan across
drillSpeed: 0.85, // m/s at the bottom of the ramp (x the type's vMul)...
drillSpeedMax: 2.0,// ...and at the top. Stamped per body AT SPAWN — constant v is also what lets
// arrival() be a plain division, so the queue is honest
drillSpawnEvery: 3.2, // s between arrivals at the bottom of the ramp...
drillSpawnMin: 1.1, // ...and at the top. THE pressure dial: the queue outgrows your clearing rate
drillRampT: 210, // s to go from the bottom of every curve above to the top
// THE JUDGEMENT SLACK. Two bodies arriving within this of each other are BOTH legal kills —
// without it, near-simultaneous arrivals made the rule a coin flip and a fault read as theft.
// Tightening it is the real difficulty curve for good players; below ~0.4 it starts punishing
// reads the eye genuinely cannot separate.
drillSlack: 0.9,
drillMultEvery: 4, // correct kills per multiplier step...
drillMultCap: 5, // ...capped: a deep streak should feel valuable, not become the whole score
drillMark: 1, // >= 0.5 draws chevrons over every LEGAL kill (the slack set, bright head,
// dim ties). Training wheels, not a cheat: see drillMarkOff
drillMarkOff: 8, // streak at which the markers hide — you have shown you can read the court, so
// the teacher steps back. Any fault (streak to 0) brings them back. This is the
// learnability answer: the marker teaches, the streak decides when you have
// learned, unlearning re-teaches
drillOneShot: 0, // >= 0.5: every drill body drops to 1 hp. OFF after one playtest: "way too easy"
// AND a 1 hp body skips every ZWOUND stage, so the gore art never renders.
// Also flattens triage — pre-softening a heavy IS the planning layer
drillCaughtBack: 45, // s of ramp handed back when one reaches you. A real setback, not a reset
// ---- ZOMBIE TENNIS (beats.startMatch) — the exhibition match. One game per opponent, blood only.
matchOppHp: 4, // the first opponent's pool. FOUR, deliberately: a quarter comes off per point
// won, so a love game walks it through every wound stage and the game point
// fells it — the scoreboard and the body agree all the way down
matchPlayerHp: 8, // your pool for the MATCH — richer than the gore mode's 4, because a real game
// has rallies you deserve to lose without the run ending ("the player is too
// weak in this mode"). Their point still costs 1; matchHeal is how you last
matchSpeedBase: 1.25, // racketAI speed multiplier for opponent ONE. 1.4 was calibrated against
// the broken recovery (the player never walked back to centre, so every
// wide return looked like opponent strength) — with real footwork restored
// it starts gentler and the ladder still climbs
// STEEPER PER RUNG (2026-08-12): "in general this is too easy now". The flick fix means an aimed
// return can no longer sail out, so the player stopped handing over free points and the ladder had
// to earn them back. The OPENING is deliberately softer than before (the shambler starts at 0.72
// where the old first opponent was 0.85) — the ramp got steeper at both ends, not just harder.
matchSpeedPer: 0.11, // ...growing this much per opponent felled. Softened once: each rung moves
// THREE dials at once (speed, flub, width, pace), and the recorder showed
// opponent two as a wall — he beat one and lost every gap to two by ~0.3 m
matchFlub: 0.08, // the match's own whiff base — the duel's 0.2 gives away a fifth of all
// returns, which across a game is an opponent that beats itself
matchFlubKeep: 0.76, // ...and it keeps only this much per rung — the ladder now that hp is flat
// THE WOUNDS PLAY, not just show. The score is the body, so the body it has left should be worth
// something at the racket: a one-armed opponent cannot cover as much, a headless one whiffs more.
// Deliberately SMALL per stage (Oliver: "not too pronounced") — the ladder is still the difficulty
// curve and this is the point-to-point texture under it, worth ~12% reach and ~35% more whiff at
// the LAST stage, which is one point from falling anyway. Read through racketStage, never hp.
matchWoundReach: 0.06, // reach lost per wound stage (1 arm off, 2 headless) — match only
matchWoundFlub: 0.175, // ...and extra whiff earned per stage, on top of the rung's own rate
matchReach: 1.35, // contact reach multiplier in the MATCH. The recorder showed swings missing by
// centimetres point after point — the assist parks you near the ball, not inside
// a 1.3 m reach. A rally mode lives on contact feeling fair. Match-only
matchAssist: 1.15, // auto-run floor DURING A MATCH, any device. autoAssist keys off pointer:coarse,
// so desktop had no legs at all — the match is a tap-timing game and footwork
// is the game's job. Held keys still override
matchOppPace: 0.78, // scale on the opponent's return SPEED for opponent one (~21 m/s instead
// of ~27) — the flight time auto-run needs. THE reachability dial
matchOppPacePer: 0.05,// ...climbing back per rung, capped at full pace (softened with speedPer)
matchWideBase: 2.1, // m either side of centre the FIRST opponent's returns may land — the width an
// auto-run player can actually cover. Its scatter painted lines from game one
// ("still a LOT of unreachable balls"); now line-painting is the late ladder
matchWidePer: 0.25, // ...earning this much more width per opponent felled (softened with speedPer)
matchHeal: 2, // hp back for a game won (the duel's healPerDuel rate)
matchBoomHold: 0.5, // s the game-winning body lies prone before it bursts — the fall must be SEEN
killGush: 3, // prone bleed-out during the kill fall: particles ATTEMPTED per frame (gushProne)
// — the body visibly bleeds out on the ground before it bursts
matchBoomCard: 0.6, // s after the burst before the WOUNDS TREATED card may land on top of it
// The timing windows, DRAWN (the match's whole ask: "what is the possible tap window, what is the
// sweet spot"). Numbers shared with the mechanics they visualise, so the picture cannot lie:
matchRingFar: 9, // m — the approach ring fades in once the incoming ball is this close
matchRingSweet: 2.7, // m — contact distance where the rings kiss (the 30-tapped-points benchmark
// lands hits tapping at 2.2..3.2 m, so the sweet spot is its centre)
matchRingBand: 0.5, // m either side of sweet that draws gold — the plausible window, made visible
fps: 60, // frame cap — 30 halves battery/heat but Oliver felt it; smoothness wins (0 = uncapped)
lowPower: 1, // >= 0.5 caps RES at 3 (−44% of pixels painted per frame) — the battery saver,
// flippable live from the T panel. DEFAULT ON since 2026-08-08: Oliver judged
// the sharpness trade invisible on his phone. Play speed and fps are untouched.
// Set to 0 for the full-fat near-1:1 render.
runnerSpeed: 3.2, // runner ('f') speed multiplier over the shamble
runnerVMax: 4.2, // hard runner speed cap — the late-game ramp must stay humanly reactable
shamblerHp: 2, // splat-quality hits a classic shambler ('a'/'b') soaks before it dies — only the runner ('f') is still one-shot glass
stewardHp: 5, // the steward ('d'). Was 3 and died like a shambler, which made a bigger body read
// as decoration; at 5 he is a brute's worth of hp on a faster, ordinary-looking
// walker, and he has a wound skin (z_dw) so the second half of that shows
hordeSteward: 0.2, // share of horde spawns that come out as stewards (from game 4)
// ---- 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: 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.
burnTime: 3.0, // s a body burns before the fire goes out
burnDps: 2.0, // hp lost per second while burning — burnTime*burnDps must exceed bruteHp or tanks walk out of the fire
burnSpread: 2.2, // m — a burning body ignites anything this close, every frame it burns
burnIgniteR: 4.5, // m — the flame ball's ignition radius around the body it kills
burnBallR: 2.4, // m — the flame ball also lights anything it FLIES PAST. Without this the ball's
// full pierce simply killed the whole lane outright and nothing was left to catch fire.
burnPanic: 1.0, // walk-speed multiplier while burning (1 = off; crank it for panicked sprinting)
setCardT: 2.8, // s the centered SET card holds (the 'kurze unterbrechung' before the horde)
bossWinT: 4.4, // ...and s the EPIC! card holds after a chapter boss. Its OWN number: a boss is
// the longest fight in the game and the win was over before it registered, but
// stretching setCardT would have made every routine set announcement drag too.
bossIntroT: 3.0, // s the boss walk-on overlay holds the frozen court before it lets go BY ITSELF.
// A boss used to arrive on a band card nobody read ("bosses are just there now") —
// this is the one announcement the player has to be prepared by, not informed of.
modalIn: 0.34, // s the modal swooshes in — icon+title from the right, body cascading from the
// left, each on easeOutBack. The gesture that dismisses is swallowed until it
// lands, so a panic tap can't skip a popup before it has been read.
bruteStride: 0.5, // m the brute covers per lurch frame — it is the slowest thing on court, so
// its cycle is driven by ground covered rather than by the clock
bruteHp: 5, // splat-quality hits (or pellets) a brute ('g') soaks before it dies — one wound skin per stage
shieldDown: 1.5, // s a shield zombie's ('s') chair stays dropped after a block — the kill window
meleeRange: 1.7, // m — a racket swish cleaves shamblers this close to you
racketHp: 3, // splat-quality hits a racket zombie (mini-boss) takes before it dies
racketStride: 0.42, // m the racket zombie covers per walk frame — his cycle steps off GROUND
// COVERED, so he stops animating when he stops moving
racketSpeed: 2.8, // m/s the racket zombie shambles to chase a ball
aiRead: 0.8, // how much of the BOUNCE the racket zombie reads while the ball is in the air:
// 0 = the old pure pursuit of the live ball (a hard, angled serve was an
// automatic ace — 0/40 returned, measured), 1 = never wrong-footed. See racketAI.
matchRecvX: 2.45, // m off the centre line the match opponent WAITS on, receiving. The tennis mark
// (1.3) covers the T and concedes the corner; the box runs to 4.1. See matchReady.
aiReturnDash: 1.3, // ...and how much faster he moves for the SERVE only, the first step a
// returner takes. Serve-scoped on purpose: the rally is unchanged.
racketQMax: 0.55, // its return quality is capped here (slower, sloppier than the tennis CPU)
racketFlub: 0.2, // chance the racket zombie whiffs a return
missHud: 0, // show the last miss reason on screen (full log: T.G.missLog) — off; dev-only
killPips: 1, // splatter: show the kills-to-next-point pips on the scoreboard
};
// the death animation's timeline, in seconds of G.eatenT. A: thrash · B: you burst into gore
// · C: the summary fades in. The restart tap is gated past the whole thing so a panic tap
// during the burst can't skip it.
// assets/audio/tennis-applaus.wav runs 4.16s and always plays in full, so the crowd must keep
// clapping at least that long — every cheer() call was passing a shorter beat, which left the
// stand sitting back down while the applause was still audible.
export const APPLAUSE_T = 4.2;
// A: they drag you down and you fight it on your knees · B (EATEN_BURST): the crowd closes over you
// and what is left is the mound · C (EATEN_SUMMARY): the summary fades in over them still feeding.
// It is deliberately long. Oliver: "the movies surround and bite them, blood spills, and then the
// person basically drowns in the zombies" — that cannot happen in half a second.
export const EATEN_BURST = 2.3, EATEN_SUMMARY = 3.6, EATEN_RESTART = 4.6;
// ...and the same two beats for the ending with no corpse in it (a classic match played out). It is
// shorter because there is no animation to sit through — only the crowd finishing, and the summary.
export const DONE_RESTART = 2.0;
export const EATEN_FEED_R = 1.15; // m — how tight the feeding ring packs around the body
// UI accent colours, one place so the whole HUD can be re-tuned at once. Deliberately
// desaturated from the original arcade primaries: they were shouting over the artwork.
// (NOT the gore — blood and piles keep their loud reds; only text and interactive UI.)
export const UI = { hot: '#d9705f', warm: '#e0cc72', pick: '#cbd06a', cream: '#e8e8dc',
dim: '#8fa0b8', bad: '#a34a42', ball: '#dbe063',
// the weapon barrel's glow, and the only pink on the court: nothing else in the
// palette is near it, so "pink means a weapon is in there" needs no explaining
pink: '#ff5cf0' };
// ------------------------------------------------------------------ courts ----
// A court type is pure DATA: adding one is a row here plus its four PNGs, never a change to render().
// Each carries TWO palettes because every court still rots — `tennis` is how it looks before the
// horde arrives, `splatter` is the drained version render.js lerps to (see paletteT).
// stripes mown lawn bands. Grass only: clay and acrylic are laid flat, and drawing stripes on
// them was the single thing that made a recoloured court still read as grass.
// line painted line colour. Chalk-white reads on green and blue; on clay it has to go warmer
// or the lines glow like neon.
// ads/crowd sprite BASE names; the clap frame is <crowd>2 (see the two-frame note in render()).
// bounceY/XZ copied into TUNE on selection, so the T panel keeps editing the live values and the
// three physics call sites never learn that courts exist. Grass skids low and fast, clay
// sits up and drags, acrylic is the middle it always was.
export const COURTS = [
{ id: 'grass', name: 'CENTRE COURT', sub: 'GRASS · fast, low skid', tile: 'court_grass',
ads: 'x_ads', crowd: 'x_crowd', stripes: true, line: '#e8e8dc', border: '#e8e8dc',
bounceY: 0.50, bounceXZ: 0.84,
tennis: { stands: '#232e42', tierA: '#2a3750', tierB: '#26324a', board: '#0e5c2e',
out: '#295c2d', lawn: '#4cae52', stripe: '#46a54c' },
splatter: { stands: '#181426', tierA: '#201a33', tierB: '#1c172e', board: '#3a1212',
out: '#20331c', lawn: '#5e7747', stripe: '#576f42' } },
{ id: 'clay', name: 'TERRE ROUGE', sub: 'CLAY · slow, high kick', tile: 'court_clay',
ads: 'x_ads_c', crowd: 'x_crowd_c', stripes: false, line: '#efe3cf', border: '#efe3cf',
bounceY: 0.66, bounceXZ: 0.70,
tennis: { stands: '#4a3025', tierA: '#5a3a2c', tierB: '#513428', board: '#8a4426',
out: '#7a3f26', lawn: '#c4652f', stripe: '#c4652f' },
splatter: { stands: '#2a1a18', tierA: '#33201d', tierB: '#2d1c19', board: '#3a1212',
out: '#4a2a1c', lawn: '#8a4a2a', stripe: '#8a4a2a' } },
{ id: 'hard', name: 'THE SLAB', sub: 'HARD · true bounce', tile: 'court_hard',
ads: 'x_ads_h', crowd: 'x_crowd_h', stripes: false, line: '#f2f4f8', border: '#f2f4f8',
bounceY: 0.58, bounceXZ: 0.78,
tennis: { stands: '#1d2836', tierA: '#243141', tierB: '#1f2b3a', board: '#12525e',
out: '#2c7a52', lawn: '#1f5c93', stripe: '#1f5c93' },
splatter: { stands: '#141a26', tierA: '#1a2130', tierB: '#161d2a', board: '#3a1212',
out: '#25452f', lawn: '#1c3e5c', stripe: '#1c3e5c' } },
// THE NIGHT COURT (Oliver, 2026-08-15: "a doomy gloomy court, the lines are bright"). The only
// court whose TENNIS palette is already darker than everything else's SPLATTER one — which is
// the whole idea: it starts where the others end up, and the rot then takes it further still.
// The lines need no new knob to be bright: paintBackdrop paints every line in UI.cream, so on a
// near-black surface they read as chalk under floodlights on their own. The ball is UI.ball and
// is bright for the same reason. It borrows the hard court's night-blue stands and boards rather
// than growing two more sheets — the stands are meant to be a wall of dark up there.
// `dim` is the one new knob: how far down the STAND is taken before the rot even starts (render's
// standDim). Without it the borrowed hard-court crowd sits floodlit and blue above a near-black
// court and the whole picture stops being a night. Nothing else in COURTS carries it, so it is 0
// everywhere else and reads as absent.
{ id: 'gloom', name: 'THE NIGHT COURT', sub: 'AFTER DARK · TRUE BOUNCE, NO LIGHT', tile: 'court_gloom',
ads: 'x_ads_h', crowd: 'x_crowd_h', stripes: false, line: '#dff6ff', border: '#c8dbe6', dim: 0.62,
bounceY: 0.58, bounceXZ: 0.80,
tennis: { stands: '#0a0d15', tierA: '#0f131e', tierB: '#0c1019', board: '#131a2b',
out: '#0f1a15', lawn: '#16261e', stripe: '#16261e' },
splatter: { stands: '#06050b', tierA: '#0b0813', tierB: '#08060f', board: '#2c0e0e',
out: '#0a120d', lawn: '#101c15', stripe: '#101c15' } },
];
// The tour, picked on the same screen as the court. Only the two TENNIS PLAYERS and the turned CPU
// (the racket zombie — a zombified opponent) have a second drawing; the horde, the officials and
// the ball boys are shared, because they are staff, not the draw. `sfx` is the sprite-name suffix
// render.js appends: null means "the frames as drawn", which is the men's set.
// dx/apex are optional and override BOUNCE_IDLE's pair: she reaches further out and higher than he
// does, so the pre-serve ball has to meet a different palm. Same rule as BOUNCE_IDLE — measured off
// the drawn hand by eye, not calculated.
// THE THREE MODES, picked before a run and nowhere else. Pure DATA, like TOURS and COURTS: adding
// one is a row here plus its dispatch line in main.start(), never markup.
// classic — real tennis, best of TUNE.classicSets sets, nothing ever turns
// zombie — the exhibition match (beats.startMatch): one game per opponent, blood only
// gore — the original: the opening match TURNS and you survive what walks on
// `art` is a full-bleed banner scene behind the whole button (Oliver's call over the sprite tiles,
// 2026-08-14: "bright backgrounds symbolising the individual game mode with the descriptions
// written on top"). Generated through the _famroll OpenRouter route, one scene per mode, no text
// baked in — the button's own name/sub render over a scrim, so the caption stays crisp.
export const MODES = [
{ id: 'classic', name: 'CLASSIC TENNIS', sub: 'REAL TENNIS · BEST OF THREE SETS', art: 'mode_classic' },
{ id: 'zombie', name: 'ARCADE', sub: 'ONE GAME PER OPPONENT · BLOOD ONLY', art: 'mode_arcade' },
{ id: 'cup', name: 'THE TOURNAMENT', sub: 'FOUR ROUNDS · THE CHAMPION WAITS', art: 'mode_cup' },
// ...and gore no longer opens on a game of tennis (see gameplay.startGore), so its blurb stopped
// promising one: the match already turned, you just arrived late
{ id: 'gore', name: 'SPLATTER GORE', sub: 'NO WARM-UP · SURVIVE THE DEAD CLUB', art: 'mode_gore' },
];
// THE SETUP SCREEN'S TOP ROW (Oliver's two-step menu, 2026-08-14): three posters, and picking one
// opens its own second step — classic deals the difficulty chips, zombie tennis deals its two
// sub-mode cards (arcade / the cup), gore just IS gore. A row either points at a run mode in MODES
// (`mode`) or carries its own card and the MODES ids it fans out to (`kids`).
export const MENU = [
{ mode: 'classic', diffs: true },
{ name: 'ZOMBIE TENNIS', sub: 'THE DEAD CLUB PLAYS YOU', art: 'mode_zombie', kids: ['zombie', 'cup'] },
{ mode: 'gore' },
// NETPLAY (prototype): a poster with no run mode of its own — its second step is HOST / JOIN,
// and either chip starts classic tennis against the other phone (beats.startNetVs + src/net.js)
{ name: 'VS A FRIEND', sub: 'LIVE 1V1 · REAL TENNIS · TWO PHONES', art: 'mode_vs', net: true },
];
// CLASSIC's difficulty. The first cut scaled only flub/quality-cap/speed and Oliver felt NOTHING —
// the quality distributions overlapped (0.45 floor either way), ±12% speed never binds because the
// CPU pre-plans its intercept, and the dials that actually read as skill were untouched. What a
// player FEELS is: how fast the CPU reads a shot (reaction — "well-aimed corners are winners"),
// how long its racket is (reach), the FLOOR of its shot quality (qMin — a raised floor means every
// ball comes back with pace), how often it just misses (flub), and its serve.
// `smart` is the probability the CPU aims at the OPEN court instead of a random lane (trySwing) —
// the dial that turns "returns everything" into "plays you".
// THE 2026-08-15 RETUNE (Oliver: "medium is too easy and hard is a tad too hard, easy could also be
// a little harder"): the three chips were bunched at the bottom and then jumped. MEDIUM no longer
// repeats the shipped TUNE values — it is a real step ABOVE them, and it is where `smart` first
// appears (0.35), so the middle chip plays the court instead of just returning. HARD stepped back
// on the dials that made it feel superhuman rather than good — speed 1.5 -> 1.38, floor 0.70 ->
// 0.66, smart 0.8 -> 0.7 — and EASY closed most of its gap to the old MEDIUM. The order across all
// three still climbs on every dial (pinned by test); only MEDIUM's old pin to TUNE is gone.
// `recover` scales the between-shots jog (the rally block's 0.5 "slow recovery: wrong-footing wins
// points") — a HARD CPU that ambles back to centre is free points, a recovering one is a wall.
// Speed calibration: the zombie RUNNER covers 2.8 x 1.8 x 1.22 = 6.15 m/s with no reaction gate,
// and Oliver clocked HARD as slower than him — HARD now moves 4.3 x 1.5 = 6.45.
export const DIFFS = [
// EASY's first cut (flub 0.30, quality cap 0.62, reaction 0.62) was "way too easy" once HARD
// taught us which dials bite — it is a mild step below MEDIUM now, not a training dummy
{ id: 'easy', name: 'EASY', flub: 0.15, quality: 0.80, qMin: 0.45, speed: 0.95, reaction: 0.50, reach: 1.55, serve: 0.95, smart: 0, recover: 0.48 },
{ id: 'medium', name: 'MEDIUM', flub: 0.06, quality: 0.92, qMin: 0.56, speed: 1.18, reaction: 0.34, reach: 1.70, serve: 1.05, smart: 0.35, recover: 0.66 },
{ id: 'hard', name: 'HARD', flub: 0.02, quality: 1.0, qMin: 0.66, speed: 1.38, reaction: 0.27, reach: 1.80, serve: 1.15, smart: 0.7, recover: 0.80 },
];
// THE TOURNAMENT (Oliver's pick over the endless ladder, 2026-08-13): four rounds up the club's
// dead hierarchy, each round DRAWING one rung from its band so runs differ, the final always THE
// CHAMPION. `need` is games to take the round — round one is a single game so a run opens fast —
// and the drawn body's pool scales with need (see matchOpponent), so its wound stages spread
// across the whole round instead of being spent in game one. Blood carries between rounds; the
// trainer's heal-per-game is what makes the distance survivable. Deathmatch (id 'zombie') stays
// beside it as the arcade run.
// The bands widened with the ladder (12 rungs -> 18, 2026-08-15): the shape is unchanged — the
// bottom third opens, the middle third is the quarter, everything up to management is the semi,
// and the final is the honours board — but every round now draws from a deeper hat, which is
// exactly what the bracket screen's shuffle is showing you.
export const CUP_ROUNDS = [
{ name: 'ROUND ONE', band: [1, 2, 3, 4, 5], need: 1 },
{ name: 'QUARTER-FINAL', band: [6, 7, 8, 9, 10], need: 2 },
{ name: 'SEMI-FINAL', band: [11, 12, 13, 14, 15, 16, 17], need: 2 },
{ name: 'THE FINAL', band: [18], need: 2 },
];
export const TOURS = [
// `kit` — HOW this tour's art takes a colour, because the two sets are dressed differently and the
// same operation cannot serve both.
//
// 'dye' the men. Drawn in blue shorts, so the kit pick rotates that hue and keeps the shading.
//
// 'wash' the women. Drawn head to toe in WHITES — her skirt is (240,248,248)/(208,216,224), a
// cool-cast white — so there is no hue to rotate: rotating a near-neutral does nothing,
// and the only genuinely saturated blue on her sheets is HER HAIR, at the same hue AND
// lightness as his shorts. So her whites are TINTED INSTEAD: the pick's hue laid over
// them at a low saturation, lightness kept, which is how you dye a white kit in real
// life and gives rose / powder blue / mint / lilac rather than a solid block of colour.
// Her hair and skin fall out of it on lightness alone, which is why this is safe where
// a dye was not.
// no `tile` any more: the setup screen draws the LIVE player instead, so assets/tour_m.png and
// tour_w.png are unreferenced (left on disk — they are generated art, not code).
{ id: 'men', name: "MEN'S SINGLES", sfx: null, kit: 'dye' },
{ id: 'women', name: "WOMEN'S SINGLES", sfx: '_f', kit: 'wash', dx: 0.46, apex: 0.78, tossDx: -0.59,
oDx: 0.48, oApex: 0.73 },
];
// THE PLAYER'S OWN COLOURS — testers asked for "some control over how my player looks", and from
// behind (which is the only way you ever see him) there are exactly two things to control: the kit
// and the skin. No new art: both are a PIXEL PASS over the sheets already loaded, keyed by hue —
// see tint() in render.js. Measured on p_idle1.png (84x224, 1510 opaque colours), the sheets fall
// into three clean hue populations: the kit blue around 208 deg, the skin around 22 deg, and the
// near-neutral shirt whites at sat < 0.18 that neither bucket touches.
//
// `h` is the ABSOLUTE hue the bucket is rotated onto; `s`/`l` scale saturation and lightness from
// whatever the pixel already had, so every bit of the drawn shading survives the swap. An entry
// with no `h` is "as drawn" and costs nothing — the tint pass is skipped entirely.
//
// The kit is the whole kit, not the shorts: the run frames carry blue down at the shoes and the
// women's set is blue well above the waist, so a hue swap moves every blue on the body together.
// That is the feature — you pick a KIT — and the alternative is a per-region mask baked by the
// sprite pipeline, which is a lot of work for a distinction nobody asked for.
export const KITS = [
{ id: 'blue', name: 'CLUB BLUE', sw: '#1678ba' }, // as drawn
{ id: 'red', name: 'CLARET', sw: '#b83030', h: 356, s: 0.92 },
{ id: 'green', name: 'ACID', sw: '#6ea52a', h: 88, s: 0.86 },
{ id: 'purple', name: 'VIOLET', sw: '#7a46c0', h: 268 },
{ id: 'orange', name: 'CLAY', sw: '#d4761e', h: 28, s: 0.94 },
{ id: 'black', name: 'BLACKOUT', sw: '#2e3138', h: 220, s: 0.16, l: 0.42 },
];
// Skin is NOT a hue rotation — rotate a skin tone and you get a green man. Real tones sit in a
// narrow hue band and differ in lightness and saturation, so these mostly move `l`.
export const SKINS = [
{ id: 'tan', name: 'TAN', sw: '#d6956f' }, // as drawn
{ id: 'pale', name: 'PALE', sw: '#eec4a4', h: 26, s: 0.72, l: 1.14 },
{ id: 'olive', name: 'OLIVE', sw: '#b8825a', h: 27, s: 0.94, l: 0.9 },
{ id: 'brown', name: 'BROWN', sw: '#8e5a38', h: 22, s: 1.02, l: 0.68 },
{ id: 'deep', name: 'DEEP', sw: '#5d3722', h: 20, s: 1.05, l: 0.46 },
];
// The hue windows the two buckets claim, in degrees, and the saturation each needs before it will
// claim a pixel at all. The kit window runs generously wide (150-300) because the ~165 anti-aliased
// pixels where blue meets the white shirt land out at 270-300, and a tight window leaves them
// behind as a dull fringe when the kit moves far. Nothing else on the sheet is blue, so its floor
// can stay low and catch every soft edge.
//
// THE SKIN BUCKET IS FENCED ON THREE SIDES, AND EVERY NUMBER IS MEASURED. It has two neighbours it
// must not touch, and each needed a different fence:
//
// THE SHIRT, above it. The whites are not neutral — they are WARM, hue ~37, running up to
// (243,236,224) on the run and smash frames — so they sit inside the skin's hue arc. Tinted, they
// came out as mud spattered across the man's back on every dark pick. Saturation does not
// separate them (they reach 0.35, inside real skin's range); LIGHTNESS does. Real leg skin never
// exceeds lum 0.77 across p_idle1/p_run1/p_smash/p_idle1_f, and the shirt whites start at 0.82,
// so `lumSkin` sits in that gap.
//
// THE BLOOD, beside it — and this is the one that matters, because this is Splatter Tennis. The
// death frames carry DRAWN blood (spray on the shirt, a pool under the body), and at a 330 deg
// lower edge a DEEP pick turned every drop of it to mud. Saturation cannot separate blood either:
// blood runs 0.8-0.9 and so does real skin ((240,160,128) is a highlight at 0.79). HUE separates
// them cleanly and only just — blood is a spike at 0 deg, skin peaks at 16-24 — so the arc starts
// at 10. What that leaves untinted on the death frames is the reddest skin, which is the
// blood-soaked skin, and it reads as gore rather than as a miss.
//
// THE OPPONENT wears red, not blue — (184,8,8) for him, (184,48,48) for her — so the friend across
// the net in a netplay match needs his own kit arc, `oKit`, sitting around 0 deg. It is safe there
// for the one reason it would not be on the player's sheets: the opponent art carries no DRAWN
// blood (his damage is all live particles), so nothing in that arc is blood.
//
// THE WHITES, for a 'wash' tour. Bright and barely saturated, which is exactly what her skin is
// NOT — hers reaches lum 0.77 at saturation 0.79, so the pair of limits keeps them apart with room
// to spare — and what her hair is not either, at lum 0.25. washS/washL are the dye strength: the
// pick's saturation scaled down to a pastel, and a slight darkening without which a tint laid on a
// 0.96-lightness white is invisible.
export const TINT = {
kit: [150, 300], oKit: [345, 15], skin: [10, 45],
satKit: 0.15, satSkin: 0.28, lumSkin: 0.80,
// washS/washL retuned after Oliver played it: 0.30/0.93 was a hint of colour rather than a kit
// ("a little too subtle on the white — could be more"). 0.55/0.86 reads as dyed from across the
// court and still keeps every fold; past about 0.70 it stops looking like tennis whites at all.
whiteLum: 0.72, whiteSat: 0.60, washS: 0.55, washL: 0.86,
};
// netW was the singles-stick width (10.06) while the court is drawn to the DOUBLES lines, so the
// net stopped 0.46 m short of each sideline and visibly failed to meet the court edge. The posts
// now stand exactly on the doubles sidelines.
export const COURT = { L: 23.77, W: 8.23, dblW: 10.97, netZ: 11.885, netH: 0.914, netW: 10.97 };
COURT.svcFar = COURT.netZ - 6.4; // far service line
COURT.svcNear = COURT.netZ + 6.4; // near service line
// world sizes (meters)
export const PLAYER_H = 1.88;
// The chapter-boss ROSTER. One design per chapter, cycling — chapter 5 gets design 1 again, but at
// far more hp and much bigger, so it still escalates. Each entry is pure DATA so adding a design is a
// sheet plus a row here, never a change to drawZombie.
// walk: two frames, stepped off DISTANCE COVERED (never time — see the animation invariant)
// wounds: damage skins, healthiest first. However many there are, they are spread evenly over hp.
// throws: the same states mid-throw, used while windT > 0. Index is clamped, so a design with
// fewer throw frames than wound stages simply reuses its last one.
// Every name must be ALL LOWERCASE and a whole word — see the case-collision note in the pipeline.
//
// BEHAVIOUR is data too, and every field below is OPTIONAL — anything left out falls back to the
// shared TUNE.boss* number, so a row with only art in it behaves exactly like a boss did before:
// move 'pace' | 'mirror' | 'advance' | 'charge' | 'bounce' (spawnChapterBoss -> bossAI)
// hpMul / speedMul scale this design's hp and its forward crawl
// volley shots per attack · gap s between them (0 = the whole volley leaves in ONE frame,
// which is a WALL to dodge rather than a rhythm to parry) · spread m of scatter
// cycle s between attacks · wind s of telegraph · shot m/s it throws at
export const BOSS_ART = [
{ name: 'THE GROUNDSKEEPER', walk: ['z_boss1', 'z_boss2'],
wounds: ['z_bossw1', 'z_bossw2', 'z_bossw3'],
throws: ['z_bosst0', 'z_bosst1', 'z_bosst2', 'z_bosst3'],
move: 'pace', speedMul: 0.7 },
{ name: 'THE LINE JUDGE', walk: ['z_judge1', 'z_judge2'],
wounds: ['z_judgew'], throws: ['z_judget', 'z_judgewt'],
move: 'shadow' }, // he walks the line, and the line is wherever you are
{ name: 'THE CHAIR UMPIRE', walk: ['z_chair1', 'z_chair2'],
wounds: ['z_chairw'], throws: ['z_chairt', 'z_chairwt'],
move: 'surge', hpMul: 1.1 }, // the immovable one — he just keeps coming, and coming back
{ name: 'THE HEAD COACH', walk: ['z_coach1', 'z_coach2'],
wounds: ['z_coachw1', 'z_coachw2', 'z_coachw3'],
throws: ['z_coacht0', 'z_coacht1', 'z_coacht2', 'z_coacht3'],
move: 'pace', paceMul: 1.9, hpMul: 0.9, // never where he was: drills you while running the line
volley: 5, gap: 0.14, cycle: 2.2, wind: 0.4, shot: 19 },
{ name: 'THE PHYSIO', walk: ['z_physio1', 'z_physio2'],
wounds: ['z_physiow1', 'z_physiow2', 'z_physiow3'],
throws: ['z_physiot0', 'z_physiot1', 'z_physiot2', 'z_physiot3'],
// THE BODY IS THE WEAPON — which it never once was. One ball every 3.4 s and a charge that gave
// back most of its own ground meant the fight was over long before it arrived: Oliver, "the
// physio does basically nothing but move towards the net, too easy". It covers the ground half
// again as fast now (speedMul) and drills you on the way in, and since the charge style itself
// retreats to the front of the band after every rush (bossAI), it arrives OVER AND OVER rather
// than either never or once and for good.
move: 'charge', hpMul: 0.9, speedMul: 1.45,
volley: 3, gap: 0.26, cycle: 2.4, wind: 0.3, shot: 22, spread: 0.5 },
{ name: 'THE SPONSOR MASCOT', walk: ['z_mascot1', 'z_mascot2'],
wounds: ['z_mascotw1', 'z_mascotw2', 'z_mascotw3'],
throws: ['z_mascott0', 'z_mascott1', 'z_mascott2', 'z_mascott3'],
move: 'bounce', hpMul: 1.3, speedMul: 0.6, // soaks, and answers with a WALL: five at once, so
volley: 5, gap: 0, spread: 1.6, cycle: 3.2, wind: 0.65, shot: 14 }, // there is nothing to parry
{ name: 'THE STRINGER', walk: ['z_string1', 'z_string2'],
wounds: ['z_stringw1', 'z_stringw2', 'z_stringw3'],
throws: ['z_stringt0', 'z_stringt1', 'z_stringt2', 'z_stringt3'],
move: 'shadow', paceMul: 1.6, hpMul: 1.1, // he never mis-strings: he takes your lane and puts
volley: 2, gap: 0.28, spread: 0.1, cycle: 2.1, wind: 0.34, shot: 23 }, // two dead-straight ones on it
{ name: 'THE TOURNAMENT DIRECTOR', walk: ['z_dir1', 'z_dir2'],
wounds: ['z_dirw1', 'z_dirw2', 'z_dirw3'],
throws: ['z_dirt0', 'z_dirt1', 'z_dirt2', 'z_dirt3'],
move: 'surge', hpMul: 1.2, // the one fight you cannot stand still in: he has a
volley: 2, gap: 0.4, spread: 0.7, cycle: 3.0, wind: 0.5, // radio, and he calls security with it
line: 'HE IS CALLING SECURITY.', adds: { every: 6, variant: 'd', cap: 5 } },
{ name: 'THE STRAWBERRY SELLER', walk: ['z_berry1', 'z_berry2'],
wounds: ['z_berryw1', 'z_berryw2', 'z_berryw3'],
throws: ['z_berryt0', 'z_berryt1', 'z_berryt2', 'z_berryt3'],
move: 'charge', hpMul: 1.4, speedMul: 1.15, // a tray of cream and a ladle: he
volley: 4, gap: 0.12, spread: 1.4, cycle: 3.0, wind: 0.55, shot: 15 }, // sprays, then comes for you
// THE FINAL BOSS, and the only row that is not in the chapter rotation: bossArt() hands him out
// every TENTH chapter (game 100, then 200). Two things make him the wall the run ends on.
// `rally` he RETURNS anything that is not a splatter-quality kill shot — see the boss branch in
// zombies.js. Every other body in the game can be worn down with soft balls; he is the
// one you have to out-HIT, and a lazy serve comes straight back at your head.
// `phases` three rows that override this one as his health falls (bossPlan), each with its own
// cry. He holds the baseline and rallies, then runs the line drilling volleys, then
// drops the trophy and comes for you. Same body, three fights.
{ name: 'THE CHAMPION', walk: ['z_champ1', 'z_champ2'],
wounds: ['z_champw1', 'z_champw2', 'z_champw3'],
throws: ['z_champt0', 'z_champt1', 'z_champt2', 'z_champt3'],
rally: true, hpMul: 1.6, line: 'HE RETURNS EVERYTHING.',
phases: [
{ move: 'pace', paceMul: 0.9, volley: 1, cycle: 3.2, wind: 0.6, shot: 17, spread: 0.3 },
{ move: 'shadow', paceMul: 1.8, volley: 4, gap: 0.22, cycle: 2.2, wind: 0.4, shot: 20, spread: 0.5,
cry: 'SECOND SET.' },
{ move: 'charge', speedMul: 1.5, volley: 3, gap: 0.1, cycle: 1.9, wind: 0.3,
shot: 24, spread: 1.1, cry: 'MATCH POINT.' },
] },
];
// WHICH design a chapter gets. The roster is walked one design per chapter, and every TENTH chapter
// is THE CHAMPION — last in the array and never part of the rotation. The floor(ch/10) term takes
// his chapters back out of the count, so chapter 11 opens the second lap on the groundskeeper
// instead of skipping him.
export const bossArt = ch => ch % 10 === 0 ? BOSS_ART[BOSS_ART.length - 1]
: BOSS_ART[(ch - 1 - Math.floor(ch / 10)) % (BOSS_ART.length - 1)];
// ---- THE MATCH OPPONENTS. In zombie tennis you play a SEQUENCE of opponents, one game each:
// opponent 1, opponent 2, and so on. This is that list. It walks the SAME dead club staff onto the
// court as the gore mode's chapter bosses — the roster is the joke and it is worth telling twice:
// in gore they come for you, here they play you at tennis. So opponent N takes design N's NAME
// (bossArt, the champion on ten) and a PERSONALITY.
//
// A personality is DIALS, not new mechanics: every field below multiplies a number racketAI and the
// return shaping already read, so an opponent is a data row and never a branch. All optional.
// pace scale on its returns' speed wide scale on how far off centre they may land
// flub scale on its whiff chance speed scale on how fast it covers the ground
// line the personality, said once, on the name plate as it walks on
// fam which SPRITE FAMILY this opponent wears (config.ZFAMS). Absent = the club pro's body.
// This is the payoff of _master.py --variant: generate a body, add its key to ZFAMS,
// name it here, and that opponent walks on as it — through all three wound stages.
// short 8 characters, because that is the board's name slot — the match board names
// the thing you are playing instead of calling it THEM
// THE CARD, in order. It is a ladder of STAFF: the further you get, the further up the club's
// hierarchy the thing across the net used to be. That is the narrative, and the difficulty ramp is
// the same list read as numbers — every dial climbs monotonically, so "start easy and get harder"
// is a property of the table rather than something the code has to arrange.
// 1-3 the ones who fetch, sweep and watch. Slow, wild, forgiving.
// 4-6 the ground staff and the ones who keep order. They start covering ground.
// 7-8 the entertainment and the legs.
// 9-12 the trades and the officials. Width arrives: they begin painting lines.
// 13-16 the ones who actually worked the players, and then played themselves.
// 17-18 management, and then the man whose name is on the honours board.
// EIGHTEEN RUNGS since 2026-08-15 (Oliver: expand the roster, "the champion can then be even a
// little bit harder to beat"). The six new ones are interleaved rather than stacked on the end —
// a ladder is a hierarchy, not an arrival order, so the spectator belongs at the bottom with the
// ball boy and the head coach belongs up beside the club pro. Every new rung wears a family rolled
// off the body it already has in the gore mode, same rule as the twelve before it.
const OPPONENTS = [
{ short: 'SHAMBLER', name: 'THE SHAMBLER', fam: 'sh',
line: 'HE WAS HERE BEFORE ANY OF THIS.', speed: 0.72, pace: 0.72, wide: 0.75, flub: 1.35 },
{ short: 'BALL BOY', name: 'THE BALL BOY', fam: 'bb',
line: 'HE STILL FETCHES. HE JUST BITES NOW.', speed: 0.80, pace: 0.78, wide: 0.82, flub: 1.25 },
{ short: 'PUNTER', name: 'THE SPECTATOR', fam: 'sp',
line: 'HE PAID FOR THE WHOLE SEAT.', speed: 0.86, pace: 0.82, wide: 0.86, flub: 1.18 },
{ short: 'KEEPER', name: 'THE GROUNDSKEEPER', fam: 'gk',
line: 'HE MOWED THIS LAWN FOR THIRTY YEARS.', speed: 0.9, pace: 0.88, wide: 0.9, flub: 1.05 },
// 5: the heavy of the ground staff. Slow legs, but everything he touches comes back flat and hard
{ short: 'ROLLER', name: 'THE ROLLER MAN', fam: 'rm', h: 1.95,
line: 'HE FLATTENED THIS COURT. THEN THE REST.', speed: 0.88, pace: 0.98, wide: 0.92, flub: 1.0 },
{ short: 'STEWARD', name: 'THE STEWARD', fam: 'st',
line: 'HE IS ASKING YOU TO LEAVE.', speed: 0.98, pace: 0.95, wide: 1.0, flub: 0.98 },
// 7-8: the entertainment, and then the legs
{ short: 'MASCOT', name: 'THE MASCOT', fam: 'ma',
line: 'THE CROWD STILL LOVES HIM.', speed: 1.02, pace: 0.98, wide: 1.05, flub: 0.95 },
{ short: 'RUNNER', name: 'THE RUNNER', fam: 'ru',
line: 'HE COVERS THE COURT. ALL OF IT.', speed: 1.22, pace: 1.0, wide: 1.05, flub: 0.92 },
// 9-12: the trades and the officials — width arrives here
{ short: 'SELLER', name: 'THE STRAWBERRY SELLER', fam: 'sb',
line: 'CREAM WITH THAT?', speed: 1.04, pace: 1.06, wide: 1.10, flub: 0.90 },
{ short: 'STRINGER', name: 'THE STRINGER', fam: 'sg',
line: 'HE STRUNG YOUR RACKET. HE KNOWS ITS LIMIT.', speed: 1.06, pace: 1.12, wide: 1.14, flub: 0.86 },
{ short: 'JUDGE', name: 'THE LINE JUDGE', fam: 'lj',
line: 'EVERY BALL OF YOURS IS OUT.', speed: 1.05, pace: 1.08, wide: 1.28, flub: 0.84 },
{ short: 'UMPIRE', name: 'THE CHAIR UMPIRE', fam: 'cu',
line: 'HIS CALL WAS ALWAYS FINAL.', speed: 1.08, pace: 1.14, wide: 1.26, flub: 0.80 },
{ short: 'PHYSIO', name: 'THE PHYSIO', fam: 'ph',
line: 'HE KNOWS EXACTLY WHERE IT HURTS.', speed: 1.12, pace: 1.16, wide: 1.24, flub: 0.78 },
{ short: 'COACH', name: 'THE HEAD COACH', fam: 'hc',
line: 'HE TAUGHT YOU EVERY SHOT YOU HAVE.', speed: 1.20, pace: 1.20, wide: 1.22, flub: 0.72 },
{ short: 'CLUB PRO', name: 'THE CLUB PRO',
line: 'HE GAVE LESSONS ON THIS COURT.', speed: 1.16, pace: 1.22, wide: 1.20, flub: 0.70 },
// THE BRUTE, not "THE BULK": the gore mode's stats screen has called this body THE BRUTE since it
// was the pole-dragger (ENEMIES.g below), and one character may not carry two names.
{ short: 'BRUTE', name: 'THE BRUTE', fam: 'br', h: 2.0,
line: 'NOTHING GETS PAST HIM TWICE.', speed: 1.02, pace: 1.34, wide: 1.24, flub: 0.66 },
{ short: 'DIRECTOR', name: 'THE DIRECTOR', fam: 'dr',
line: 'HE CAN HAVE YOU REMOVED.', speed: 1.26, pace: 1.28, wide: 1.28, flub: 0.60 },
// 18: the man whose name is on the honours board. `h` is WORLD height (zombieH reads it, so the
// serve aims at the body actually drawn): four family rolls could not make the art itself bulky
// against the pose sheet — see _famroll.py's pair-reference note — so his size is stature.
// Six new rungs stand between him and the start now, so he took the step up Oliver asked for:
// he reads the court faster, hits harder, paints wider, and misses half as often as he did.
{ short: 'CHAMPION', name: 'THE CHAMPION', fam: 'ch', h: 2.1,
line: 'HE RETURNS EVERYTHING.', speed: 1.45, pace: 1.42, wide: 1.36, flub: 0.38 },
];
// Who opponent N is. This used to reuse the chapter roster's cycling (bossArt) so the two lists
// could never drift — DECOUPLED 2026-08-13 on Oliver's call: the match ladder is 12 rungs with the
// champion capping rung 12, while the chapter bosses stay a 10-design rotation with the champion
// on every tenth. The lists share CHARACTERS (a rung and a boss are the same person, anchored on
// the same art), just not arithmetic. The ladder cycles past 12 exactly as bossArt cycles past 10.
export const matchFoe = n => {
const i = (n - 1) % OPPONENTS.length;
return { key: 'L' + i, fam: 'r', ...OPPONENTS[i] };
};
// How many rungs there ARE, for everything that has to know the shape of the ladder rather than
// one rung of it: the ramp test, and the bracket screen's shuffle. OPPONENTS stays private —
// exporting the length is the whole of what anyone outside needs.
export const LADDER = OPPONENTS.length;
// ---- THE SPRITE BENCH's stops (back room only, see beats.startBench). Each is either an ANIMATED
// state or ONE NAMED FRAME, so frames the game cannot reach yet — the serve, the fall — can still be
// looked at the day they are generated rather than the day they are wired up.
export const BENCH = [
{ name: 'WALK CYCLE', walk: true },
{ name: 'READY STANCE', pose: 'idle' },
{ name: 'FOREHAND', pose: 'swing' },
// `spr` is a SUFFIX, not a filename: the bench builds z_r<suffix> / z_rw1<suffix> / z_rw2<suffix>
// from the wound stage, so the same left/right control walks the serve and the fall through their
// damage stages exactly as it walks the shuffle through them.
{ name: 'SERVE · BOUNCE', spr: 'bounce' },
{ name: 'SERVE · TOSS', spr: 'toss' },
{ name: 'SERVE · STRIKE', spr: 'serve' },
{ name: 'FALL 1 · BUCKLING', spr: 'fall1' },
{ name: 'FALL 2 · KNEES', spr: 'fall2' },
{ name: 'FALL 3 · DOWN', spr: 'fall3' },
{ name: 'RISE', spr: 'rise' },
];
export const BENCH_STAGES = ['HEALTHY', 'ARM OFF', 'HEADLESS'];
// ---- MATCH-OPPONENT SPRITE FAMILIES. A family is a complete opponent: the eleven states at three
// wound stages, 31 frames, all named off one key (`r` -> z_r1, z_rtoss, z_rw1fall2 …). Generating a
// new one is `assets/_master.py --variant <key>`; USING it is one row here, because everything that
// needs the names — the preloader, drawZombie's wound lookup, the sprite bench — derives them from
// this table rather than spelling them out. That is the whole point: 31 hand-written filenames in a
// preload list is 31 chances to typo one and have a body silently vanish for a frame.
// THE BRUTE ('br') IS PARKED, not deleted. Its healthy play sheet is good and is on disk; its serve
// and fall sheets each RE-INVENT the character, because they restyle from the MASTER — a lean
// red-shirted man — rather than from the brute that was just made. The palette gate now measures
// that (15 to 48 away from z_br1, where a good frame scores under 8.5) and refuses them, which is
// the gate working, not the gate being wrong. Re-registering it is one entry here; see the note at
// the top of _master.py for the fix its next attempt needs.
export const ZFAMS = ['r', 'br', 'sh', 'bb', 'st', 'ru', 'gk', 'lj', 'dr', 'ch', 'ph', 'ma',
'hc', 'sg', 'sb', 'cu', 'sp', 'rm'];
// HEAD CROPS for the opponent intro card — a straight source-pixel crop out of each family's own
// healthy frame (z_<fam>1.png), so the card's icon is the body on court, never separate art.
// MEASURED offline (silhouette-width profile: the head band ends where the shoulders widen; br and
// gk hand-extended past the rule — the brute's shoulders start early, the keeper's hat brim wider
// than his face). Re-run the measurement when a family sheet is regenerated. A family missing here
// simply gets a text-only card, so a new roster row never breaks the walk-on.
export const HEAD_CROP = {
r: { x: 3, y: 0, w: 71, h: 73 },
br: { x: 0, y: 0, w: 87, h: 61 },
sh: { x: 8, y: 0, w: 62, h: 65 },
bb: { x: 7, y: 0, w: 62, h: 65 },
st: { x: 14, y: 0, w: 57, h: 55 },
ru: { x: 14, y: 0, w: 57, h: 55 },
gk: { x: 6, y: 0, w: 73, h: 65 },
lj: { x: 6, y: 0, w: 74, h: 56 },
dr: { x: 10, y: 0, w: 62, h: 49 },
ch: { x: 3, y: 0, w: 68, h: 63 },
ph: { x: 12, y: 0, w: 63, h: 58 },
ma: { x: 0, y: 0, w: 124, h: 81 }, // the mascot's head IS the ball rim too — hand-deepened
};
// THE RACKET FAMILY'S THREE STAGES, as a FRACTION of the pool — never against literal hp, the rule
// this whole file already holds (see woundStage). It lived inline in drawZombie; the FALL BEAT needs
// the same answer to know which body collapses and which one gets back up, and two copies of a
// threshold is exactly how the boss's skins and its blood drifted apart before.
export const racketStage = zb => {
const f = zb.hp / (zb.hpMax || TUNE.racketHp);
return f <= 0.34 ? 2 : f <= 0.67 ? 1 : 0; // 0 unhurt, 1 arm off, 2 headless
};
// ...and what that stage COSTS it at the racket. One place, because racketAI reads it twice and two
// copies of a threshold is how the boss's skins and its blood drifted apart before. An unhurt body
// gets exactly 1 and 1, so the gore duel's racket zombie is untouched by this (see the G.match gate
// at the call sites — the duel is one sudden-death point and has no wound arc to express).
export const matchWound = zb => {
const st = racketStage(zb);
return { reach: 1 - TUNE.matchWoundReach * st, flub: 1 + TUNE.matchWoundFlub * st };
};
const POSES = ['bounce', 'toss', 'serve', 'fall1', 'fall2', 'fall3', 'rise'];
export const famFrames = f => [
`z_${f}1`, `z_${f}2`, `z_${f}3`, `z_${f}4`, // stance, shuffle A, shuffle B, forehand
...POSES.map(s => `z_${f}${s}`),
// the wound rows carry no separate stance — the shuffle doubles as one, which is how drawZombie
// reads them (…w1 / …w1b are the stride pair, …w1s the forehand)
...[1, 2].flatMap(st => [`z_${f}w${st}`, `z_${f}w${st}b`, `z_${f}w${st}s`,
...POSES.map(s => `z_${f}w${st}${s}`)]),
];
// The roster, as the BODY COUNT screen reads it. Until now a variant's NAME lived only in the
// comment above its hp value — fine while nothing ever had to print one. The key is the `variant`
// char the zombie already carries, except that 'a' and 'b' fold together (they are one shambler in
// two shirts, and two identical rows on the stats list would just look like a bug) and a chapter
// boss files under 'B0'..'B5' by BOSS_ART index, so the screen can name the design that got you.
// `spr` is the frame the row draws — the first walk frame, which every body has.
// The barrel ('o') is deliberately absent: it is loot, not an opponent, and killZombie scores it
// as nothing. Keep that agreement — a barrel row would put a crate in a list of the dead.
export const ENEMIES = {
a: { name: 'SHAMBLER', spr: 'z_a1', sub: 'THE ORDINARY DEAD' },
d: { name: 'THE STEWARD', spr: 'z_d1', sub: 'HI-VIS · ONE HIT TOUGHER' },
e: { name: 'THE BALL BOY', spr: 'z_e1', sub: 'STILL FETCHING' },
f: { name: 'THE RUNNER', spr: 'z_f1', sub: 'ONE HIT · BUT HE SPRINTS' },
g: { name: 'THE BRUTE', spr: 'z_g1', sub: 'SLOW · DRAGS A POLE' },
h: { name: 'THE BOUNCER', spr: 'z_h1', sub: 'BUILT LIKE A DOOR' },
k: { name: 'THE ROLLER MAN', spr: 'z_k1', sub: 'HAULS THE COURT ROLLER' },
s: { name: 'THE SPECTATOR', spr: 'z_s1', sub: 'BLOCKS WITH HIS CHAIR' },
// The man you were playing, after the transition turns him (gameplay.js spawns him as 'c'). One
// per run, and he is NOT the racket zombie below — that one keeps coming back. Missing this row
// is what put a second "SHAMBLER" on the screen: an unmapped key falls back to the shambler art.
c: { name: 'THE OPPONENT', spr: 'z_c1', sub: 'HE TURNED MID-MATCH' },
r: { name: 'THE CLUB PRO', spr: 'z_r1', sub: 'STILL HAS A BACKHAND' },
m: { name: 'THE BALL MACHINE', spr: 'z_mach1', sub: 'FIRES BACK · FURNITURE' },
};
for (let i = 0; i < BOSS_ART.length; i++) // the chapter bosses, named where they already are
ENEMIES['B' + i] = { name: BOSS_ART[i].name, spr: BOSS_ART[i].walk[0], boss: true,
sub: i === BOSS_ART.length - 1 ? 'THE FINAL · EVERY TENTH CHAPTER' : 'CHAPTER BOSS' };
// ...and the same roster again as MATCH opponents, because that is who walks on in zombie tennis.
// Separate keys, not shared ones: the two are different fights with different bodies, they are
// counted on different screens (the mode split in stats.js), and one row cannot mean both. The 'L'
// prefix is kept as-is because it is written into saved run records — renaming it would orphan them.
// ...and it reads OPPONENTS, not BOSS_ART. It used to take the NAME off the boss roster and the
// line off this one — which was the same answer only while the two lists were the same list. They
// were decoupled 2026-08-13 and the screen has been mislabelling rungs ever since (L4 printed THE
// PHYSIO over the mascot's line); at 18 rungs against 10 designs it would also have left the top
// six with no row at all. One list, its own names, its own bodies.
for (let i = 0; i < OPPONENTS.length; i++)
ENEMIES['L' + i] = { name: OPPONENTS[i].name, sub: OPPONENTS[i].line,
spr: 'z_' + (OPPONENTS[i].fam || 'r') + '1' };
// Which wound stage a body is in: 0 = unhurt, 1..n = the skins, spread evenly over its full health.
// This is the rule the chapter boss already used, and it turns out to reproduce the brute's old
// hardcoded thresholds EXACTLY (5 hp over 4 skins = one skin per hit) and the shamblers' single skin
// too — so there is now one rule for damage readout instead of three, and it is the one that keeps
// working when a body's hp is not one-per-skin. Every caller passes its own stage count.
export const woundStage = (zb, n) => {
const f = zb.hp / (zb.hpMax || 1);
return f >= 1 ? 0 : Math.min(n, Math.max(1, Math.ceil((1 - f) * n)));
};
// Wound skins by variant, HEALTHIEST FIRST — the same ordering BOSS_ART.wounds uses, indexed by
// woundStage above. This one table replaced two branches in drawZombie that did the same job against
// different constants (the brute had a hardcoded stage array, everything else a single skin behind
// `'abd'.includes(...)`), which is why a new multi-stage body is a row here and no code at all.
// Each name's `…b` twin — the stride swapped — is looked up automatically, so a wounded body keeps
// WALKING instead of freezing and sliding. 'r' and 'B' carry their skins elsewhere ('B' on its
// design row) but read them through the very same woundStage call.
export const ZWOUND = {
a: ['z_aw'], b: ['z_bw'], d: ['z_dw'],
e: ['z_ew', 'z_ew2'],
h: ['z_hw', 'z_hw2', 'z_hw3'],
g: ['z_gw', 'z_gw2', 'z_gw3', 'z_gw4'],
k: ['z_kw', 'z_kw2', 'z_kw3', 'z_kw4'],
};
// ---- THE ARSENAL. One table, keyed by id, replacing the five places the roster used to be spelled
// 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: 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'] },
triple: { label: 'TRIPLE', icon: 'w_triple', tin: 10, kind: 'serve', // was 3 — Oliver asked for ten
desc: ['ONE SERVE, THREE BALLS', 'FANNED WIDE ACROSS THE LANE'] },
fire: { label: 'FIRE', icon: 'w_fire', tin: 3, kind: 'serve',
desc: ['KILLS ON ANY TOUCH', 'AND SETS THE OTHERS ALIGHT'] },
gun: { label: 'GUN', icon: 'w_gun', tin: 1, kind: 'burst', len: 8, rate: 12,
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.
// 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.
// rate was 1.6: about one lob every two thirds of a second, which is a long time to stand still
// late on. The abort in selectWeapon is the real answer to "I picked the wrong tin", but a lob
// cannon you cannot leave AND cannot feel working was two problems, so it throws faster now.
beacon: { label: 'BEACON', icon: 'w_beacon', tin: 1, kind: 'burst', len: 11, rate: 2.4,
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.
// trail 2 -> 4 and a WIDE trailR: an ignite round throws fat drifting embers rather than the
// beacon's one-pixel thread (see the pe.ignite branch in the trail block), so the stream reads as
// a body of fire with edges instead of a line of dots. glow 2 -> 2.8 to match.
flamer: { label: 'FLAMER', icon: 'w_flamer', tin: 1, kind: 'burst', len: 6, rate: 30,
r: 1.15, dmg: 0, hp: 0, pierce: true, speed: 23, life: 1.15, ignite: true, igniteR: 2.6,
trail: 4, trailR: 0.34, col: '#ff8420', glow: 2.8, 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.
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.
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: 1.1,
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'] },
};
// Every row knows its own key. The rack has always looked weapons up BY key and passed the row
// around after that, so nothing needed the id — the stats screen does, because a kill has to be
// filed under the weapon that made it long after the row itself is out of scope.
for (const k in WEAPONS) WEAPONS[k].id = k;
// 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
};
// Gathered weapons the rack holds (plus slot 0 = BALL, so ten tiles). Was 4, and at four you were
// throwing away a barrel almost every time one popped — Oliver: "that makes replacing the ball rarer
// if not unnecessary at all". The `temp` valve below survives for the eleventh, which nobody will see.
export const RACK_MAX = 9;
export const RACK_ROW = 5; // tiles per row — past five the rack grows a SECOND row upward, so a tile
// never shrinks below the thumb target rackHit was widened for
export const MACH_H = 1.55; // was 1.15 — waist high read as a prop you could ignore. Still furniture
// (a body is PLAYER_H 1.88), but big enough to be a thing on the court.
// Drawn height of a body on the far side, by variant. render draws to it AND the serve aims at a
// fraction of it, so the two cannot drift — a shot at a brute has to arrive at the brute's chest,
// not at the height a shambler's chest happens to be.
export const zombieH = zb => zb.variant === 'o' ? TUNE.barrelH
: zb.variant === 'g' ? 2.7 : zb.variant === 'd' ? 2.15
: zb.variant === 'k' ? 3.2 : zb.variant === 'h' ? 2.4
: zb.variant === 'r' ? ((zb.foe && zb.foe.h) || 1.85) : zb.variant === 'm' ? MACH_H
: zb.variant === 'B' ? 1.8 * (zb.scale || 1.5) : 1.8;
// Where on that body the ball is aimed, and where the aim mark is drawn. Solving the shot to the
// GROUND under a shambler meant the ball came down past it and struck at the ankles — the wound is
// on the torso, so the ball should arrive there.
export const Z_CHEST = 0.58;
export const BOY_H = 1.5; // was 1.2 — the mopping boy read as a distant speck at that scale
// how far off-court a boy starts. The mop boy comes in from just off-screen (he is slow, and
// waiting 8s for him to walk into shot is dead time); a fetching boy sprints in from further out.
export const BOY_ENTER = { mop: 9.5, run: 13 };
export const UMP_H = 3.0;
// The chair is 1.12 m wide as drawn (x_ump is 42x112 at UMP_H 3.0), so at 5.6 its near legs stood
// 0.45 m INSIDE the doubles line, on the court. 6.05 puts them exactly on the line; anything much
// beyond ~6.3 clips the right edge of the portrait viewport (7.1 was fully off-screen).
export const UMP_X = 6.05;
// contact rules: outside this height band the ball can't be hit (below = it's past you)
export const CONTACT = { minY: 0.24, maxY: 2.3 };
// volleys (pre-bounce contact) get extra forgiveness: the ball passes at full speed and
// there's no bounce cue to time against, so reach and swing window widen by these factors
export const VOLLEY = { reach: 1.4, window: 1.8 };
// how deep from the far/near baseline a player's interception zone extends
export const ZONE_DEPTH = 9.5;
// recovery positions between shots
export const HOME = { P: { x: 0, z: 22.8 }, O: { x: 0, z: 2.5 } };
// where the near player is allowed to move
export const P_BOUNDS = { x: 6.5, zMin: COURT.netZ + 1.2, zMax: 26 };
// serve toss: release height and upward speed (apex derives from these + gravity).
// dx/oDx: the tossing HAND's x offset from the frame centre, in metres, at facing +1 — the ball
// must rise over the raised palm, not the head (Oliver's report). MEASURED off the toss frames
// (topmost opaque row = the fingertips, × PLAYER_H/SPRITE_H): p_toss −52 px → −0.44 m,
// o_toss +50 px → +0.42 m (he is drawn from the front, so his left hand is the viewer's right).
// The women's frame reaches further (p_toss_f −70 px → −0.59 m) — TOURS overrides tossDx.
export const TOSS = { y0: 1.8, vy: 4.6, dx: -0.44, oDx: 0.42 };
// shot target depth from the receiving baseline, by aim (up = deep, none = mid, down = short)
export const SHOT_DEPTH = { deep: 3.0, mid: 5.4, short: 8.6 };
// how long swing/serve poses hold before returning to idle (s)
export const POSE_TIME = 0.38;
// the server's idle ball bounce: sideways offset to the free hand, apex height, tempo.
// tempo drives the ball, the hand sprite AND the pock sound — one value, always in sync.
// Measured off the sprite, not guessed: the player is RIGHT-handed, so he pats the ball with his
// LEFT hand — the left-hand side of the image — whose palm sits ~0.69 m above his feet and ~0.36 m
// off his centre line. The ball tops out just under that hand instead of beside it.
// dx/apex are per-man because the two sprites reach differently, and a single pair put the
// opponent's ball out by his racket instead of under his palm. Both are FEEL KNOBS measured off the
// drawn palm by eye — a skin-detector guess at the hand lands on a knee, so tune these by looking.
export const BOUNCE_IDLE = { dx: 0.36, apex: 0.68, tempo: 3.6, // the player, drawn from behind
// the opponent, drawn from the front. Remeasured 2026-08-14 for the
// friendlier anchor art: HIS new palm-out bounce holds the ball close
// (palm +0.24 m, 0.83 m up); HER reach stays wide — her pair rides
// TOURS like the player's dx/apex already does.
oDx: 0.24, oApex: 0.72 };
// generated sprite sheets: the idle pose is this many pixels tall; all scales derive from it
export const SPRITE_H = 224; // 2x remaster (was 112). RES is 3, so the player at the baseline
// rasterises into ~170 DEVICE pixels — a 112px source was being UPSCALED
// 1.5x, which is why near sprites looked soft and mushy. Every sprite that
// goes through drawChar/drawBoy/the wound-stage height rule must share this
// scale, so unmatched sprites were 2x-upscaled rather than left at 112.