Files
splatter-tennis/config.js
T
wuwiandClaude Opus 5 4d6b47dadd Die the way people die in the films
The old death was a red tint and a shake for half a second, then a puff of gore.
That reads as the sprite having a moment, not as being eaten.

Now it takes four and a half seconds and it is a crowd doing it. You are dragged
to your knees fighting it (new frames, p_die1/p_die2, alternating), blood coming
out of you the whole time — a constant spray and a stain that keeps widening,
with the wet work on a loop underneath. Every body on court drops what it was
doing and converges: each claims a spot in a ring around you, kept from wherever
it was standing, so they crowd in from all sides instead of stacking on one
pixel. Then you are pressed flat into the ground while the ring closes over you —
you are drawn INSIDE the crowd's z order, so the ones between you and the camera
hide you exactly as they arrive — and what is left is the mound, with them still
feeding over it when the summary finally fades in.

Also: the wounded shamblers were stepping their two frames at half the cadence of
the healthy four, which reads as frozen next to the crowd they walk in. Same
clock now. (They were animating — verified frame by frame — just too slowly.)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 20:36:25 +02:00

342 lines
27 KiB
JavaScript

// All tuning and constants. TUNE is live-editable in game via the T panel;
// the exported consts below are structural values that code shares but the panel doesn't show.
export const TUNE = {
camZ: 42, camY: 9, camF: 406, horizon: 40, // camF/horizon are per-orientation, set in fitViewport
gravity: 9.2, // slightly floaty vs 9.8 for readability
spinFactor: 0.9, // extra dive on quality shots (fake topspin)
bounceY: 0.58, bounceXZ: 0.78,
playerSpeed: 5.4, aiSpeed: 4.3,
reach: 1.3, aiReach: 1.6, // tight contact radius: the assist positions you, the tap must be honest
swingWindup: 0.06, // s before contact window opens
swingWindow: 0.16, // s contact window length
baseShotSpeed: 16, qualityShotSpeed: 12, // 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: 21 m/s floppy at the minimum pull, 41 m/s at full. (baseShotSpeed above is the rally
// volley and stays put — it's shared with tennis mode and the CPU.)
serveSpeed: 19, serveQualitySpeed: 22,
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.
aimError: 2.4, // m of scatter at quality 0
perfectMs: 45, // |offset from window center| for PERFECT
aiQualityMin: 0.45, aiQualityMax: 0.85, aiFlub: 0.09,
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)
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 HANDOFF "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.
zSpeedMax: 2.9, // cap on that ramp (top speed = zSpeed * 3.9). runnerVMax still caps runners.
nastyPerGame: 0.03, // share of spawns drawn from the advanced roster, per finished game
nastyMax: 0.75, // ...capped here (g14 matches the old 0.42, g25 hits the cap). The old mix was
// frozen at exactly 0.42 from game 12 to the end of the run.
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 HANDOFF.
// 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
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: 16, // 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)
bossHpPerChapter: 9,
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)
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
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)
// ---- 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
boyMaxJobs: 4, // stains one boy will clean in a round — a kill streak must not employ him forever
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
// 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 (= PERFECT) at pullFull. Below
// zSplatQ it only staggers, so a lazy flick can't kill: THE anti-spam knob.
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
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.
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
// 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
bombMax: 3, // bomb balls in a fresh tin
tinTriples: 3, // triple-serves in a fresh tin
tinFlames: 3, // flame balls in a fresh tin
tinGuns: 1, // machine-gun charges in a fresh tin
wCap: 9, // per-type cap on the courtside rack — tins accumulate, but only so many
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
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
fps: 60, // frame cap — 30 halves battery/heat but Oliver felt it; smoothness wins (0 = uncapped)
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)
// 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.0, // s the centered SET card holds (the 'kurze unterbrechung' before the horde)
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
racketSpeed: 2.8, // m/s the racket zombie shambles to chase a ball
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_GRAB = 0.9, EATEN_BURST = 2.3, EATEN_SUMMARY = 3.6, EATEN_RESTART = 4.6;
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' };
// 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.
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'] },
{ name: 'THE LINE JUDGE', walk: ['z_judge1', 'z_judge2'],
wounds: ['z_judgew'], throws: ['z_judget', 'z_judgewt'] },
{ name: 'THE CHAIR UMPIRE', walk: ['z_chair1', 'z_chair2'],
wounds: ['z_chairw'], throws: ['z_chairt', 'z_chairwt'] },
];
export const MACH_H = 1.15; // a ball machine stands about waist high — it must read as furniture, not as a body
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)
export const TOSS = { y0: 1.8, vy: 4.6 };
// 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.
export const BOUNCE_IDLE = { dx: 0.5, apex: 0.55, tempo: 3.6 };
// 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.