The short ball gets chased, intercept gets legs, the vignette gets corners

Second recorded session: 5 wins and a deuce war to 5-6 — the mode is a
contest — but all seven losses shared one signature: the ball died in court
1.4-2m short of him while he ran 0.2-0.5m. Three defects:

The pace cut created dying rollers. Slow returns bounce and die at z 18-20,
outside intercept's near zone, so nothing chased them, and the rolled-dead
rule hands the point to the zombie. They also arrive at his feet below legal
contact height — the "ball arrives right at the player" feel. intercept keeps
an anyHit candidate now: the earliest reachable point on his half where the
ball is still at hitting height, so the assist runs forward onto the short
ball. Verified: he sprints z 23 -> 16.4 and meets it at gap ~0.

intercept computed reachability with autoAssist — 0 on any fine pointer — so
a desktop match's planner answered "home" every time. He had legs, his
destination was always the baseline. assistK() is shared by mover and
planner now.

And the vignette was a radial gradient in a portrait canvas: the circle
reaches the corners but hits the side edges at fraction zero — "red pulsing
from below" was the bottom edge being the only place it got close. Four
linear edge fades now, hugging the rectangle on any aspect.

48 tests, 12 seeds clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-11 23:36:15 +02:00
1 parent 3a789eb2e9
commit f37fb93875
5 files changed
+93 -16

No files matched your search

