// 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. // THE APP HAS NO SERVER BEHIND IT. Inside the Capacitor shell the page is served out of the app // bundle (capacitor://localhost), so every page-relative call — the world board, the netplay socket // — would resolve against the app's own files and 404. The shell therefore talks to the live host // BY NAME; the web build keeps the page-relative form, which is the only reason staging, localhost // and prod all work off one file with no switch. Consequence worth remembering: THE APP ALWAYS TALKS // TO PROD, never to staging. Deliberately no `location` here — this module is evaluated by the // headless test suite, which has no DOM; net.js reads the protocol inside its connect function. export const NATIVE = !!globalThis.Capacitor?.isNativePlatform?.(); export const HOST = 'splatter-tennis.wuwi.one'; export const API = NATIVE ? 'https://' + HOST + '/api/' : 'api/'; 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 aimTrust: 0.3, // an aimed rally shot TRUSTS its flick: the scatter may still push the // ball further OUT (the chosen out-risk keeps its exact shape) but only // this fraction of an INWARD roll survives, so a mistimed left flick // lands SHORTER LEFT rather than center-or-right "wherever the dice // said". Player's lane shots only — the opponent's symmetric scatter is // its width mechanic, and the gore free-aim never used lanes. Must stay // under 0.5*flickWidth*flickReach/aimError (~0.43) or a committed // mistimed swipe can cross back past centre (pinned by the suite). // 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 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 FRIEND ACROSS THE NET used to need a THIRD arc here, `oKit`, sitting around 0 deg, because // he was drawn on the roster opponent's red sheets. Red was the wrong hue to hang a kit on and no // arc could fix it: the only saturated red on the men's art is the SHIRT, so a pick never reached // the grey shorts your own pick dyes, and on the women's art it is the same hue as her skin and her // hair, so a dark pick painted her arms and her ponytail. He is drawn on his OWN sheets now — the // player's kit from the front, `n_*`, see assets/_frontroll.py — and rides the two arcs below.) // // 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], 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 / z_rw1 / z_rw2 // 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 `; 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_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.