diff --git a/HANDOFF.md b/HANDOFF.md
index 757a864..33b6207 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -331,9 +331,19 @@ zombie (now an occasional opponent, `TUNE.racketEvery`, not the every-game fixtu
`stick.aimable` is false there, so the drag is still the absolute steer it always was.
- **Two type registers, and the register IS the message's category.** Silkscreen for the HUD, the
rack, the control hints and `RESUMING IN` — things you read at leisure. Bangers (`B()` in render)
- for `SET`, `CHAPTER`, `EPIC!`, `PERFECT` and every free-floating flash — things shouted at you.
- Free-floating text carries a 1px shadow (`shadowText`) and one shared y (`FLASH_Y`), because it
- has no panel and has to survive the crowd, sunlit clay and blood behind it.
+ for `SET`, `CHAPTER`, `EPIC!`, the praise words and every free-floating flash — things shouted at
+ you. EVERY floating string goes through `shadowText`, both faces, because none of them has a panel
+ and all of them have to survive the crowd, sunlit clay and blood behind them. The scoreboard and
+ the rack are the two exceptions: they sit on their own dark panels, and a 1 px shadow under 6-8 px
+ type on a flat dark fill only smears it.
+- **Praise is a narrow window, not a ceiling.** `pullQuality` SATURATES: `pullFrac` clamps at
+ `pullFull`, so every pull past 130 px is exactly quality 1.0 and the old `quality > 0.999` test
+ praised nearly every serve — *"it displays perfect a lot even when nothing special happened"*.
+ `hitServe` takes `perfect` as its own argument now: the toss ritual still earns it on the 45 ms
+ `perfectMs` window, the slingshot earns it by STOPPING in `pullFull..pullFull*perfectBand`. The
+ draw clicks brighter when you enter that band and goes quiet when you overshoot — without that
+ audible target it would be invisible skill. The word itself rotates through `PRAISE` (cycled, not
+ random: the same word twice running reads as a stuck HUD element, not as praise).
## Session log: the sidestep, and two type registers (2026-08-03)
diff --git a/config.js b/config.js
index c9d92a5..e808c4d 100644
--- a/config.js
+++ b/config.js
@@ -186,8 +186,15 @@ export const TUNE = {
// 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.
+ 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.
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.
diff --git a/gameplay.js b/gameplay.js
index ed6e909..dd74e9c 100644
--- a/gameplay.js
+++ b/gameplay.js
@@ -34,6 +34,16 @@ export const pullQuality = mag => TUNE.pullQMin + pullFrac(mag) * (1 - TUNE.pull
// pullQuality inverted: hitServe is handed a quality, not a drag, and the band it leaves behind is
// drawn at the same weight the live preview had.
export const qualityFrac = q => Math.max(0, Math.min(1, (q - TUNE.pullQMin) / (1 - TUNE.pullQMin)));
+// A pull that STOPS in the band at the top of the draw, rather than merely reaching it. See
+// TUNE.perfectBand for why the old "quality == 1" test praised almost every serve.
+export const pullPerfect = mag => mag >= TUNE.pullFull && mag <= TUNE.pullFull * TUNE.perfectBand;
+
+// The umpire has never once acknowledged the massacre, and he is not about to start now: a clean
+// shot gets the same line it would get at Wimbledon. Cycled rather than random — the same word twice
+// running is what made one word read as a stuck HUD element rather than as praise.
+const PRAISE = ['PERFECT', 'TEXTBOOK', 'IMMACULATE', 'OH, LOVELY', 'UNPLAYABLE', 'SHOT OF THE DAY'];
+let praiseN = 0;
+const praise = () => { G.flash = { text: PRAISE[praiseN++ % PRAISE.length], t: 0.7 }; };
export function flickTarget(ch, aim) { // exported: render draws the live reticle with it
const q = pullQuality(aim.mag), f = pullFrac(aim.mag);
@@ -139,7 +149,7 @@ function doHit(ch, quality, aim) {
G.aiReactT = 0; // CPU hasn't read the shot yet
}
sfx(ch.side === 'P' ? 'tennis-player' : 'tennis-opponent', 0.9, { reverb: true }); // recorded racket strike, per side
- if (quality > 0.999) { G.hitstop = 0.05; G.flash = { text: 'PERFECT', t: 0.7 }; pock(0.5, 1600); }
+ if (quality > 0.999) { G.hitstop = 0.05; praise(); pock(0.5, 1600); } // the rally hit: a real 45 ms window (perfectMs)
ch.pose = 'swing'; ch.poseT = 0;
}
@@ -234,7 +244,10 @@ function shotBand(x0, y0, z0, v, g, tx, tz, ty, zt, q) {
return { x0, y0, z0, v, g, tx, tz, ty, zt, q, f: qualityFrac(q), T, t: 0 };
}
-function hitServe(sv, quality, aim = null) {
+// `perfect` is passed SEPARATELY from `quality` because the two serves earn it differently: the toss
+// ritual earns it on a 45 ms timing window, while the slingshot's quality saturates at pullFull and
+// so can never fail the old `quality > 0.999` test once you have pulled far enough.
+function hitServe(sv, quality, aim = null, perfect = quality > 0.999) {
const toFar = sv.side === 'P';
const s = TUNE.serveSpeed + quality * TUNE.serveQualitySpeed;
// splatServeTarget + serveArc are shared with previewServe, so the arc drawn under your thumb
@@ -294,7 +307,7 @@ function hitServe(sv, quality, aim = null) {
if (sv.side === 'P' && G.mode === 'tennis') G.outMarker = { x: boxX, z: tzs, out: !inServeBox(boxX, tzs), t: 1.3 };
G.phase = 'rally'; sfx(sv.side === 'P' ? 'tennis-player' : 'tennis-opponent', 0.9, { reverb: true }); // recorded serve strike, per side
sv.pose = 'serve'; sv.poseT = 0;
- if (quality > 0.999) { G.flash = { text: 'PERFECT', t: 0.7 }; G.hitstop = 0.05; }
+ if (perfect) { praise(); G.hitstop = 0.05; }
}
// ---------------------------------------------------------------- scoring ----
@@ -1038,7 +1051,8 @@ export function dragServe(aim) {
extras.push({ x: ball.x, y: ball.y, z: ball.z, vx: ball.vx, vy: ball.vy, vz: ball.vz, pow: ball.pow || 0.5, t: 2.5, g: ball.g });
ball.active = true; ball.collected = false; ball.bounces = 0; ball.firstLand = null;
ball.x = P.x + 0.12; ball.z = P.z; ball.y = 2.2; ball.g = TUNE.gravity; // over his head, see tossBall
- hitServe(P, pullQuality(aim.mag), aim); // a full pull is quality 1.0 — PERFECT, hitstop and all
+ // a full pull is quality 1.0, but only a pull that STOPS at the top of the draw is praised
+ hitServe(P, pullQuality(aim.mag), aim, pullPerfect(aim.mag));
}
// The swish cooldown, scaled by how crowded you are. Oliver died on every game-20 horde: the wave
diff --git a/index.html b/index.html
index 8b1502c..ac0fbd8 100644
--- a/index.html
+++ b/index.html
@@ -184,6 +184,6 @@
-
+