+24
View File
@@ -986,6 +986,30 @@ himself in live play.
trails. It ended a three-round guessing match in one session — his reports and my autopilot
measurements kept disagreeing because the autopilot had legs he did not.
**TWENTY-FIRST PASS — the short ball, the home-bound intercept, and the portrait vignette.** Second
recorded session: 5 wins and a deuce war to 5-6 (the mode is a contest now), but all seven losses
shared one signature — the ball DIED IN COURT 1.4-2 m short of him while he ran 0.2-0.5 m. Plus his
note *"there seems to be a problem when the ball arrives right from the player"* and *"still no
vignette. just a slight red pulsing from below"*. Three distinct defects:
- **The pace cut created DYING ROLLERS**: slow returns bounce and die at z 18-20 — outside
`intercept`'s near zone, so nothing ever chased them, and the rolled-dead rule gives the point to
the last hitter (the zombie). They also arrive at his feet below legal contact height, which is
the "ball arrives right at the player" feel. `intercept` now keeps an `anyHit` candidate — the
earliest REACHABLE point on his half where the ball is still at hitting height — so the assist
runs FORWARD onto the short ball, which is the tennis answer. (Zone points still win: deep is
better.) Verified: the chase test shows him sprinting z 23 -> 16.4 and meeting the ball at gap ~0.
- **`intercept` computed reachability with `autoAssist`** — 0 on any fine pointer — so a desktop
match's intercept thought nothing was reachable and answered "home" every time: he had legs
(matchAssist) but his destination was always the baseline centre. `assistK()` is the shared
helper now; both the mover and the planner use it.
- **THE VIGNETTE WAS RADIAL IN A PORTRAIT CANVAS.** The circle reaches the corners but arrives at
the left/right screen edges at fraction ZERO — "red pulsing from below" was the bottom edge being
the only place the circle got close. It is FOUR LINEAR EDGE FADES now, hugging the actual
rectangle on any aspect, sqrt(dmg) curve kept, inset deepening with damage. Posed at 6/8 hp: all
four edges carry visible red.
- **Test-measurement trap for the log**: reading `P.z` after the chase loop proves nothing — the
point reset teleports him home. Measure the minimum DURING the chase.
**Open, and every one of them is Oliver's call after he plays it:**
1. ~~**A ball that carries through the lane can complete two steps at once.**~~ Largely CLOSED by
the slice removal (seventh pass) — a cut ball picking its own next victim was the main way this
+1 -1
View File
@@ -400,6 +400,6 @@
</div>
</div>
</div>
<script type="module" src="src/main.js?v=173"></script>
<script type="module" src="src/main.js?v=174"></script>
</body>
</html>
+22 -6
View File
@@ -863,17 +863,33 @@ function onBounce() {
// Where should a player run to? Simulate the ball forward and take the first point in
// their half at hitting height that they can actually REACH IN TIME; otherwise chase the
// deepest hittable point. This is the "every ball is catchable, timing is the skill" contract.
// The assist's leg speed — the match floors it (matchAssist) because autoAssist is 0 wherever the
// pointer is fine. Shared by the movement block AND intercept below: intercept uses it to decide
// which points are reachable, and computing that with autoAssist alone meant a desktop match's
// intercept thought NOTHING was reachable and sent the player home every time — he had legs but
// his destination was always the baseline centre.
export const assistK = () => G.match ? Math.max(TUNE.autoAssist, TUNE.matchAssist) : TUNE.autoAssist;
function intercept(side) {
const ch = side === 'O' ? O : P;
const spd = side === 'O' ? TUNE.aiSpeed : TUNE.playerSpeed * TUNE.autoAssist;
const spd = side === 'O' ? TUNE.aiSpeed : TUNE.playerSpeed * assistK();
let { x, y, z, vx, vy, vz } = ball;
const g = ball.g || TUNE.gravity, dt = 0.01;
const inZone = side === 'O' ? zz => zz <= ZONE_DEPTH : zz => zz >= COURT.L - ZONE_DEPTH;
const home = HOME[side];
let fallback = null, deepest = null;
let fallback = null, deepest = null, anyHit = null;
for (let i = 0; i < 300; i++) {
vy -= g * dt; x += vx * dt; y += vy * dt; z += vz * dt;
if (y <= 0 && vy < 0) { y = 0; vy = -vy * TUNE.bounceY; vx *= TUNE.bounceXZ; vz *= TUNE.bounceXZ; }
// THE SHORT BALL (match): a slow return can bounce and DIE at z 18-20, which never enters the
// near zone below — so nothing ever chased it, it rolled dead at the player's feet (below the
// legal contact height by then) and the point went to the zombie. Seven of seven recorded
// losses. Remember the earliest reachable point ON HIS HALF where the ball is still at hitting
// height: running forward onto the short ball is the tennis answer, and now it is the assist's.
if (side === 'P' && G.match && !anyHit && i > 5 && z > COURT.netZ && y < 1.6 && y > 0.35) {
const pt = { x, z: Math.min(24.5, z) };
if (Math.hypot(pt.x - ch.x, pt.z - ch.z) / spd <= i * dt) anyHit = pt;
}
if (i > 5 && inZone(z) && y < 1.6 && y > 0.3) {
const pt = { x, z: side === 'O' ? Math.max(0.8, z) : Math.min(24.5, z) };
const reachable = Math.hypot(pt.x - ch.x, pt.z - ch.z) / spd <= i * dt;
@@ -882,7 +898,7 @@ function intercept(side) {
deepest = pt; // last resort: run at the deepest hittable point
}
}
return fallback || deepest || home;
return fallback || deepest || anyHit || home; // zone points first: meeting deep is still better
}
// ---------------------------------------------------------- miss diagnostics ----
@@ -1316,13 +1332,13 @@ export function update(dt) {
// desktop Browser pane (pointer: fine) had NO auto-run at all — the flight recorder showed
// Oliver swinging in every lost point while his x never moved: a statue with a racket. The
// match is a tap-timing game; footwork is the game's job. Held keys still override (mx/mz).
const assistK = G.match ? Math.max(TUNE.autoAssist, TUNE.matchAssist) : TUNE.autoAssist;
if (!mx && !mz && assistK > 0 && ball.active && !G.horde && !(G.mgT > 0)) { // auto-fire: you aim by hand, no assist
const aK = assistK();
if (!mx && !mz && aK > 0 && ball.active && !G.horde && !(G.mgT > 0)) { // auto-fire: you aim by hand, no assist
// splatter: anything travelling back toward you is yours to run down — your own rebound
// included. The assist puts you there; the volley timing stays honest.
const chasing = ball.lastHit === 'O' || (G.mode === 'splatter' && !G.interlude && ball.vz > 0);
// no `slow` here: tapping must not freeze your legs, or anticipating a shot loses it
const rate = TUNE.playerSpeed * assistK * dt * (chasing ? 1 : 0.55);
const rate = TUNE.playerSpeed * aK * dt * (chasing ? 1 : 0.55);
// Run to the ball — then recover. WHERE you recover to is mode-specific and each rule exists
// for a reason:
// GORE SPLATTER recovers in DEPTH ONLY, holding your lane — recovering to HOME.P's x undid
+19 -9
View File
@@ -1971,16 +1971,26 @@ export function render() {
const dmg = 1 - Math.max(0, G.hp) / (G.hpMax || 1);
const pulse = G.hurtFlash > 0 ? Math.min(1, G.hurtFlash / 0.3) * 0.3 : 0;
if (dmg > 0 || pulse > 0) {
// SQRT of the damage, not the damage: the match pool is 8, so a linear curve made the first
// hit a 10% alpha nobody can see — "the vignette is basically invisible". sqrt front-loads it:
// hit one on 8 hp reads at ~0.3 alpha, and it still deepens all the way to the cap. The inner
// radius tightens with damage too, so deep wounds shrink the clean centre.
// FOUR EDGE FADES, not a radial. A radial gradient in a tall portrait canvas reaches the
// corners but arrives at the side edges at fraction ZERO — Oliver saw "a slight red pulsing
// from below" because the bottom edge was the only place the circle got close. Linear fades
// hug the actual rectangle on any aspect. sqrt(dmg) front-loads the curve (the match pool is
// 8, so linear made hit one an invisible 10%), and the inset DEEPENS with damage so wounds
// shrink the clean centre.
const a = Math.min(0.85, Math.sqrt(dmg) * 0.8 + pulse);
const g = ctx.createRadialGradient(W / 2, H / 2, Math.min(W, H) * (0.5 - dmg * 0.22),
W / 2, H / 2, Math.max(W, H) * 0.7);
g.addColorStop(0, 'rgba(120,8,12,0)');
g.addColorStop(1, `rgba(120,8,12,${a.toFixed(3)})`);
ctx.fillStyle = g; ctx.fillRect(0, 0, W, H);
const col = k => `rgba(120,8,12,${(a * k).toFixed(3)})`;
const D = Math.round(Math.min(W, H) * (0.2 + 0.32 * dmg));
for (const [x0, y0, x1, y1, w, h, gx, gy] of [
[0, 0, 0, D, W, D, 0, 1], // top
[0, H, 0, H - D, W, D, 0, -1], // bottom
[0, 0, D, 0, D, H, 1, 0], // left
[W, 0, W - D, 0, D, H, -1, 0], // right
]) {
const g = ctx.createLinearGradient(x0, y0, x1, y1);
g.addColorStop(0, col(1)); g.addColorStop(1, col(0));
ctx.fillStyle = g;
ctx.fillRect(gx < 0 ? W - D : 0, gy < 0 ? H - D : 0, w, h);
}
}
}
+27
View File
@@ -588,6 +588,33 @@ test('the match brings its own legs: auto-run works even where autoAssist is 0',
} finally { TUNE.autoAssist = assist; }
});
test('THE SHORT BALL: the assist runs FORWARD onto a dying return, desktop conditions included', () => {
// Seven of seven recorded losses: a slow return bounced and died at z 18-20 — outside the
// intercept zone, so nothing chased it and it rolled dead at his feet (below legal contact
// height). Doubly broken on desktop: intercept computed reachability with autoAssist (0 there),
// so its answer was always "run home". Both fixed via assistK() + the anyHit candidate.
const assist = TUNE.autoAssist; TUNE.autoAssist = 0; // the desktop pane condition
try {
startMatch();
for (let i = 0; i < 2.2 * 60; i++) update(DT);
G.phase = 'rally';
P.x = 0; P.z = 23;
// a slow short return: incoming, will bounce and die around z ~19
ball.active = true; ball.lastHit = 'O'; ball.x = 1.5; ball.z = 10; ball.y = 1.6;
ball.vx = 0.4; ball.vy = 2.2; ball.vz = 5.5; ball.bounces = 0; ball.g = TUNE.gravity;
const z0 = P.z;
let minGap = 99, minPz = P.z; // deepest he RAN, read during the chase — the point reset
// teleports him home after, so the final position proves nothing
for (let i = 0; i < 2.5 * 60 && ball.active && ball.lastHit === 'O'; i++) {
update(DT);
minPz = Math.min(minPz, P.z);
minGap = Math.min(minGap, Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8));
}
assert.ok(minPz < z0 - 3.0, 'he ran FORWARD to meet it: z ' + z0 + ' -> ' + minPz.toFixed(1));
assert.ok(minGap < 1.6, 'and got the ball into reach (closest ' + minGap.toFixed(1) + ' m)');
} finally { TUNE.autoAssist = assist; }
});
test('the match recovers to CENTRE between shots — a wide return must not strand you', () => {
// The match inherited gore-splatter's depth-only recovery (a sidestep-protection rule) by being
// G.mode 'splatter' — so one wide return pulled you off centre and you STAYED there, and the next