The aim cloud goes quiet, and a mistimed flick falls short of the line

Hidden behind TUNE.aimCloud rather than deleted: 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 for the next look at it.

And "a little harder to hit the out" is a number: flickReach 0.62 -> 0.52,
which halves the worst-case rate from about 15% to about 7%, with the test
tightened so it cannot drift back. The shape is untouched — a perfect flick
still never sails, a badly timed full swipe still can — but a mistimed one now
lands nearer the middle than the line, which is the only half of that trade a
player who cannot see his own timing can do anything about.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-18 07:58:47 +02:00
1 parent 1b16b422df
commit 7ff8671a22
5 files changed
+30 -7

No files matched your search

+11 -1
View File
@@ -502,6 +502,13 @@ swayOf` reads the swipe's length as a continuous 0..1 (`TUNE.flickMin`..`flickFu
the SERVE already did, and `doHit` spends that instead of `ax`. Keys have no length, so they keep the SERVE already did, and `doHit` spends that instead of `ax`. Keys have no length, so they keep
the full lane, and the AI's aim (no `mag`) is untouched. the full lane, and the AI's aim (no `mag`) is untouched.
**...and the out was tightened once more** (2026-08-18, *"make it a little harder to hit the out"*):
`flickReach` 0.62 -> 0.52, halving the worst-case rate from ~15% to ~7% and pinned by the test. The
shape is unchanged — a perfect flick still never sails, a badly timed full swipe still can — but a
mistimed one now lands NEARER THE MIDDLE rather than nearer the line, which is the only half of that
trade a player who cannot see his own timing can do anything about. **`flickReach` is the out dial**;
`aimError` is shared with the opponent's returns and with every aimed shot in the gore modes.
**This is also what hands the OUT back to the player.** Oliver: *"the number one reason i die is **This is also what hands the OUT back to the player.** Oliver: *"the number one reason i die is
that i myself keep firing balls into the out."* Going out was never his choice — every aimed shot that i myself keep firing balls into the out."* Going out was never his choice — every aimed shot
took the FULL width and then `err()` on top, so the dice decided. Width follows the swipe now, so took the FULL width and then `err()` on top, so the dice decided. Width follows the swipe now, so
@@ -540,7 +547,10 @@ timing can keep in. Three things were learned making it read, all of them the ha
obvious ramp showed nothing across the whole safe half of its range. The cool end carries its obvious ramp showed nothing across the whole safe half of its range. The cool end carries its
contrast in LIGHTNESS, which is what makes it read on grass, clay and slab alike. contrast in LIGHTNESS, which is what makes it read on grass, clay and slab alike.
The threshold is not a flip: the colour is already gold before you reach it, so "too hard" is The threshold is not a flip: the colour is already gold before you reach it, so "too hard" is
something you watch coming. Only inside the window where a release would still BE a flick (`FLICK_MS`/`TUNE.flickMin`, the same something you watch coming. **It is OFF as of its first playtest** (*"hide the indicator for now"*)
— `TUNE.aimCloud` gates the whole block and the tuning panel can flip it back live. Kept rather than
deleted: it is the only thing that shows the gesture's strength before the ball leaves, and the
lesson it teaches has no other teacher. Only inside the window where a release would still BE a flick (`FLICK_MS`/`TUNE.flickMin`, the same
gate `endTouch` uses) — past that the gesture is a run and a target hanging off it would be a lie. gate `endTouch` uses) — past that the gesture is a run and a target hanging off it would be a lie.
Rally modes only (`rallyFeel`); gore's flick free-aims at a body, not at a lane. The placement ring Rally modes only (`rallyFeel`); gore's flick free-aims at a body, not at a lane. The placement ring
turns `UI.bad` when the shot really is out, so the two rings together ARE the lesson: what you asked turns `UI.bad` when the shot really is out, so the two rings together ARE the lesson: what you asked
+1 -1
View File
@@ -659,6 +659,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
</div> </div>
</div> </div>
</div> </div>
<script type="module" src="src/main.js?v=287"></script> <script type="module" src="src/main.js?v=288"></script>
</body> </body>
</html> </html>
+14 -4
View File
@@ -69,12 +69,17 @@ export const TUNE = {
flickWidth: 3.95, // m off centre a PERFECTLY timed flick reaches. The singles line is 4.115, 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 // 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. // not make the flick punchier, it makes a perfect one go out.
flickReach: 0.62, // how much of an aimed shot's width a MISTIMED flick gets (1 = all of it). 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 // 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 // overshot it: "flicking: too easy. i must be able to play out." At 0.62
// a mistimed flick CAN sail (~15% of the time at quality 0, on the worst // a mistimed flick sailed ~15% of the time at quality 0 and a perfectly
// half of the scatter) and a perfectly timed one never can — the risk is // timed one never could — the risk was back, and timing rather than a
// back and it is timing, not a coin toss, that decides it. // 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 // 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: // 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 // 3.3 * 0.45 + 2.4 = 3.89 against a 4.11 m line. Raise it and the flick
@@ -86,6 +91,11 @@ export const TUNE = {
// of agency, the player can't really tell if there was skill or luck"). The serve already read its // 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. // 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 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 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 // 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. // one goes hunting the line, which is what makes going OUT a chosen risk.
+1 -1
View File
@@ -2294,7 +2294,7 @@ export function render() {
// walks out toward the corner as you swipe further, and turns red once you are asking for a ball // walks out toward the corner as you swipe further, and turns red once you are asking for a ball
// the line cannot hold. Timing still decides how much of it you actually get — that is what the // the line cannot hold. Timing still decides how much of it you actually get — that is what the
// receipt says, and the difference between the two rings is exactly the lesson. // receipt says, and the difference between the two rings is exactly the lesson.
if (G.swipe && G.phase === 'rally') { if (G.swipe && G.phase === 'rally' && TUNE.aimCloud) {
const sway = swayOf(G.swipe); const sway = swayOf(G.swipe);
const x = sway * TUNE.flickWidth; const x = sway * TUNE.flickWidth;
const z = G.swipe.fy < -0.38 ? SHOT_DEPTH.deep : G.swipe.fy > 0.38 ? SHOT_DEPTH.short : SHOT_DEPTH.mid; const z = G.swipe.fy < -0.38 ? SHOT_DEPTH.deep : G.swipe.fy > 0.38 ? SHOT_DEPTH.short : SHOT_DEPTH.mid;
+3
View File
@@ -807,6 +807,9 @@ test('an aimed return can be played OUT, but only by mistiming it', () => {
}; };
assert.ok(pOut(0) > 0.02, 'a badly timed flick CAN go out: ' + (pOut(0) * 100).toFixed(1) + '%'); assert.ok(pOut(0) > 0.02, 'a badly timed flick CAN go out: ' + (pOut(0) * 100).toFixed(1) + '%');
assert.ok(pOut(0) < 0.25, '...but not the coin toss it was (every flick under q 0.66 went out)'); assert.ok(pOut(0) < 0.25, '...but not the coin toss it was (every flick under q 0.66 went out)');
// ...and "make it a little harder to hit the out" (2026-08-18) is a NUMBER, not a vibe: the
// worst-case rate was halved off 15%, and this is what stops flickReach drifting back up.
assert.ok(pOut(0) < 0.10, 'a mistimed flick lands nearer the middle than the line: ' + (pOut(0) * 100).toFixed(1) + '%');
assert.equal(pOut(1), 0, 'a perfectly timed flick never goes out'); assert.equal(pOut(1), 0, 'a perfectly timed flick never goes out');
for (let q = 0.05; q <= 1.0001; q += 0.05) for (let q = 0.05; q <= 1.0001; q += 0.05)
assert.ok(pOut(q) <= pOut(q - 0.05) + 1e-9, `risk falls as timing improves (q ${q.toFixed(2)})`); assert.ok(pOut(q) <= pOut(q - 0.05) + 1e-9, `risk falls as timing improves (q ${q.toFixed(2)})`);