The lob is out: a gesture the opponent's position redefines is unpredictable

Oliver, on the build: "all good but the lob. please remove it - this is
totally unpredictable."

Reverted in full — oppAtNet, farBody, TUNE.lobArm, TUNE.lobApex,
SHOT_DEPTH.lob, the branch in doHit and its two tests. The forward flick is
the smash it always was. Everything else from that commit stays: the pace
feedback (streak, crack, camera kick) and the death match's blood alignment.

The reason is worth more than the code, and PROJECT.md carries it as a rule:
a gesture whose MEANING depends on where the opponent is standing is
unpredictable however well the shot is tuned. It is the same objection
input.js already states about the slingshot — the direction you moved is the
thing you asked for, no invisible modes — and the written comparison let
option (c) past it on the argument that context cannot mis-fire. It can: you
do not track where the far body is standing while a ball is coming at you, so
one thumb movement produced two shots for reasons you were not watching. The
LOB word at contact explained the surprise; it did not prevent it.

If it comes back it needs a gesture of its own. The ballistics are solved and
are in the history at 4f4534e.

111 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-22 13:13:56 +02:00
1 parent 4f4534e272
commit a42dc6fc81
5 files changed
+41 -146

No files matched your search

+34 -34
View File
@@ -491,44 +491,41 @@ zombie (now an occasional opponent, `TUNE.racketEvery`, not the every-game fixtu
`RETURNED`, `DOUBLE KILL`) is a footnote to something you already watched happen. Sharing the loud
FACE with the milestone cards is right; sharing their SIZE made every footnote read as a milestone.
## Session log: THE LOB, AND TWO THINGS THE MULTIPLAYER TEST FOUND (2026-08-22, same day)
## Session log: THE LOB (BUILT, THEN CUT) AND TWO THINGS THE MULTIPLAYER TEST FOUND (2026-08-22, same day)
### THE LOB IS THE FLICK YOU ALREADY HAVE — option (c), picked by Oliver after a written comparison
### THE LOB WAS BUILT AND REMOVED THE SAME DAY — and the reason is the useful part
Oliver: *"when you're close to the net in a duel with another player there should be a gesture
allowing to make a ball flying high from below (lupfen)... this likely needs to be discussed."*
Three candidates were put to him — (a) slow drag vs fast flick, (b) a down-then-up hook, (c) context
arms it — and he picked (c): *"i think this is the easiest. no additional gesture to learn."* He also
corrected the premise: *"the opponent should come to the net too — in zombie tennis the zombie often
comes to the net"*, and he is right (racketAI chases up to `COURT.svcFar`, the classic CPU to z 9.5).
Three candidates were put to him — (a) slow drag vs fast flick, (b) a down-then-up hook, (c) let
CONTEXT arm it: the forward flick keeps its one meaning ("put it deep behind him") and the game
picks the trajectory that does that, which with a man at the net is over his head. He picked (c):
*"i think this is the easiest. no additional gesture to learn."*
**The rule: a forward flick means "put it deep behind him", and the game picks the trajectory that
actually does that.** With him on his baseline that is the smash it has always been; with him in, it
is over his head. `oppAtNet()` in gameplay.js is the whole switch. Four things it rests on:
It was built, measured and shipped. It worked exactly as specified — armed at `TUNE.lobArm` 7 m
(the distance at which a lob peaking at `lobApex` still passes over `CONTACT.maxY`), pace derived
from the height so the arc was the same from the baseline and from mid-court, target deeper than
`SHOT_DEPTH.deep`, and the word LOB popped at the contact to say which shot had just been asked for.
1. **It reads the BODY, not O.** In the duel and the match the opponent you play is the racket
zombie; O is the invisible tennis body the serve machinery is driven off and it only moves
between points, so asking O would have answered "on his mark" all rally, in the one mode Oliver
plays most. `farBody()` picks the 'r' zombie whenever there is one and is not netplay.
2. **`TUNE.lobArm` 7.0 m is not taste.** A lob peaking at `lobApex` passes a man 7 m from the net at
almost exactly `CONTACT.maxY` (2.3 m, the height above which nothing can be struck) — so the shot
arms EXACTLY as far out as it can still clear him. Measured, not guessed; the numbers are in the
suite. Move one of the three and re-measure the other two.
3. **The pace is DERIVED from the height, never chosen.** `vy = sqrt(2g(apex - y))` fixes the hang
time and the ground still to cover fixes the speed, so the same arc comes off the baseline (9.5
m/s) and off mid-court (5.8 m/s). A fixed speed was tried on paper first and is a fine lob from
one place and a flat drive from the other. It also carries NO topspin multiplier — the dive that
makes a hard flat ball land in would pull a lob into the net.
4. **`SHOT_DEPTH.lob` is DEEPER than `deep` (2.0 vs 3.0 m).** At the ordinary deep target the ball
passes only a metre or two behind the man it is going over, and at the far edge of the arm band
that was 2.05 m — inside his reach. The shot is defined by where it comes DOWN.
**His verdict, within the hour: *"all good but the lob. please remove it — this is totally
unpredictable."*** It is reverted in full: `oppAtNet`, `farBody`, `TUNE.lobArm`, `TUNE.lobApex`,
`SHOT_DEPTH.lob`, the branch in `doHit` and its two tests are gone, and the forward flick is the
smash it always was.
Measured, nominal (no scatter), from the baseline: over a man at the far service line it passes at
**3.25 m**, lands 2 m inside his baseline, 2.17 s of hang. Over one at z 9.5 (a real net-rusher):
5.4 m. The AI can and does run it down — a lob is not a free point in tennis either — but it pulls
him off the net, which is what the shot is for. The word `LOB` pops at the contact: (c) buys "no
gesture to learn" by letting context change what the flick means, and something has to say which
shot you just asked for.
**THE LESSON, AND IT IS A RULE NOW: a gesture whose MEANING depends on where the opponent is
standing is unpredictable, however well the shot itself is tuned.** This is the same failure the
input layer already names in its own words — *"No timing window and no invisible mode: the direction
you moved is the thing you asked for"* (the slingshot's sidestep-vs-pull comment). The written
comparison put that objection against option (a) and let (c) past on the argument that context can
never mis-fire. It can: the player does not track where the far body is standing while a ball is
coming at him, so the same thumb movement produced two different shots for reasons he was not
watching. The word at the contact told him WHICH shot he got, but a label after the fact does not
make a gesture predictable — it only explains the surprise.
**If the lob is ever asked for again, it needs its own gesture** (option (b), the down-then-up hook,
is the only candidate left that cannot be confused with an existing one) — and the mechanics above
are all still correct and are recorded in the git history at 4f4534e, so the shot itself is a
half-hour's work. The question was never the ballistics.
### "THE OPPONENT SEES THE BALL REALLY FAST — THE PLAYER BARELY SEES THE SPEED INCREASE"
@@ -4033,9 +4030,12 @@ Nothing here is broken; these are known gaps and unverified risks.
from a tin). At 12 rounds/s a per-pellet sample will machine-gun the mixer, not just the wave.
- **boss sounds** — footfall, wind-up, throw, wound, death. Three designs in `BOSS_ART`; decide
whether they share one set or get one each before recording anything.
14. ~~**THE LOB ("lupfen")**~~ SHIPPED 2026-08-22 as option (c) — see the session log. The rest of
this entry is the discussion it came out of, kept because the two rejected options are the
argument for the one that shipped.
14. **THE LOB ("lupfen") — asked for, built and CUT on 2026-08-22. Do not rebuild it as option
(c).** Oliver played the context-armed version and removed it the same hour: *"totally
unpredictable."* See the session log for the rule that came out of it. What is left open is the
shot with a gesture OF ITS OWN — option (b) below is the only candidate that cannot be confused
with something already in the language. The ballistics are solved and are in the history at
4f4534e; do not re-derive them, and do not re-propose (c).
**~~asked for 2026-08-22, UNDER DISCUSSION, do not build it yet.~~** Oliver:
*"when you're close to the net in a duel with another player there should be a gesture allowing
to make a ball flying high from below... I don't know how to implement this gesture wise — this
+1 -1
View File
@@ -664,6 +664,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
</div>
</div>
</div>
<script type="module" src="src/main.js?v=307"></script>
<script type="module" src="src/main.js?v=308"></script>
</body>
</html>
+1 -15
View File
@@ -74,16 +74,6 @@ export const TUNE = {
smashBoost: 0.42, // extra pace on a FORWARD flick (aim.az < 0), scaled by timing. It does not
// send the ball longer — solveShot still lands it on the same spot — it
// gets there flatter and sooner, which is what a smash is.
// THE LOB ("lupfen", Oliver 2026-08-22). Same forward flick as the smash — the game picks which
// shot that gesture means from WHERE THE OTHER MAN IS STANDING, so there is no second gesture to
// learn. He is "in" when he is closer to the net than this, in metres:
lobArm: 7.0, // ...and 7.0 is not a taste number. A lob peaking at lobApex passes a man
// 7 m from the net at almost exactly CONTACT.maxY (2.3 m), so the shot
// arms EXACTLY where it can still go over him. Raise one, raise the other.
lobApex: 6.0, // m the lob peaks at, whatever the distance. The pace is DERIVED from this
// (see doHit): a fixed speed would be a fine lob from the baseline and a
// flat drive from mid-court, because the same speed over half the ground
// is half the hang time. Height is the shot; speed is what it costs.
aimError: 2.4, // m of scatter at quality 0
aimTrust: 0.3, // an aimed rally shot TRUSTS its flick: the scatter may still push the
// ball further OUT (the chosen out-risk keeps its exact shape) but only
@@ -1409,11 +1399,7 @@ export const P_BOUNDS = { x: 6.5, zMin: COURT.netZ + 1.2, zMax: 26 };
export const TOSS = { y0: 1.8, vy: 4.6, dx: -0.44, oDx: 0.42 };
// shot target depth from the receiving baseline, by aim (up = deep, none = mid, down = short)
// `lob` is DEEPER than deep on purpose: a lob that lands where an ordinary drive lands passes only
// a metre or two behind the man it is going over, and a ball that close to him is still inside his
// 2.3 m reach (measured — at SHOT_DEPTH.deep it cleared him by nothing at the far edge of the arm
// band). The shot is defined by where it comes DOWN, not by how high it goes.
export const SHOT_DEPTH = { deep: 3.0, mid: 5.4, short: 8.6, lob: 2.0 };
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;
+4 -35
View File
@@ -157,15 +157,6 @@ export const swayOf = aim => aim.mag === undefined ? aim.ax
// as flat rather than resetting the lesson it just failed to use.
export const flatCount = (streak, aim) => (aim && Math.abs(swayOf(aim)) > 0.15 ? 0 : streak + 1);
// WHERE THE MAN ACROSS THE NET ACTUALLY IS — and it is not always O. In the duel and the match the
// opponent is the BODY: O is the invisible tennis body the serve machinery is driven off, and it
// only moves between points, so asking O where the opponent is would answer "on his mark" all
// rally. In classic and in netplay (both kinds) O is the opponent, drawn and driven.
const farBody = () => (!G.net && zombies.find(z => z.variant === 'r')) || O;
// HAS HE COME IN? The one question the lob is armed on — see TUNE.lobArm for why 7 m and not taste.
// Exported for the suite and for anything that ever wants to show the player that the shot is live.
export const oppAtNet = () => COURT.netZ - farBody().z < TUNE.lobArm;
function doHit(ch, quality, aim) {
// aim: {ax: -1|0|1, az: -1|0|1} from held keys / flick at contact (az -1 = deep, 1 = short)
const toFar = ch.side === 'P';
@@ -180,7 +171,7 @@ function doHit(ch, quality, aim) {
ball.x = ch.x + ch.facing * 0.5;
ball.z = ch.z + (toFar ? -0.3 : 0.3);
const err = () => (Math.random() * 2 - 1) * TUNE.aimError * (1 - quality);
let tx, tz, aimedLane = false, sway = 0, lob = false;
let tx, tz, aimedLane = false, sway = 0;
let s = TUNE.baseShotSpeed + quality * TUNE.qualityShotSpeed;
const ft = G.mode !== 'tennis' && !G.interlude && ch.side === 'P' // the duel aims like tennis: lanes, not free-aim
? (aim && aim.fx !== undefined ? flickTarget(ch, aim) : { zt: zombieTarget(aim) }) : null;
@@ -230,30 +221,14 @@ function doHit(ch, quality, aim) {
const bound = Math.max(-TUNE.flickWidth, Math.min(TUNE.flickWidth, ch.x));
tx = sway < 0 ? Math.min(tx, bound) : Math.max(tx, bound);
}
// THE LOB IS THE SAME FLICK, READ AGAINST WHERE HE IS STANDING (Oliver 2026-08-22, "lupfen").
// Forward flick = "put it deep behind him", and when he has come to the net the trajectory that
// does that is over his head, not through him. No new gesture, nothing to learn, and it cannot
// be mis-fired — with him on his baseline the same flick is the smash it has always been.
lob = ch.side === 'P' && aim.az < 0 && oppAtNet();
const tzBase = lob ? SHOT_DEPTH.lob
: aim.az < 0 ? SHOT_DEPTH.deep : aim.az > 0 ? SHOT_DEPTH.short : SHOT_DEPTH.mid;
const tzBase = aim.az < 0 ? SHOT_DEPTH.deep : aim.az > 0 ? SHOT_DEPTH.short : SHOT_DEPTH.mid;
tz = (toFar ? tzBase : COURT.L - tzBase) + err() * 0.8;
// A FORWARD FLICK IS A SMASH. Up-screen is the far court, so flicking INTO the court asked for
// depth and nothing else — the gesture had a direction with no consequence behind it. It buys
// PACE now, scaled by timing like the width is. Note what it deliberately does not do: solveShot
// still lands the ball on the same tz, so extra speed makes the shot flatter and earlier rather
// than longer. A smash you can hit out on a whim would just be the bug above wearing a hat.
if (ch.side === 'P' && aim.az < 0 && !lob) s *= 1 + TUNE.smashBoost * quality;
// PACE DERIVED FROM THE HEIGHT, not chosen: for the lob to peak at lobApex it must leave at
// vy = sqrt(2g(apex - y)), which fixes the hang time, and the hang time over the ground still
// to cover fixes the speed. Same arc from the baseline and from mid-court — a fixed speed
// would be a lob from one and a flat drive from the other. Timing still prices it: `tz` above
// already carries the same scatter every aimed ball does.
if (lob) {
const vy0 = Math.sqrt(2 * TUNE.gravity * Math.max(0.5, TUNE.lobApex - ball.y));
const hang = (vy0 + Math.sqrt(vy0 * vy0 + 2 * TUNE.gravity * Math.max(0, ball.y - 0.08))) / TUNE.gravity;
s = Math.hypot(tx - ball.x, tz - ball.z) / hang;
}
if (ch.side === 'P' && aim.az < 0) s *= 1 + TUNE.smashBoost * quality;
aimedLane = true;
}
tx = Math.max(-5, Math.min(5, tx));
@@ -287,9 +262,7 @@ function doHit(ch, quality, aim) {
}
// topspin: quality shots dive under extra effective gravity, which is what lets real
// pace arc over the net and still land in — the core tennis trade-off
// ...and a lob carries NO topspin: the dive multiplier is what makes a hard flat ball land in, and
// on a shot whose whole point is hang time it would just pull the arc down into the net.
const g = lob ? TUNE.gravity : TUNE.gravity * (1 + quality * TUNE.spinFactor);
const g = TUNE.gravity * (1 + quality * TUNE.spinFactor);
let v = solveShot(ball.x, ball.y, ball.z, tx, tz, s, g);
// low contact → trade pace for loft until the net is cleared; good timing gets a
// comfortable margin, sloppy timing keeps some tape risk. Same crossesNet guard as serveArc's,
@@ -328,10 +301,6 @@ function doHit(ch, quality, aim) {
const w = praiseWord(); // a rally hit IS the impact — the word pops right on the contact
if (w) popText(ball.x, ball.y, ball.z, w);
}
// ...and the lob NAMES ITSELF. (c) buys "no gesture to learn" by letting context change what the
// flick means, and the price of that is that nothing on screen says which shot you just asked
// for. One word at the contact, in the praise words' own slot, is what pays it back.
if (lob) popText(ball.x, ball.y + 0.3, ball.z, 'LOB');
ch.pose = 'swing'; ch.poseT = 0;
if (G.net && ch.side === 'P') netSendShot(); // netplay: the shot IS the sync unit
}
+1 -61
View File
@@ -89,7 +89,7 @@ globalThis.getComputedStyle = () => ({ paddingTop: '0' });
// ---- game modules (real code) ----------------------------------------------
const state = await import('./src/state.js');
const { G, P, O, score, ball, setupPoint } = state;
const { update, forceZombies, startGore, splatPoint, restartRun, flickTarget, flatCount, swayOf, keyPressed, classicGame, serveApexY, assistK, rallyFeel, barrelGap, machCap, hitServe, cheer, inCourt, oppAtNet } = await import('./src/gameplay.js');
const { update, forceZombies, startGore, splatPoint, restartRun, flickTarget, flatCount, swayOf, keyPressed, classicGame, serveApexY, assistK, rallyFeel, barrelGap, machCap, hitServe, cheer, inCourt } = await import('./src/gameplay.js');
const { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill: _startDrill, endDrill, startMatch: _startMatch, endMatch, FALL_LEN, startNetVs, endNetVs } = await import('./src/beats.js');
const { mirror, netMsg, netTest, netTick } = await import('./src/net.js');
// the drill's first-sight modal freezes the world (that is what modals do) — dismiss it the way a
@@ -736,49 +736,6 @@ test('a receiver stands BEHIND his baseline, on both sides of the net', () => {
});
test('THE LOB: the forward flick goes OVER him once he has come in', () => {
// Oliver, 2026-08-22: "when you're close to the net in a duel there should be a gesture allowing
// to make a ball flying high from below." Option (c): no new gesture — the flick that already
// means "deep" picks the trajectory that actually puts the ball deep, which with a man at the net
// is over his head. The contract: unchanged while he is back, unhittable while he is in.
const shot = (oz, swipe) => {
const rnd = Math.random; Math.random = () => 0.5; // no scatter: the NOMINAL arc
startMatch(); G.interlude.introT = 0; G.phase = 'rally';
const zb = oppOf(); zb.x = 0; zb.z = oz;
P.x = 0; P.z = 23; P.swingT = -1;
ball.active = true; ball.lastHit = 'O'; ball.bounces = 1;
ball.x = 0; ball.z = 22.6; ball.y = 1.0; ball.vx = 0; ball.vy = 0; ball.vz = 1; ball.g = TUNE.gravity;
G.swipe = swipe; keyPressed('Space');
for (let i = 0; i < 20 && ball.lastHit !== 'P'; i++) update(DT);
G.swipe = null; Math.random = rnd;
assert.equal(ball.lastHit, 'P', 'setup: he got a racket on it');
let y = ball.y, z = ball.z, vy = ball.vy, apex = y, over = null;
const g = ball.g, dt = 1 / 240;
for (let i = 0; i < 4000 && y > 0; i++) {
const z0 = z; vy -= g * dt; y += vy * dt; z += ball.vz * dt;
apex = Math.max(apex, y);
if (over === null && z0 > zb.z && z <= zb.z) over = y; // height as it passes him
}
return { s: Math.hypot(ball.vx, ball.vz), apex, over, land: z };
};
const UP = { ax: 0, az: -1, fx: 0, fy: -1, mag: 90 };
const back = shot(1.5, UP);
assert.ok(back.apex < 2.5, 'he is on his baseline: the forward flick is the DRIVE it always was, apex ' + back.apex.toFixed(1));
assert.ok(back.s > 19, '...at drive pace: ' + back.s.toFixed(1) + ' m/s');
// ...and once he steps in, the SAME gesture lifts it over him. Two positions: the far service
// line (as far back as the racket zombie's chase ever brings it) and the edge of the arm band.
for (const oz of [9.5, 5.5, COURT.netZ - TUNE.lobArm + 0.05]) {
const l = shot(oz, UP);
assert.ok(Math.abs(l.apex - TUNE.lobApex) < 0.2, 'it peaks at lobApex from anywhere: ' + l.apex.toFixed(1));
assert.ok(l.over > CONTACT.maxY, 'it passes him at ' + l.over.toFixed(2) + 'm, over his ' + CONTACT.maxY + 'm reach (z ' + oz + ')');
assert.ok(l.land > 0.3 && l.land < COURT.netZ, '...and comes down IN, at z ' + l.land.toFixed(1));
assert.ok(l.s < back.s * 0.6, '...off a much slower ball: ' + l.s.toFixed(1) + ' m/s');
}
// the sideways pass is untouched — only the FORWARD flick changes meaning
const pass = shot(5.5, { ax: 1, az: 0, fx: 1, fy: 0, mag: 90 });
assert.ok(pass.apex < 2.5 && pass.s > 19, 'a sideways flick with him at the net is still a drive');
});
test('PACE IS FELT: a fast ball kicks the camera, a slow one does not', () => {
// Oliver, 2026-08-22, after the multiplayer test: "when smashing (for example on serve) the
// opponent sees the ball really fast — the player barely sees the speed increase." The ball IS
@@ -803,23 +760,6 @@ test('PACE IS FELT: a fast ball kicks the camera, a slow one does not', () => {
assert.ok(hard > 0, '(and the fast one did: ' + hard.toFixed(1) + ')');
});
test('THE LOB arms off the BODY, not off the invisible tennis opponent', () => {
// In the match O is the body the serve machinery is driven off and it only moves between points;
// the opponent you actually play is the racket zombie. Asking O would answer "on his mark" all
// rally, so the lob would arm at the wrong times in the one mode Oliver plays most.
startMatch();
const zb = oppOf();
O.z = 10.5; // O parked at the net, the body back on its mark
zb.z = 1.5;
assert.ok(!oppAtNet(), 'the BODY is back, so nothing is armed');
zb.z = 6.0;
assert.ok(oppAtNet(), '...and the body coming in arms it');
// classic tennis has no body: there O IS the opponent
restartRun(); G.classic = true;
O.z = 1.5; assert.ok(!oppAtNet(), 'classic: O back, not armed');
O.z = 9.5; assert.ok(oppAtNet(), 'classic: O in, armed');
});
test('the match brings its own legs: auto-run works even where autoAssist is 0', () => {
// The desktop pane (pointer: fine) has autoAssist 0, so the match played as a statue with a
// racket — the flight recorder showed a swing in every lost point and an x that never moved.