Keep the praise word on screen, and take the cliff out of the groundstroke

Oliver on v371: the praise word is cut off on the left on the iPhone. It
is centred under the player's feet, so out wide it ran past the edge;
every pop is now clamped to the canvas by its measured width.

Step 4, measured first. The rally sim gets a human bot (taps on the timed
ring with a timing spread), a Climb mode and a lost-point breakdown. At
level 26 the ladder falls over six rungs and then goes flat: nearly every
lost point is the player's own early whiff, the CPU barely ever misses,
and the auto-run reaches everything, so no opponent dial decides points.
A quality ramp, a follow-through and CPU put-aways were each measured
and reverted; the options go to Oliver in PROJECT.md.

Shipped: in the rally modes q no longer drops from 1 to 0.43 one ms past
PERFECT; it falls smoothly to the window's edge (TUNE.qSeamless). A tap
30 ms early is q 0.86 instead of 0.61. Same window, same whiff edge. v372.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5.5 committed 2026-10-14 18:01:24 +02:00
1 parent d561050af8
commit 5dd5d71c33
7 files changed
+170 -9

No files matched your search

+57 -2
View File
@@ -5531,6 +5531,60 @@ day, textbook ... verdecken manchmal den Ball."*
serve's landing). Gore keeps its words on the bodies they belong to. Checked in the pane: PERFECT
under him, ball clear.
### Step 4, measured first: the ladder cannot climb, because points are decided by the player's whiffs (2026-10-14, v372)
Oliver on v371: *"okay, aber der Text wird auf dem iPhone teilweise links abgeschnitten"*, and *"es kann auch
sein, dass durch die geänderte Mechanik alles etwas schwerer geworden ist"*.
- **The praise word is kept on screen** (`render`, the pops loop): v371 centres it under the player's feet,
and a player out wide pushed "SHOT OF THE DAY" past the left edge. Every pop is clamped to the canvas by
its measured width. Gore's words get the same clamp.
- **`tools/rally-sim.mjs` grew a human bot** (`BOT=human`, the default): it lands its finger on the timed
ring with `JIT` ms of gaussian spread (clamped ±2σ) plus a fixed `BIAS`, taps with a 120 ms lift, lobs or
passes a net man, drops a deep one. `CLIMB=1` plays the Climb's step boost on a pinned rung (training
with `train` off and hp topped up; driving the real Climb ended the run). `XP` sets the level, `WHY=1`
files every lost point under how it came about, `PROBE=1` times ring vs contact, `JSON=1` for
aggregation. Run 8 seeds and pool them: one seed's 40-50 points per rung is ±7%.
- **Measured** (Climb, level 26, 8 seeds × 500 s, point win %; σ40 ≈ a practised thumb, σ60 an average one):
| rung | 1 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| σ40 | 78 | 65 | 77 | 57 | 50 | 44 | 40 | 62 | 48 | 40 | 65 | 41 | 49 | 64 | 40 | 61 | 45 |
| σ60 | 61 | 39 | 52 | 38 | 32 | 30 | 29 | 44 | 27 | 29 | 48 | 23 | 31 | 51 | 29 | 49 | 34 |
The curve falls over the first six rungs and is then FLAT, zigzagging by style: the three net rushers and
the second slicer are 15-20 points easier than their neighbours, and the Champion is the Runner.
- **Why it cannot climb.** Nearly every lost point (`WHY=1`) is the player's own "MISS tap too early"; the
CPU's whiff rate is `matchFlub`·0.76^step, ~1% at the Runner and ~0.07% at the Champion. The auto-run
makes the player defensively near-perfect, so the CPU's pace, width, quality and style barely decide a
point; the player's whiff rate decides, and no opponent dial touches it. Three experiments, all measured:
1. **A quality ramp** (every rung's q band on a line from the shambler's to the Brute's, smart aim from rung
6): no change outside the noise. Reverted.
2. **A follow-through** (a groundstroke swing meeting a ball up to 60 ms after its window at q 0.05,
instead of whiffing): the bot won 80-99% everywhere, with 20-40-shot rallies. Reverted.
3. **On top of it, the CPU putting sitters away** (a q < 0.5 ball answered at q 0.95 to the open court,
with a chance from 0.2 at the shambler to 0.85 at the champion): no change. The auto-run reaches the
put-away too. Reverted.
- **Where the "harder" comes from.** The early whiff edge. A tap a little early starts the swing while
the ball is in the air, the swing waits for the bounce (`letBounce`), and the groundstroke window has run
out by then: a whiff at ~70 ms early (a tap 120 ms LATE still meets it). Before step 1 the same ball was
volleyed in the 1.8x window, so the edge sat near 130 ms. Sweep at level 26, rung 8, perfect thumb plus a
fixed bias: −70 ms is 56% contact, −50 ms q 0.24, 0 and +50 ms q 0.99, +70 ms q 0.52. The ring itself is
centred: contact comes 4-10 ms after its predicted moment, which is frame quantisation.
- **Shipped: the quality curve loses its cliff** (`TUNE.qSeamless`, rally modes, player only). q was 1 inside
`perfectMs` and `1 - off/half` outside it: a groundstroke fell from 1 to 0.43 one ms past PERFECT. It now
falls from 1 at PERFECT's edge to the window's edge. −30 ms is q 0.86 (was 0.61), +70 ms q 0.76 (was 0.52).
The window, PERFECT and the whiff edge are unchanged, and **the bot's win rate does not move**; this is a
feel fix, not the difficulty fix. Test: a tap 3 frames early of the kiss returns at q >= 0.45 (0.33 on
the old curve).
- **The decision for Oliver**, because each option changes what the game IS:
- (a) **Keep the error race and dial it**: the CPU's whiff curve gets a floor and a gentler decay so the
ladder slopes smoothly, styles get compensated. Cheap, predictable, still "who whiffs first".
- (b) **The follow-through, plus a CPU that can WIN points**: an early tap becomes a weak ball instead of a
whiff, and a sitter is punished by a put-away the auto-run does NOT fully cover (the run reacts late
after a weak ball of your own, or a put-away's pace is beyond its legs). Points then turn on shot
quality, which is what the opponent dials scale. More tennis, more tuning.
- (c) **Widen the early whiff edge only** (a smaller tail), keeping the error race: undoes most of the
"harder" without changing the model.
# OPEN — leftovers, in rough priority order
Nothing here is broken; these are known gaps and unverified risks.
@@ -5538,8 +5592,9 @@ Nothing here is broken; these are known gaps and unverified risks.
0. **THE RALLY GOES FLAT (2026-10-13), the top item.** Every match converged on serve, both at the net,
reflex volleys, won by a wrong-foot. Step 1 (the court gets its length) SHIPPED v361, awaiting the
phone verdict; the tap timing + leg knee (v362), the soft ball (v363, drop/lob split v364) and the
opponent styles (v364) followed. Awaiting the phone verdict on v364. See the section of that name
above. Fallback tag `rally-baseline-v360`.
opponent styles (v364) followed; the swing-log fixes ran to v371. **Step 4 (v372) is BLOCKED on a
design decision**: the ladder cannot climb while points are decided by the player's whiffs. See "Step
4, measured first" above for options (a)/(b)/(c). Fallback tag `rally-baseline-v360`.
1. **`RES` 4 is unverified on a real phone under load.** It nearly doubled the fill rate (810 -> 1080
device px across). Oliver has not reported stutter, but he also has not played a late horde since.
+1 -1
View File
@@ -1140,6 +1140,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
</div>
</div>
<span id="recLamp" hidden>REC</span>
<script type="module" src="src/main.js?v=371"></script>
<script type="module" src="src/main.js?v=372"></script>
</body>
</html>
+3
View File
@@ -153,6 +153,9 @@ export const TUNE = {
// 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
qSeamless: 1, // rally modes: past perfectMs, q falls from 1 to the window edge with no step.
// 0 = the old curve, 1 - off/half, which dropped a groundstroke 1 ms past
// PERFECT straight to q 0.43 (step 4, 2026-10-14 — see gameplay.trySwing)
// 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
+9
View File
@@ -1687,6 +1687,15 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) {
const center = TUNE.swingWindup + win / 2;
const off = Math.abs(t - center) / (win / 2);
let q = Math.max(0.05, 1 - off);
// ...WITHOUT A CLIFF in the rally modes (step 4, 2026-10-14). The line above runs from the centre, so
// q was 1 inside perfectMs and 0.43 a millisecond outside it: a groundstroke's half-window is ~80 ms
// and PERFECT owns 45 of them. The volley's 1.8x window used to hide that (q 0.69 at the edge); step
// 1 made the rally groundstrokes, and a 30 ms early tap came back at q 0.6 (0.89 now). Same window,
// same PERFECT, same whiff edge — only the step between them is gone. It does NOT move the ring bot's
// win rate: points are decided by whiffs, not weak balls (see PROJECT.md, step 4's measurement).
const p = TUNE.perfectMs / 1000, half = win / 2;
if (rallyFeel() && !isAI && TUNE.qSeamless && half > p)
q = Math.max(0.05, 1 - (Math.abs(t - center) - p) / (half - p));
if (Math.abs(t - center) * 1000 < TUNE.perfectMs) q = 1;
ch.facing = dx >= 0 ? 1 : -1; // forehand right (righty), backhand left → mirrored sprite
// the LIVE swipe outranks the released one: since the crossing fires the swing (input.js), the
+5 -1
View File
@@ -2883,7 +2883,11 @@ export function render() {
ctx.save();
ctx.globalAlpha = Math.min(1, f * 3); // holds, then a quick fade at the end
ctx.font = B(13); ctx.textAlign = 'center'; ctx.textBaseline = 'top';
shadowText(p.text, Math.round(pr.x), Math.round(pr.y - (p.still ? 0 : (1 - f) * 14)), p.col || '#ffffff');
// kept on screen: a word under a player out wide (the rally's praise, v371) is centred on his feet,
// and "SHOT OF THE DAY" off a man near the left tramline lost its first letters on the iPhone
const hw = ctx.measureText(p.text).width / 2 + 3;
const x = hw * 2 < W ? Math.min(W - hw, Math.max(hw, pr.x)) : W / 2;
shadowText(p.text, Math.round(x), Math.round(pr.y - (p.still ? 0 : (1 - f) * 14)), p.col || '#ffffff');
ctx.restore();
}
// (a PERFECT-contact spark ring lived here for one build — Oliver's verdict: too much. The
+29
View File
@@ -1143,6 +1143,35 @@ test('a SHORT ball is an approach, a deep one keeps the baseliner back, and nobo
} finally { TUNE.matchFlub = flub; }
});
test('a groundstroke a little off PERFECT is a little worse, not a weak ball (no cliff past perfectMs)', () => {
// Step 4's measurement (2026-10-14): q was 1 inside perfectMs and 1 - off/half outside it, so a
// groundstroke (half-window ~80 ms) fell from 1 to 0.43 a millisecond past PERFECT, and a tap 30 ms
// before the ring's kiss came back at q ~0.6, ~45 ms at 0.32 (0.57 now). The rally modes now fall from 1 at the edge of
// PERFECT to the window's edge. The finger lands 3 frames EARLY of the kiss, every ball.
const assist = TUNE.autoAssist; TUNE.autoAssist = 1.15;
try {
startMatch(); G.interlude.introT = 0; G.match.opp = 6;
for (let i = 0; i < 2.2 * 60; i++) update(DT);
let armed = false, incoming = 0, returned = 0, sumQ = 0;
for (let i = 0; i < 90 * 60; i++) {
G.modal = null; G.modalQ.length = 0; G.hp = G.hpMax; G.match.opp = 6;
if (G.phase === 'pretoss' && !(G.interlude && G.interlude.introT > 0)) { if (i % 40 === 0) keyPressed('Space'); }
else if (G.phase === 'toss') { if (ball.vy < 0.4 && ball.y > 2.4) keyPressed('Space'); }
else if (G.phase === 'rally' && ball.active && ball.lastHit === 'O' && ball.vz > 0 && !armed) {
const c = ringCue();
if (c && c.eta - c.sweet <= 3 / 60 + 1e-6 && c.eta - c.sweet > 2 / 60) { armed = true; incoming++; keyPressed('Space'); }
}
const before = ball.lastHit;
update(DT);
if (armed && before === 'O' && ball.lastHit === 'P') { returned++; sumQ += ball.pow; armed = false; }
else if (armed && (!ball.active || G.phase !== 'rally')) armed = false;
}
assert.ok(incoming >= 20, 'a real sample: ' + incoming);
assert.ok(returned / incoming >= 0.9, `an early-ish tap still returns: ${returned}/${incoming}`);
assert.ok(sumQ / returned >= 0.45, 'and still returns a real ball (0.32 on the old curve): mean q ' + (sumQ / returned).toFixed(2));
} finally { TUNE.autoAssist = assist; }
});
test('a TAP is timed from touch-down: a normal 120 ms tap on the gold ring still returns cleanly', () => {
// Oliver, 2026-10-14: "manchmal scheine ich den Ball trotz gutem Timing nicht mehr zu bekommen".
// A tap only proves it is a tap when the finger LIFTS, so it swung ~100-150 ms late — inside the
+66 -5
View File
@@ -3,6 +3,8 @@
// a fixed tap window, a random lane/deep flick) and prints WHERE each side meets the ball, how often
// it volleys, how long each shot gives the other side, and where rally balls land. It measures the
// shape of a rally, not a win rate — the bot is not Oliver. See PROJECT.md "THE RALLY GOES FLAT".
// Since step 4: BOT=human (default; JIT, BIAS ms), CLIMB=1, XP, WHY=1, PROBE=1, LOG=1|all, SUMMARY=1,
// JSON=1 — see PROJECT.md "Step 4, measured first". Pool 8 seeds before reading a win rate.
// ponytail: the platform stubs are copied from test.js; fold this into a test once the rework lands.
let _seed = (Number(process.env.SEED) || 20260811) >>> 0;
Math.random = () => { // xorshift32 — small, fast, good enough for jitter
@@ -84,14 +86,49 @@ const SECS = Number(process.env.SECS || 240);
TUNE.autoAssist = 1.15;
const pct = (a, f) => a.length ? Math.round(100 * a.filter(f).length / a.length) : 0;
const med = a => { const s = [...a].sort((x, y) => x - y); return s.length ? s[Math.floor(s.length / 2)] : NaN; };
const rows = [], land = [], pts = [];
// BOT=human (the default): taps on the timed gold ring like a player (JIT ms of timing spread, a
// 120 ms tap), and plays the game a little — a lob or a pass against a net man, the odd drop when
// the opponent hangs back. BOT=dist is the original fixed-distance tapper with random flicks.
const HUMAN = (process.env.BOT || 'human') === 'human';
const JIT = Number(process.env.JIT ?? 60) / 1000, LAG = 7;
const gauss = () => Math.sqrt(-2 * Math.log(1 - Math.random())) * Math.cos(2 * Math.PI * Math.random());
const probe = [];
const bot = { armed: false, fire: -1, jit: null, swipe: null };
const sw = (dx, dy, mag) => { const n = Math.hypot(dx, dy) || 1, fx = dx / n, fy = dy / n;
return { ax: Math.abs(fx) > 0.38 ? Math.sign(fx) : 0, az: Math.abs(fy) > 0.38 ? Math.sign(fy) : 0, fx, fy, mag }; };
function humanAim() {
const rz = process.env.CLASSIC ? state.O : zombies.find(z => z.variant === 'r');
const oz = rz ? Math.abs(rz.z - COURT.netZ) : 12, r = Math.random();
const away = rz && rz.x > 0 ? -1 : 1; // the side he is not on
if (oz < TUNE.netFrom) return r < 0.4 ? sw(0, 1, 150) : sw(away, -0.3, 120); // net man: lob him or pass him
if (oz > COURT.netZ + 0.5 && Math.abs(P.z - COURT.netZ) < COURT.netZ - 1.5 && r < 0.15) return sw(0, 1, 50); // drop
if (r < 0.45) return sw(away, -0.2, 110); // to the open side
if (r < 0.65) return sw(0, -1, 80); // deep through the middle
return null; // a plain tap
}
const rows = [], land = [], pts = [], whys = [];
for (const rung of RUNGS) {
restartRun();
restartRun(); Object.assign(bot, { armed: false, fire: -1, jit: null, swipe: null });
prof.prof.lg = 5; prof.prof.xp = Number(process.env.XP || 0); prof.takePromo();
G.mode = 'splatter'; G.everTransitioned = true; state.setupPoint();
const cfgm = await import(R + 'config.js');
if (process.env.CLASSIC) { G.mode = 'tennis'; G.everTransitioned = false; G.classic = true; G.diff = cfgm.DIFFS[rung]; state.setupPoint(); }
// CLIMB=1: the arcade run (its own step boost at the bottom), pinned to this rung like the suite does
else if (process.env.CLIMB) { beats.startMatch(false, null, rung); G.match.train = false; }
else beats.startMatch(false, null, rung);
const why = {};
let prevE = null, lastHitE = null; // LOST is filed under how it came about: the player's last act before it
G.missLog.push = function (e) {
if (e.result === 'LOST') {
const k = /never swung/.test(e.reason) ? 'never swung' : prevE && prevE.result === 'MISS' ? 'MISS ' + prevE.reason.split(' — ')[0].replace(/[\d.]+m?s?/g, '#').slice(0, 30)
: lastHitE && /q=0\.[0-4]/.test(lastHitE.reason) ? 'after weak hit q<.5' : 'after good hit';
why['L: ' + k] = (why['L: ' + k] || 0) + 1;
} else if (e.result === 'WON') why.WON = (why.WON || 0) + 1;
if (e.result === 'HIT') lastHitE = e;
if (e.result === 'WON' || e.result === 'LOST') lastHitE = null;
if (e.result !== 'THEM') prevE = e;
return Array.prototype.push.call(this, e);
};
G.modal = null; G.modalQ.length = 0;
for (let i = 0; i < 2.2 * 60; i++) { G.modal = null; G.modalQ.length = 0; update(DT); }
const lands = [];
@@ -101,11 +138,24 @@ for (const rung of RUNGS) {
let swipe = null, pts0 = [...score.pts], net = { P: false, O: false };
const won = { P: 0, O: 0, Pnet: 0, PnetWon: 0, Onet: 0, OnetWon: 0 };
for (let i = 0; i < SECS * 60; i++) {
G.modal = null; G.modalQ.length = 0;
G.modal = null; G.modalQ.length = 0; if (process.env.CLIMB && G.match) { G.match.opp = rung; G.match.train = false; G.hp = G.hpMax; }
if (G.mode === 'eaten' || G.done) { G.hp = G.hpMax; }
t += DT;
if (HUMAN && (ball.lastHit !== 'O' || !ball.active || G.phase !== 'rally')) { bot.jit = null; if (i !== bot.fire) { bot.armed = false; bot.fire = -1; } }
if (G.phase === 'pretoss' && !(G.interlude && G.interlude.introT > 0)) { if (i % 40 === 0) keyPressed('Space'); }
else if (G.phase === 'toss') { if (ball.vy < 0.4 && ball.y > 2.4) keyPressed('Space'); }
else if (HUMAN) {
// the finger lands when the ring says "now" give or take a human's timing (JIT ms, gaussian),
// and a tap lifts LAG frames later carrying its age; a flick fires on the land (its crossing)
if (G.phase === 'rally' && ball.active && ball.lastHit === 'O' && ball.vz > 0 && !bot.armed) {
const c = gp.ringCue();
if (c) {
if (bot.jit === null) bot.jit = Math.max(-2, Math.min(2, gauss())) * JIT + Number(process.env.BIAS || 0) / 1000;
if (c.eta - c.sweet + bot.jit <= 0 || c.eta <= 0) { bot.land = { i, eta: c.eta, sweet: c.sweet, b: ball.bounces }; bot.armed = true; bot.jit = null; bot.swipe = humanAim(); bot.fire = i + (bot.swipe ? 0 : LAG); }
}
}
if (i === bot.fire) { G.swipe = bot.swipe; keyPressed('Space', bot.swipe ? 0 : LAG / 60, !!bot.swipe); }
}
else if (G.phase === 'rally' && ball.active && ball.lastHit === 'O') {
const d = Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8);
if (d < 2.9 && d > 2.2 && P.swingT < 0) {
@@ -117,7 +167,10 @@ for (const rung of RUNGS) {
keyPressed('Space');
}
}
const lh = ball.lastHit;
if (process.env.PROBE && lh === 'O' && bot.land && P.swingT >= 0) bot.land.sw = P.swingT, bot.land.d = Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8), bot.land.y = ball.y, bot.land.bb = ball.bounces;
update(DT);
if (process.env.PROBE && lh === 'O' && ball.lastHit === 'P' && bot.land) { const L = bot.land; probe.push({ late: Math.round(((i - L.i) / 60 - L.eta) * 1000), sw: Math.round(L.sw * 1000), q: +ball.pow.toFixed(2), bAtLand: L.b, bAtHit: L.bb, d: +L.d.toFixed(2), y: +L.y.toFixed(2) }); bot.land = null; }
if (ball.lastHit !== 'O') G.swipe = null;
const rz = process.env.CLASSIC ? state.O : zombies.find(z => z.variant === 'r');
if (ball.active && ball.lastHit !== prevLast && (ball.lastHit === 'P' || ball.lastHit === 'O')) {
@@ -161,7 +214,15 @@ for (const rung of RUNGS) {
land.push({ rung, hitter: side, bounces: l.length, medLandFromBaseline: +med(l).toFixed(1),
'deep<3m%': pct(l, x => x < 3), 'svcbox>5.5m%': pct(l, x => x > 5.5), medPace: +med(v).toFixed(1) });
}
pts.push({ rung, Pwon: won.P, Owon: won.O, 'P net pts': won.Pnet, 'P net won%': won.Pnet ? Math.round(100 * won.PnetWon / won.Pnet) : '', 'O net pts': won.Onet, 'O net won%': won.Onet ? Math.round(100 * won.OnetWon / won.Onet) : '' });
const oh = hits.filter(x => x.side === 'O');
pts.push({ rung, foe: (zombies.find(z => z.variant === 'r')?.foe?.short) || '', 'P win%': Math.round(100 * won.P / Math.max(1, won.P + won.O)),
'shots/pt': +(rallies.reduce((a, b) => a + b, 0) / Math.max(1, rallies.length)).toFixed(1), 'O at net%': pct(oh, x => x.fromNet < 6.4), Pwon: won.P, Owon: won.O, 'P net pts': won.Pnet, 'P net won%': won.Pnet ? Math.round(100 * won.PnetWon / won.Pnet) : '', 'O net pts': won.Onet, 'O net won%': won.Onet ? Math.round(100 * won.OnetWon / won.Onet) : '' });
whys.push({ rung, ...why });
rows.push({ rung, side: 'rally', shots: rallies.length, medDistNet: med(rallies), '<6.4m(svcline)%': '', '<3.5m(net)%': '', 'volley%': '', medTimeToHit_s: 'shots/pt mean ' + (rallies.reduce((a, b) => a + b, 0) / rallies.length).toFixed(1) });
}
console.table(rows); console.table(land); console.table(pts);
if (!process.env.SUMMARY) { console.table(rows); console.table(land); }
if (process.env.PROBE) { const l = probe.map(p => p.late).sort((a, b) => a - b); console.log("contact after predicted entry, ms: n", l.length, "mean", (l.reduce((a, b) => a + b, 0) / l.length).toFixed(1), "p10", l[l.length * 0.1 | 0], "p50", l[l.length / 2 | 0], "p90", l[l.length * 0.9 | 0]); }
if (process.env.JSON) { console.log(JSON.stringify(pts)); process.exit(0); }
console.table(pts);
if (process.env.WHY) console.table(whys);
if (process.env.LOG) console.log(G.missLog.slice(process.env.LOG === "all" ? 0 : -25).map(e => `${e.at} ${e.result} ${e.reason}`).join("\n"));