Oliver: "the serving and running of the opponent during normal tennis still
feels odd. which spritesheet was used?" Two sheets, two defects. The run
(o_run1-8, _refcheck/run_o.png via _runroll.py) FLICKERED IDENTITY — frames
alternated a tanned living face with a grey red-eyed one, because the
identity reference bakes in o_toss/o_smash and THOSE were the second defect:
the serve pair came from the legacy opponent_toss regen batch in a chibi
register (big head, heavy outline) that matched nothing around it.
Fixed in dependency order. The serve pair re-rolled pose-style (o_idle1 the
identity anchor): the batch's kit gate rejected all three tries but try 3
was right by eye — the "bright" channel it tripped on is the white-filled
string bed the run frames already carry — so it was installed on the montage
verdict, which is the pipeline's own rule. Then _pose_ref_run_o recomposed
off the cleaned frames and the run re-rolled: SIX frames, one face, one
racket side (+0.18..+0.24), real strides. The model drew 6 and a clean 6
beats a fought-for 8 (the player's cycle is already 6) — o_run7/8 deleted,
the SPR list trimmed, render's run8/run6/run4 probe does the rest. All six
verified 200 in the live game. The women's cycle (a floating shoe behind
her in every frame) is still rolling and lands separately. SPRV 118.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Oliver: 'the motion seems very flashy and unnatural'. Two causes, both
measured: drawChar centres a frame by its total width while the racket's
reach varies across the cycle, so the BODY teleported sideways up to 28
source px on a frame swap (the female player was worst) — every frame is now
padded until its lower-third centroid sits at the frame centre (residuals
±0.2 px, the normalisation lives in _runroll.py's cut). And the hardcoded
4.5 cycles/s was tuned on 2-4 near-duplicate frames; on eight real phases it
was a strobe — rolled sets stride at TUNE.runHz (2.4), old art keeps 4.5.
In-pane: body wobble down from ~36 device px per swap to 3.2.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The old run frames were generated one at a time and it showed: 91/110/92/98 px
of body-width drift, duplicated poses drawn differently, no racket. Each
character (player m/f 6 frames, opponent m/f 8) now comes from ONE 4K call
anchored on a composed identity reference of its settled frames — the broken
run art stayed out of the reference on purpose. Stride-gated, cut with _fine,
judged by eye per roll. drawChar probes run8/run6/run4/run2.
Side effect: the male opponent's old run art faced LEFT against the
art-faces-RIGHT convention — he had been moonwalking right since install.
Fixed by drawing the new rows to the convention.
Pipeline lessons recorded in PROJECT.md (the model negotiates the count; a
back view has no direction; the racket clause goes early and loud).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The "smaller when swinging or tossing" was an anchor bug, not art. _fine.py
scales a whole sheet so the --anchor frame is 224 px, and drawChar draws every
sprite at img.height/SPRITE_H — so anchoring opponent_play on the TOSS, with
arm and ball above the head, made 224 px cover the raised arm and shrank every
body on that sheet. o_swing1 was 162 px against a 224 px standing pose. Each
opponent batch now leads with a standing `ref` frame, which _fine.py anchors on
and _regen.py discards. Correctly anchored the toss is 278 px and the man is
full size; the swing is 221.
The receiver no longer slides 0.12 m side to side while waiting on a serve —
sliding a sprite sideways reads as skating, and the split-step swap to the run
frames (removed earlier) had already been the other half of that.
The idle bounce ball had one hardcoded offset for both men. They reach to
opposite sides of the screen — the player is drawn from behind, the opponent
from the front — so the opponent's ball sat out by his racket. dx and apex are
per-man now, and o_bounce/o_bounce2 were redrawn with a patting hand to put it
under.
Also: the opponent wears a RED headband, and a prompt of mine that said "white"
had quietly repainted it on six frames.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
zombie-mode: 5.9 MB of 24-bit WAV down to 287 KB. Its master is 982597 frames
at 48 kHz, so loopEnd is the un-round 20.47077 — the decoder hands back
20.4787, and those 7.9 ms of AAC padding would tick once a loop. Nothing in
assets/audio/ is a multi-megabyte WAV any more.
o_run1/o_run2 had no racket in either hand. The cause was the batch prompt:
opponent_run never said "racket", and its reference sheet was built from the
racket-less frames it was replacing, so there was nothing to copy one from. It
is a ref_only batch off o_idle1 now — same man, front-facing, racket in his
right hand — with the subject spelling out that he still carries it mid-stride.
The stride reads as well as the old one did.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both full sets through the same pipeline: stances and ball-bounce, serve toss,
smash and both swing frames, the four-frame run, and the five-frame death. The
opponent's ten frames with them. Inner-edge density roughly doubles across the
board — the serve frames go 0.22-0.32 to 0.41-0.54.
_regen.py retries a rejected batch itself now (default three attempts). A
rejection is usually just a bad roll — two figures drawn close enough that the
slicer sees one frame, or a limb off the canvas — and re-rolling fixes it without
me sitting in the loop.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The umpire is situational again. Each line is tied to the thing that justifies
it — a new variant appearing, the court filling, one crossing your baseline, a
game lost, a horde, a changeover — behind one shared cooldown, so several
triggers at once still yield one remark. The random timer pool is gone, the
interval went 26s to 55s, and ump_out_of_order, ump_let and ump_no_appeal are
deleted.
"Visually the ball is not yet at the player but somehow the player already is
hit" was real and measurable. The hostile-ball hit test reused the parry's
hypot(dx, dz*0.8), and weighting depth shrinks it: contact fired while the ball
was still 0.68 m up-court and drawn up around his head. It has its own
unweighted test now, and contact lands at 0.25 m with the ball on his chest.
The parry keeps its weighting — a racket sweep really is wider than it is deep.
On "everything is a tad too small": measured first, because higher-resolution
sprites are only half the answer. At RES 3 the player rasterises into 170
device pixels from a 112px source — upscaled 1.5x — while a far shambler is
downscaled to 78. So near bodies needed resolution and far bodies needed the
camera.
Camera: pushed back and lengthened (camZ 42->48, camF 580->760) to flatten the
perspective so distant bodies grow relative to near ones rather than everything
zooming, camY raised for a steeper look-down, horizon re-centred. Far shambler
26->30px, boss 39->45px, player unchanged, court in the same band.
Sprites: the original sheets were still in the repo at full resolution, so
nothing needed regenerating — IDLE_TARGET_H went to 224 and every sheet was
re-sliced. The sheet-to-output mapping was never recorded, so it was recovered
by matching sliced frames to existing assets by image signature: 58 came back
from sheets, the 29 without a confident match were 2x-upscaled to stay on the
shared scale, since a sprite off that scale renders at half height.
Tennis untouched: 50 hits / 0 whiffs. 45-game soak clean, all four chapters.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- white ring shows where the incoming ball will land; yellow/red ring shows
where your shot will land (red = going out) — Mario-Tennis-style feedback
- ball boys wait at the net posts, fetch every dead ball, and trot home
- canvas renders at 2x density, all sprites re-sliced at 112px; run/swing
sheets regenerated in the finer style
- assist: 0.35m deadzone + facing hysteresis kills the left/right flicker;
player reach 1.5 -> 1.7
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>