Commit Graph
254 Commits
Author SHA1 Message Date
wuwiandClaude Fable 5 b8595f6c35 EASY grows a backbone, and the door out steps below the blood
Two phone verdicts. "easy is now way too easy" — its first cut was tuned
before HARD taught us which dials bite, and with reaction 0.62 and a 0.62
quality cap it had become a training dummy. EASY is a mild step below MEDIUM
now (flub 0.18, quality 0.76, reaction 0.54, speed 0.9): beatable without
being able to fall asleep on it. And the quit chip sat exactly on the BLOOD
panel's corner in the splatter modes — it rides 40px down now, below the
pips' strip, one position with one meaning in every mode. Verified live over
a zombie run: pips and door stacked, nothing touching.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 12:12:41 +02:00
wuwiandClaude Fable 5 8044857457 HARD finally outruns the runner
Oliver benchmarked HARD against the game's own roster: "the runner in zombie
mode is way faster than the regular player in hard" — and the numbers agreed.
The RUNNER covers 6.15 m/s (racketSpeed x rung x foe.speed) with no reaction
gate; HARD's classic CPU did 5.59, only after its 0.22 s read, and ambled
back to centre at HALF speed between shots — that recovery jog was the free
points. HARD now moves 4.3 x 1.5 = 6.45 m/s, and DIFFS grows `recover`: the
between-shots factor, 0.85 on HARD where the shipped 0.5 stays pinned on
MEDIUM. Wrong-footing him still works; strolling him side to side no longer
does.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 12:07:59 +02:00
wuwiandClaude Fable 5 11ca935ad0 Every run gets a door out: the quit chip and the LEAVE THE MATCH? ask
"all tennis screens are missing the ability to exit the game and return to
the menu — right now I need to force close the app." A quiet x chip rides the
top-right corner of every run (the scoreboard owns the left; the desktop key
hint shifts inboard). Tapping it — or Escape on desktop — freezes the world
with the existing paused flag and asks LEAVE THE MATCH?: BACK TO THE MENU in
the menu's crimson, PLAY ON underneath. Quitting is the death path minus the
death — restartRun, refreshSplash, splash — and banks nothing: an abandoned
run never happened, statistically. Verified live both ways: PLAY ON resumes
the frozen rally, quitting lands on the splash with the chip retired.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 12:07:10 +02:00
wuwiandClaude Fable 5 1facc954e2 HARD hunts the corner you are not standing in
"the hard mode is still too easy — make it really challenging." The dial that
was missing is aim: the CPU rolled a random lane on every shot, so a third of
everything came politely back to your racket regardless of the other six
numbers. DIFFS grows `smart` — the probability the CPU plays the OPEN court,
the corner away from P.x — and HARD sets it to 0.8: four shots in five now
make you run. EASY and MEDIUM keep random aim (MEDIUM pinned by test), so
the shipped classic is untouched below the top chip.

The rest of HARD tightened in the same pass: reaction 0.22 s, speed 1.30,
reach 1.85 m, quality floored at 0.70 under a cap of 1.0, serve 1.15, flub
one ball in a hundred. It reads your shot almost instantly, covers what it
reads, and places its reply where you are not.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 12:02:49 +02:00
wuwiandClaude Fable 5 77bc68043d Difficulty moves the dials a player can feel: reaction, reach, floor, serve
"using the difficulty in classic tennis I don't feel any difference" — and he
was right, verified live: G.diff arrives intact, the dials were just the
wrong ones. flub/cap/speed barely register: the quality cap trims a
distribution whose 0.45 floor never moved (EASY and HARD averaged 0.59 vs
0.70), and ±12% on aiSpeed never binds because the CPU pre-plans its
intercept. What a player reads as SKILL is how fast the CPU reacts to the
shot (aiReaction — the config's own comment says corners are winners because
of it), how long its racket is (aiReach), the FLOOR of its shot quality (a
raised qMin means every ball comes back with pace), and the serve — none of
which the first cut touched, and the serve was hardcoded 0.6-0.95 besides.

DIFFS carries seven dials now with real spread: EASY whiffs one ball in
three, reads late, swings short and serves soft; HARD reads in 0.28s, swings
1.75m, floors quality at 0.62 with a cap of 1.0, and serves 10% hotter.
MEDIUM is pinned dial-for-dial to the shipped TUNE values by test, so the
middle chip remains exactly yesterday's classic. All G.diff-guarded, so the
racket zombie and gore's opener keep their own numbers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 11:50:44 +02:00
wuwiandClaude Fable 5 6b795f77cc A poster answers, a border promises: the question screens drop their checkbox half
Oliver named the wizard's wrongness precisely: a pre-highlighted card that
also navigates is "a checkbox and a button combined in one". The border said
"this is a setting", the tap said "this was an answer", and the screen left
before the setting-reading of it could cash out.

So the two roles live on different screens now. The question screens (PICK A
GAME, DIFFICULTY, the zombie pair) carry NO selection: every poster and chip
is full-bright and equal — the dimming went too, it read as disabled — and
tapping answers and advances. The remembered run survives only as a small
LAST tag in the card's corner: an indicator, not a selection. The final
screen keeps its acid borders honestly, because SERVE UP is a real confirm —
the rule is "a border means SERVE UP will use this; a bright poster means
tap me to answer". Question screens rebuild on entry so LAST follows what
was actually played, and the first card clears the header by the margin the
old h3 rows used to give (Oliver's padding note).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 11:45:50 +02:00
wuwiandClaude Fable 5 65be60b139 The setup became a wizard: one decision per screen, tap advances
"the mode setup is really overwhelming now" — three posters plus a sub-row
plus player tiles plus court meters was a wall. Discussed, Oliver picked the
wizard with tap-advance over next-buttons and the accordion: PICK A GAME
(three posters, now 148px tall — alone on a screen they get to be posters),
then the mode's own second step only when it has one (classic: DIFFICULTY
chips stacked tall; zombie tennis: ARCADE and THE TOURNAMENT as full posters;
gore skips straight through), then MATCH SETUP holding player + court +
SERVE UP with the mode riding the summary footer.

Tapping selects AND advances — one decision per screen is the wizard's whole
promise, and a NEXT under a single row of choices is a second tap for nothing.
Back walks the chain the way it came (who-and-where returns to the second
step only when the mode has one). Every step comes up pre-selected from last
time, so a returning player is three taps from serving. Three new stages on
the intro's existing data-stage switcher; splash Enter goes to 'mode' now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 11:35:44 +02:00
wuwiandClaude Fable 5 e4c917e23d The menu grows a second step, classic learns difficulty, and the rally feel unifies
Oliver's restructure: mode first, then what the mode asks — classic deals
EASY / MEDIUM / HARD chips, ZOMBIE TENNIS deals two sub-mode posters (ARCADE,
the exhibition ladder as was, and THE TOURNAMENT), and SPLATTER GORE stands
alone as the third top card. config.MENU is the grouping, MODES stays the four
run modes, and pickedMode stays the run-mode id — start(), stats and the
summary never learn the menu got taller. The sub-row sits inset under the
posters; sub-cards are the poster at 2/3 height centred on their scene.

DIFFICULTY is the one real gameplay addition: config.DIFFS scales the three
dials the classic CPU already reads (flub, quality cap, aiSpeed), MEDIUM
pinned by test to the shipped classicFlub/classicQualityMax so the middle chip
IS yesterday's game. Rides on G.diff next to G.classic, so restarts keep it.

AND THE FEEL SPLIT OLIVER REPORTED IS REAL: "in classic tennis I tend to miss
balls way more often" — the match's three contact helpers (matchReach 1.35,
the matchAssist floor, the short-ball chase) were all G.match-gated, so
classic played with a 35% smaller racket and no rescue on the dying drop
shot. rallyFeel() = match || classic now carries all three; gore's opener
keeps the old prologue feel on purpose.

The ARCADE poster took two rolls: the first came back a flat sticker line-up
("very constructed, not artsy"), the second anchors on the zombie poster
itself as a MOOD reference — one challenger lit at the net, the queue receding
into purple haze. Rolls in _refcheck/modebtn. The cup's stats mapping also
joins MODE_MATCH (it banks through endRun as MATCH and read back as GORE).
SPRV 117, main.js v233, 66 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 10:29:51 +02:00
wuwiandClaude Fable 5 d3bbd8e157 The classic server keeps his head: the scene recomposed for its own crop
The bottom-anchored button shows the lower ~60% of a poster, and the classic
scene was composed sky-first — the serve lived in the top half and the crop
took his head ("the classic player head is cut"). Fixed in the ART, not the
CSS: one edit-mode roll on the existing image, the player shrunk and moved
down to stand fully in the lower half, sky and crowd untouched. The other
three scenes already composed bottom-heavy and keep their crop. SPRV 116.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 10:14:11 +02:00
wuwiandClaude Fable 5 046e2312f0 The poster subtitle earns its outline: white, 10px, black underlay
"the subtitle is barely readable" — cream at 80% over a pixel crowd was too
polite. White at full opacity, one size up from the court rows, and a solid
1px black outline all round plus the soft shadow, so the mode's one-line rules
read on every scene including the classic crowd, dimmed or lit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 10:10:31 +02:00
wuwiandClaude Fable 5 c75064e832 The mode posters grow taller and stand on their ground line
Oliver, off the first live look: a little higher, and align the art at the
bottom. 78 -> 96 px min-height, background-position center bottom — the scenes
now lead with their court instead of their sky, which suits three of the four
(the gore court's blood and net especially). The classic serve's head goes
over the top edge with the bottom crop; his scene was composed sky-first. A
per-mode position or a re-roll fixes that one if it grates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 10:09:11 +02:00
wuwiandClaude Fable 5 954f5e15bf The mode buttons became posters: four bright banner scenes, captions on top
Oliver's design call for the setup screen: the mode buttons should POP — a
bright full-bleed background symbolising each game mode, the description
written over it. Four scenes rolled through the _famroll OpenRouter route,
each anchored on its mode's own sprite for style, no text baked in:

  CLASSIC   a sunny sports-poster serve under a blue summer sky
  ZOMBIE    the turned opponent at an acid-green floodlit night net
  CUP       the champion's silhouette looming over the gold trophy
  GORE      the horde pouring over the net into a blood-red sunset

Installed as mode_*.png at a third of the 2K roll, quantised to 128 colours
(2.1 MB of menu art became 265 KB; pixel art quantises for free). The button
is the banner now: art + scrim gradient as one background stack, caption
bottom-left with a text shadow, and the unpicked modes sit desaturated and
dimmed so the chosen one is the thing that glows — the menu's own "one bright
thing means selected" rule, applied to pictures. The sprite-tile row and its
`tile` field are gone from MODES; raw 2K rolls kept in _refcheck/modebtn for
re-cuts. SPRV 114 -> 115, main.js v229. Verified in the browser: draw, select,
summary line, no console errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 10:02:39 +02:00
wuwiandClaude Fable 5 d87fbbda56 The card wears the gore-mode face: heads cut from the horde bodies
Oliver: the tennis-family sprites are optimised for movement, not gore — the
card should lead with the zombie. Each family already anchors on a horde body
(_famroll.FAMS reads its character from one), so _heads.py now cuts the icon
from THAT sprite instead of the fam_ sheet's tennis stance: the face on the
card and the monster on court are the same person, in the gorier print.

No resize, no requantise on the way: drawCard scales the PNG itself with
smoothing off and only reads the aspect, so the crop ships as a native-size
window onto art that is already flat pixel art. The top-down head detector
survives for the four bodies it works on; seven get hand boxes, because the
horde bodies carry PROPS — the keeper's ball launcher and the physio's
stretcher ride above their heads, the director holds a walkie-talkie level
with his, and the brute's and champion's shoulders are wide enough that a
full-silhouette crop at head depth came out 2.2:1 and starved the card title
of room. SPRV 113 -> 114.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 09:12:09 +02:00
wuwiandClaude Fable 5 6cc9799536 Route by job, not by file size: the tool table fresh sessions needed
Oliver's concern, after the un-chunking: a fresh session walked straight into
_regen.py and diagnosed from there, twice — first the legacy prompts, then the
model's rolls — while the real bug sat in the cutter, provable by putting any
fam_ sheet next to the sprite cut from it. The family-pipeline section already
demoted _regen.py, but 270 lines below the "Asset pipeline" heading, and the
session memory that routed the last investigation pointed at _regen by name.

So the routing is now the FIRST thing under "## Asset pipeline": a
which-tool-for-which-job table that names _famroll -> _zsheet for families,
marks _regen.py as the single-sheet remaster tool it still is (bosses, players,
crowd, weapons — those jobs keep it alive, so it stays), and states the
diagnostic rule that would have saved two days: when installed art looks worse
than the _refcheck sheet it came from, the suspect is the CUT, and a re-cut is
free. _regen.py's own header says the same, for whoever opens it first anyway.

The "Only ink votes" lesson joins the family-pipeline section with the numbers:
0.0% of ink blocks uniform at b=11 against 99.2% of empty ones is how a
detector proven on single-row sheets quietly broke on 9-row geometry — the
threshold never changed, the data under it did.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 08:57:25 +02:00
wuwiandClaude Fable 5 c4c9f0aac2 The background voted on the pixel grid: 309 sprites un-chunked at no cost
Oliver put two pictures side by side — z_rufall3 as the fam_ru sheet drew it,
and z_rufall3 as it shipped — and they were not even close. The sheets were
never the problem: every one of the eleven family rolls came back FINE, and the
pipeline pixelated seven of them on the way down.

_fine.block_size() scores b x b blocks by uniformity, and it scored ALL of
them. key() zeroes the background, a zeroed block is uniform at every b, and on
a 9-row family sheet the background is ~85% of the bounding box — so the
detector's 0.82 bar was cleared by the background alone, at any block size it
tried. Measured on the ru sheet: 0.0% of ink blocks uniform at b=11, 99.2% of
empty ones, overall 0.821. Detected block: 11. _zsheet then honestly snapped
428 px figures down to 39 art px and scaled them back up — the pixelation was
MANUFACTURED there, not drawn. br survived at 0.775 (its ink coverage is 4
points higher) and the whole per-family chunkiness table — br fine, ch/ru/sh/st
at 6 px blocks — was ink-coverage noise around one threshold.

The fix is one line where every caller routes through: background-only blocks
get no vote. Then every family re-cut from the sheets already on disk — no new
rolls, no tokens — and all eleven came down at art block 1. br's re-cut is the
regression check: byte-identical result. Dimensions drift at most 3 px, so
nothing on court changes size. SPRV 112 -> 113 so phones drop the year-long
cache on the chunky art.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 08:52:20 +02:00
wuwiandClaude Fable 5 129a001109 The latch is stale-proof by construction, the cup divisor honest, the trail visible
Oliver's tournament freeze was the stale latch by a THIRD route:
matchFallWillPlay kept the deathmatch /4 while cup matchPoint divides by
4*need, so it promised a fall on a no-crossing point — whose synchronous
matchReady left the after-set latch scoping a dead rally. No more ordering
arguments: the latch is set BEFORE the resolve, the swallow exempts the
in-progress resolution via G.matchEarly, and every path that arms a rally
clears it in matchReady. willFall now mirrors the cup divisor exactly and
knows a mid-round game keeps its body. Regression test drives the full QF
sequence (no-crossing points, the fall, the mid-round game, game two).

And the two 'not really' reports: the trail's 0.26 alpha was tuned on the
gore palette and vanished on the bright lawn — 0.38 now, gate at 13 m/s;
oppBright 1.18 -> 1.3.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 22:36:44 +02:00
wuwiandClaude Fable 5 f9b26e5380 THE TOURNAMENT, the pace-lit trail, and brighter opponents
The fourth mode: four rounds up the dead hierarchy (CUP_ROUNDS), each round
drawing a rung from its band, the champion always the final. It rides the
match machinery whole — cupDraw feeds the drawn rung through d.opp so every
ladder dial scales untouched; the body's pool spans the round (need-scaled
pool and divisor, so wounds carry between games); cupGameWon holds the same
body mid-round, kills it on the round, and past the final cupWon banks a
NOT-dead match run under CHAMPION OF THE DEAD CLUB. Blood carries; round one
is a single game; deathmatch stays as the arcade run. Full-run state test.

Sidequests: the ghost trail is pace-qualified (TUNE.trailSpeed) so both
tennis modes streak — pow>0.8 was only ever reached by the gore slingshot;
opponent sprites draw ~18% brighter via a load-time baked copy
(render.brighten, TUNE.oppBright) with no PNG touched; the cup scoreboard
line names the round instead of lying with deathmatch arithmetic; the rack
and the apron demo no longer draw under a trophy screen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 22:20:10 +02:00
wuwiandClaude Fable 5 1e601ca33d The fall drops fast, lies long, and never rises
Oliver, three refinements in one breath: the frame PROGRESSION quicker
(drop 0.06+0.25 -> 0.05+0.15 — against a live rolling ball the slower drop
read as mush), the prone hold KEEPS its bench-tuned 2 s ('the lying itself
can stay that long'), and the rise row is cut ('the opponent respawns
anyway') — done() re-marks the body in its wound skin regardless, so rising
first was a respawn with extra steps. The rise frames stay in the sheets
for the bench. Wound-beat test updated to the new contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 21:58:32 +02:00
wuwiandClaude Fable 5 f991f1ab10 Fix the early-fall soft-lock: the decided latch must die with its rally
Oliver's retest: 'the fall never happens, the game does not continue, the
ball is gone.' Root cause: the decided latch was set AFTER resolveInterlude,
and on a point with no stage change matchPoint re-arms the next rally
synchronously — so the latch outlived the rally it scoped and swallowed
every resolution after it.

Two changes: matchFallWillPlay() gates the early resolution to points that
actually put a collapse (or the game-winning kill) on court — an ordinary
point keeps its ordinary bounce-2 ending, so the ball never vanishes
mid-roll — and with only those paths admitted, the after-set latch is
provably safe: both defer matchReady until their beat ends. A regression
test drives the exact soft-lock sequence through the real update loop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 21:52:44 +02:00
wuwiandClaude Fable 5 53df079db6 The fall beats the ball: a passing winner fells the body while it still rolls
Oliver: 'the zombie falls after the ball is lost although we know this
earlier — it could play out as ball lost because of the falling.' The match
point now resolves at bounce ONE when the winner is provably beyond the
body's reach (closed-form flight vs its own chase dials, full swing reach
plus 0.6 m of doubt — borderline balls wait for the ordinary bounce-2 call).
startFall keeps the ball rolling for an early-decided point, and the
d.decided latch swallows the rally's later resolutions so it can never bank
twice. Verified in-pane: collapse starts ~0.3 s after the bounce, ball rolls
past the prone body, score banks exactly once.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 21:35:52 +02:00
wuwiandClaude Fable 5 c6218a6284 Exact touchdown, the boy finishes his shift, healing announces itself, 4K heads
Five of Oliver's six (the sixth is a play-mode discussion):
- THE DOUBLE FAULTS WERE FRAME QUANTIZATION: the bounce registered a full
  integration step after true contact — ~0.75 m of travel at serve pace — so
  serves aimed half a metre safe were judged long. Classic enforces the
  service box, the match only the court, hence 'zombie tennis does not'.
  The bounce now rewinds to y=0 before anything reads the position.
  Measured in-pane: 30/40 faults on good taps before, 0/40 after (mistimed
  and smudged taps also 0/40; aimed hard serves keep their scatter risk).
  Also: the strike flick dead zone is 44 px so a smudged tap stays a tap,
  and the aim base clamps 0.18 m inside the lines — scatter, not the aim,
  is what faults.
- The mop beat holds the walk-on until the boy actually LEAVES the court
  (ordered off at beat end, 6 s cap).
- A match game win that restores blood shows the duel's WOUNDS TREATED
  popup; full-blood wins stay silent.
- The opponent walk-on card holds 4.0 s (was 2.4).
- Card heads are cut from the 4K sheets at 144 px (assets/_heads.py,
  z_<fam>head.png, HEAD_CROP stays as fallback) — the old 65 px crops were
  the 'very low res' heads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 21:09:04 +02:00
wuwiandClaude Fable 5 c847bcbfa1 The racket holds still: both player run cycles re-rolled steady-handed
Oliver, after the facing fix: still flashing, female worst. Film-strip
diagnosis in the pane: with facing pinned the racket STILL alternated —
frame 4 of the female cycle tucked it across the body, and half the frames
of both player cycles lost or shrank the hoop to cut fragments (overlapping
figures sliced by the even-spaced valley cuts).

Both cycles re-rolled with three new prompt rules that now live in
_runroll.py: the racket stays right of the midline in EVERY figure (named
again for the airborne pose, where it kept vanishing), and the figures
never overlap (no more sliced hoops — a debris-stripping pass was tried
and removed: after the recrop a real racket is indistinguishable from a
fragment, prevention beats cleanup). The model returns 2-row grids at
times; a coherent band is cropped and cut. Installed: racket-side spread
+0.01..+0.05 across all frames of both cycles (was a full side swap).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 19:30:40 +02:00
wuwiandClaude Fable 5 35671c689b A facing flip must be earned: the racket stops flashing across the body
Oliver, women's tour: 'the racket she is holding flashing left and right
very fast' during auto-run. The rolled cycles carry a real racket, and the
mirror flip teleports it across the body — the per-frame steering flutter
the old racketless art could hide. earnFlip() requires an auto-steered
direction to persist TUNE.faceFlip (0.13 s) before the body mirrors, at all
three steering sites (player assist, tennis CPU, match opponent); manual
input stays instant. Measured over an 8 s live rally: 4 player flips, 2
opponent flips — every one an actual turn.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 19:08:18 +02:00
wuwiandClaude Fable 5 e8b5c1905f The run cycles anchor on their feet, and stride at a real cadence
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>
2026-08-13 18:53:22 +02:00
wuwiandClaude Fable 5 9a49acff51 The twelve-rung ladder: director, champion, physio and mascot walk on
Oliver's four picks, each rolled off its own settled BOSS frame through the
family pipeline (rows in _famroll.py) — the rung and the chapter boss are the
same person. matchFoe decoupled from bossArt's cycling on his call: the match
ladder is 12 with the champion capping rung 12; chapters keep their ten.

The champion refused to bulk through four rolls (words lost, then a new
pair-reference in char_ref only half-won), so his mass is stature: OPPONENTS
rows carry an optional world height h read by zombieH — champion 2.1 m, the
Bulk 2.0 m — and the serve aims at the body actually drawn. HEAD_CROP measured
for all four; ramp test extended to 12 rungs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 18:37:43 +02:00
wuwiandClaude Fable 5 99a87115b3 Four real run cycles, rolled one-call-one-row through a new _runroll.py
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>
2026-08-13 18:17:16 +02:00
wuwiandClaude Fable 5 de77ccfea5 The serve gets stakes: tight apex window, depth rides quality, the strike flick aims
Three mechanical causes of 'no serve agency', measured then fixed:
- the 0.12 m perfect snap was ~0.3 s of hang time (now TUNE.apexSnap 0.05,
  and the gold band reads the same knob);
- the fixed mid-box target depth capped every good serve at ~36 m/s in the
  loft loop — player serve depth now rides quality, flush aims 0.3 m inside
  the service line (ladder: 49 / 52 hard / 36 mistimed / 30 bad);
- the strike flick was splatter-only — in tennis and the duel it now sways
  placement (horizontal) and buys pace (vertical, serveHard + serveHardSpin,
  the extra dive IS the clearance), paid for in scatter against the real
  fault machinery. Tap stays the safe second serve.

Phone verdict pending — see the PROJECT.md session log.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 17:23:07 +02:00
wuwiandClaude Fable 5 c084d53497 Serve reads, the mop beat, the head-icon card, and Daniel benched
Oliver's batch, the code half:
- The tennis serve lost its ghost fingers (idle tap demo + apex rider deleted
  with their counters; the rally-return hint survives).
- The toss rises over the tossing HAND, measured off the toss frames' raised
  fingertips (TOSS.dx/oDx, women's tour override) — not over the head.
- An uncaught toss returns to the bounce stance instead of flashing the smash
  frame the player never earned.
- Between match games the court belongs to the mop boy: 5 s (TUNE.matchMopT),
  full job allowance, a hurry speed between stains, and the far-court reach
  limit lifted for the match (his stains are exactly where opponents die).
  The next opponent walks on only after — d.ending guards the empty court.
- The walk-on card wears the opponent's own head: config.HEAD_CROP, straight
  crops measured off the family sheets, no new art.
- The match intro is DEATH MATCH, counting the real blood pool.
- Daniel is benched: every ump_* personality line routes through aside()'s
  early return; officiating calls keep speaking. Personality pass later.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 17:10:45 +02:00
wuwiandClaude Fable 5 0e01894f4d The camera holds still: the ball-follow lean zeroed on Oliver's verdict
The flattened body curve stays (farK 0.25 — 'really good, helped a ton'); the
broadcast lean was 'distracting' and its knobs sit at 0 like the aimCam pair.
The plumbing stays for the T panel. PROJECT.md: farK is now a settled feel
decision.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 16:28:59 +02:00
wuwiandClaude Fable 5 e571e46d92 Far bodies draw off a flattened scale curve, and the camera leans into the deep court
Oliver: the far-side opponent is barely visible in portrait, and the lens was
already at its measured ceiling. TUNE.farK (0.25) grows far bodies ~20% while
the court keeps the true projection — the 16-bit tennis trick, anchored so the
near player is untouched. TUNE.farCamY/farCamZ add a broadcast lean while the
ball is on the far half, pivoted about the player's feet (fOff) so the pull
room the apron math promised never moves. Both live in the T panel; both
awaiting the phone verdict — see the PROJECT.md session log.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 16:18:53 +02:00
wuwiandClaude Opus 5 031a203bb4 Blood fountains at neck height over a body lying flat
Two notes off the bench, both about the fall.

THE BLEEDING DID NOT STOP. bleed()'s wound anchors are ABSOLUTE WORLD HEIGHTS —
the headless neck spurt is pinned at y = 1.5 m, the torn arm at 1.15 — so a
corpse lying flat through the whole 2 s floor hold kept spraying from where its
neck used to be, two metres above itself. Oliver: "the dynamic bleeding must
stop during fall as this looks like blood fountains midair." The line calling it
already said "open wounds bleed every frame they're STANDING" and never enforced
it; now it does. Measured on the headless stage: 20 particles emitted while
walking, 0 during the fall. Only the body G.matchFall owns is suppressed —
everything else on court bleeds as before.

THE SLIDE NOW FINISHES BEFORE THE RISE. "The slide must be finished before the
rise" — a body pushing itself back onto its feet is planted, and still drifting
while it stands up reads as the court moving under it. fallSlideMix goes
[.10 .30 .45 .15] -> [.12 .35 .53 0], the rise's share redistributed across the
first three IN PROPORTION so the total travel is unchanged: 0.06 / 0.26 / 0.41 /
0.000 m, still 0.77 m end to end.

Watch fall3: it carries 0.41 m over its 2 s hold now. The ease-out front-loads
it so it should still read as a skid that settles rather than a creep, and if it
drags, share moves back into fall2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 08:23:19 +02:00
wuwiandClaude Opus 5 f1af5c9a72 The corpse was levitating, and the wounded ones walked backwards
All of it found from the sprite bench, and most of it was the same mistake
twice: a correction applied on top of art that already handled it.

THE HEADLESS BODY SHRANK. "Every headless mode shrinks the whole opponent a
little" — measured, 34.0 px healthy / 33.8 arm off / 27.8 headless on all EIGHT
families, a flat -18%, and the FEET shrank 16-19% with it. A body that loses its
head does not get smaller feet. drawZombie carried `if (st === 2) h = 1.52` to
take a head off the top, and a headless sprite is still a FULL-HEIGHT frame,
because the generator draws a gushing neck stump whose blood plume fills the
space the head occupied: 18.3% of z_rw2's frame sits above its shoulders against
10.7% above z_rw1's head. So the art had taken the head off, the per-frame PNG
scaling had handled the height, and the override took it off a third time.
Wrong on the fallback skins too (z_cw2 is a SHORTER png, which the scaling
handles): its body drew at 1.30 m against the healthy 1.78 m. Gone. The HP bar
and the aim mark read config.zombieH directly, so they had been floating above a
shrunken body for as long as it existed; they re-align for free.

THE WOUNDED BODIES WALKED BACKWARDS, and this is not bench-only — racketAI and
updateBench both set face as the sign of x movement, so a match did it too. The
racket is what measures it, Oliver's own test: "if this racket is facing to the
right I would assume that he also walks to the right." Its string bed keys out
to an enclosed hole (or is painted pale on the master), so the hoop locates
itself. The healthy stride is drawn racket-RIGHT (+0.38..+0.42) and every wound
stride racket-LEFT (-0.39..-0.45): THE WOUNDED BODY IS DRAWN FACING THE OTHER
WAY, on the master and therefore in the reference and therefore in all seven
families. One flip rule cannot serve both rows — fixing the healthy walk
inverted the wound walk, which is the second thing he saw. The flip is derived
from the frame name now, and only for the WALK frames (w1/w1b/w2/w2b), because
they are the only ones whose orientation tracks travel: the wound forehand
measures racket-right like the healthy one, and a serve or a fall puts the
racket overhead where it says nothing about direction. Checked against 18 frame
names including the tour-suffixed ones.

AND _famref.py PINS THOSE SIDES. That branch is only correct while the art stays
this way, and this is the THIRD art inconsistency this session that reached the
court because nothing measured it. A re-roll that mirrors a row now fails the
reference build pointing at drawZombie's artFacesRight, instead of shipping.

A DEAD WRITE, found on the way: racketAI set rz.facing, and zombies render off
zb.face. `facing` is the PLAYER's field and uses the OPPOSITE convention (a char
flips on facing < 0, a body on face > 0), so the match opponent's turn was never
driven by its chase at all — it fell through to the generic sticky-face line.

THE PRONE FRAME LAY ON ITS RACKET. "The player basically levitates over the
ground held by his racket." Every frame is bottom-aligned on the ground point,
and in the fall3 art the lowest ink is the racket hoop thrown out beside the
body — z_shw1fall3 is 249x85 with the body's underside at y67 and rows 68-84
racket, so the torso floated a fifth of the frame up. TUNE.proneSink drops it by
that fraction, putting the body line where fall2 leaves its knees. The shadow
made it worse and is suppressed for the whole beat: the contact ellipse is sized
off DRAWN WIDTH, and a prone frame is three times wider than a standing one, so
the widest darkest shadow in the game was painted under the one body not
standing on it.

THE FALL CARRIES THE RUN. "When the opponent runs to the ball from left to right
and then falls, the player must still move to the right while falling." The
velocity is INHERITED (racketAI and updateBench both record zb.vx), so a body
that went down standing still does not slide and one caught at a sprint slides
furthest. It was exponential decay first, which put 93% of the travel in the
first two frames — "the visible slide should also extend to the 3rd and 4th
frame" — and no retuning fixes that: stretch a wall-clock decay far enough to
move the prone body and it creeps for the whole floor hold. So the slide is a
DISTANCE BUDGET shared across the frames (TUNE.fallSlideMix), eased out within
each so it still decelerates. x is set absolutely from the start, so the total
cannot drift with frame rate. 0.05 / 0.22 / 0.35 / 0.11 m — the landing skids
and the rise drags itself the last of the way.

THE BENCH GOT A FALL BUTTON to tune all of this, and it plays the REAL beat:
same FALL table, same tickMatchFall, because a bench that re-implemented the
timing would be refining a second copy of it. matchReady() is skipped while the
bench owns the court (it runs setupPoint and re-places the body — a match doing
match things inside an art test), the walk stop stops walking during a fall, and
startFall reads the table LIVE via fallLen() so tuning the numbers cannot leave
the 'over' beat expiring mid-collapse.

FALL is 0.06 / 0.25 / 2.0 / 0.45 after a session in the bench. fall1 and fall2
are deliberately the same pace (0.87 and 0.88 m/s of slide at a 1.54 m/s entry)
so the collapse reads as one movement decelerating into the ground; the 0.07 s
fall2 it replaced gave each drawn frame ~4 display frames and read as a cut.

THE LENS, portrait camZ 48 -> 49. The three camera numbers are not independent:
holding the near player's drawn size fixed means camF = s_player * (camZ - zMax),
holding the court's on-screen depth fixed means camY = depth / (s_player -
camF/camZ), so 49 gives 795 and 11.23 and horizon re-derives to 43. Far opponent
33.1 -> 33.8 px, player unchanged, court band within a pixel. One metre is
deliberate: at camZ 55 the umpire is already clipped off the right edge and by 70
the court reads top-down, and it narrows the player's lateral room because
xVisible clamps him to what fits. A figScale experiment that scaled every body
1.2x got further (far body 40.6 px) but is NOT in here — it needs the vertical
contact constants to scale with it (ball-vs-body is gated on ball.y < 1.9, which
sits just above the tallest chest at 1.856 m, so the roller man would have gone
invulnerable) and Oliver reverted it pending a real playtest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 07:32:17 +02:00
wuwiandClaude Opus 5 e121df569c The reference sheet was the bug: a clipped head, five scales, frozen legs
Oliver walked the sprite bench and reported the same two faults on every family:
the healthy walk loses its head, and the wounded walk's feet do not move. Both
were in the ONE FILE every zombie tennis character is drawn from — and the family
pipeline had copied it faithfully, which is the pipeline working.

WHERE IT CAME FROM. That sheet was not composed from the sprites. It was a
GENERATED RE-DRAW of the club pro, made in nine separate 4K calls, one per row
band, then stacked (gen4k.py, in a session scratchpad, untracked and now gone).
Nine calls returned nine scales, and call 1 came back with the shuffle frame
decapitated. Measured against the sprite it was drawn from, that one figure
scaled 2.341 wide and 1.996 tall where all thirty others were proportional to
three decimals: 33 px missing off the top, width untouched. The local input to
that call was exact at 2.000x. The sheet lost the head, not the sprite.

THE FIX IS TO STOP PUTTING THE REFERENCE THROUGH THE MODEL. _famref.py composes
it from the 31 z_r* frames on disk — one scale, applied once, NEAREST — and costs
no tokens. That kills the head and the five scales together, and it explains two
things in _zsheet.py that read like fudges: the fall1 re-anchor and the walk-frame
levelling exist only to undo per-band scale drift. The levelling is also what
turned a headless z_sh2 into a headless AND 13% oversized one, by reading the
missing head as a height error and scaling up to compensate.

THE GATE WAS MISSING ON THE INPUT. _zsheet.py checks what comes BACK from the
model — band count, figure count, stacked bodies, edges, palette, heights — and
nothing had ever checked the sheet handed TO it. _famref.check() re-reads the file
it just wrote with _zsheet's own detection and refuses one where any figure's
width and height scales disagree by 2%, or where a stride pair is frozen.

THE THIRD DEFECT NEEDED NEW ART. z_rw1b had the same legs as z_rw1 (15.1% against
the healthy pair's 51.1%), so the club pro has slid through both wound stages
since the day he shipped, and every family inherited it. _stride.py draws the
missing twin, and what made it work after words had failed for as long as they
were tried is SHOWING the edit: image 2 is the healthy pair side by side, the same
character performing exactly the change being asked for. The headless twin then
took the ARM-OFF pair as its example — a wounded body is a closer picture of the
edit, the same chaining the wound ladder already uses. Its gate measures three
things and prints all three: legs moved, body above the hips did not, racket kept
its bed. The thresholds are CALIBRATED on pairs that already ship, not chosen —
my first guesses rejected z_e1/z_e2 and z_g1/z_g2, both accepted art. And a twin
FILLS the racket bed instead of keying it out: the first rolls came back with 959
px of enclosed transparency against their base's 0, which would have flickered the
racket face empty/filled once per step.

AFTER, on the installed sprites — leg difference within each stride pair, where
the wounded pairs used to sit between 4.8% and 19.7%:

  r  51.1 / 68.1 / 36.7     ru 55.9 / 59.1 / 38.7
  br 40.7 / 42.0 / 26.8     gk 41.1 / 44.6 / 30.6
  sh 59.4 / 71.4 / 43.9     lj 38.4 / 45.5 / 27.4
  bb 56.5 / 67.1 / 43.2     st 51.6 / 66.1 / 35.7

All seven families re-rolled off the rebuilt reference, every play row with its
head, and all 24 family/wound-stage states alternating a real pair — checked by
instrumenting drawImage on the bench, not by eye. br and lj sit lowest on the
headless pair and are worth a re-roll if they ever read as stiff; lj needed one,
its first roll came back frozen on all three pairs with the headless row 31 px
short.

AND THE REFERENCE IS TRACKED NOW. It lived in gitignored _refcheck/, so the thing
the entire pipeline rests on was unreproducible for as long as it existed. It is
assets/_pose_ref_family.png, next to _pose_ref_serve.png, and the scripts that
build and consume it are in the repo instead of a scratchpad.

Also in here: the SHAMBLE BOB, which was a 1 px lift on a wall clock applied to
every body every frame whether it was moving or not — a held pose bobbed forever
and a corpse bobbed while it lay on the ground. It only happens while walking now,
and it steps off ground covered, which is the same rule the strides follow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 22:45:05 +02:00
wuwiandClaude Opus 5 eb0e8775a4 The timing cues were match-only, and the walk cycles bounced
CLASSIC TENNIS, third time asked and this time actually found. Both cues open
with a mode gate and I checked the wrong two features twice: drawApproachRing
(the closing circles that show when to tap) and drawApexBand (the serve bar
with the gold perfect window) each began `if (!G.match ...)`. So the mode whose
whole pitch is "if the core tennis is good everything else is good" was the one
mode with neither. They run on `G.match || G.mode === 'tennis'` now — the gore
mode stays out on purpose, there is no rally there. A test reads both function
bodies and fails if either is ever gated on the match alone again.

(The two I kept pointing at — the landing ring and the placement ring — really
are shared. They were just not the ones being asked about.)

THE BOUNCING WAS IN THE ART, and measurable: the six generated families came
back with the shuffle frame 30-44 px shorter than the stance it walks out of,
against 9 px on the hand-made racket family. The game draws every frame at its
own PNG height, so the body rose and dropped once per step. A walk cycle is one
drawing with the legs moved and must be one height; _zsheet levels the stance
and the stride pair now and leaves the forehand its own, exactly as the serve
and the fall keep theirs. All seven families re-cut: spreads are 1-8 px.

THE BENCH GETS BUTTONS. Arrow keys are not a control scheme on a phone, which
is the device the walk has to be judged on — so every family and every wound
stage was unreachable from the place the problem shows. Three on-screen pads:
BODY, WOUND, POSE, the two-way ones split left/right. Geometry lives beside the
drawing and input hit-tests the same function, the rule the rack established.

THE FALL is quicker still (0.05/0.07/2.0/0.30): drop, lie there, get up.

Three umpire lines pulled — ump_wound_you, ump_felled, ump_wound_opp2. Not
funny enough to keep. Files deleted, call sites collapsed to the two that stay.

60 tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 20:38:50 +02:00
wuwiandClaude Opus 5 e1ce2486c0 SERVE UP was dropping you into gore whatever you picked
THE MENU BUG, and it explains three reports at once. cStart's handler was
`addEventListener('click', start)` — and addEventListener hands a listener the
MouseEvent as its first argument, so `start(modeId = pickedMode)` never saw its
default: modeId was an EVENT OBJECT. `G.classic = modeId === 'classic'` was
therefore always false and the zombie branch never fired. Picking CLASSIC or
ZOMBIE TENNIS on the setup screen did nothing at all; SERVE UP always started
the gore mode. A default parameter only applies to `undefined`, and a click
handler always passes something. `() => start()`.

That is also why "in normal tennis i dont see the ball circles and the serve
hint bar": there was no normal tennis to be in. Both are drawn unconditionally
in render() and the tennis struggle hints are already mode-gated correctly.
Verified through the real menu after the fix: CLASSIC gives mode=tennis with
no bodies on court, ZOMBIE gives match=true against the SHAMBLER in its own
body. Every other bare handler in the codebase takes no parameters — checked.

THE MOP BOY had the same class of bug one layer down. sendBoy was called and
mopWorthIt() refused: it tests `d.z >= COURT.netZ - 2`, the NEAR half, because
in the gore mode everything dies on the near court. A match's blood is all on
the FAR side, where the body stands and bleeds through four wound stages, so
the boy could never qualify. The near-side bias stays; the match is the
exception.

DANIEL was being dropped, not missing. Two causes, both mine: the asides were
queued at 0.7-1.5 s and matchPoint fires the score call at 0.4 s with a clip
over a second long — voice() drops anything that would start while he is still
talking (one man, one microphone). And asideEvery is 55 s, a whole match game.
Delays moved past the score call; matchAsideEvery is 15 s, which fits a mode
whose events are a limb and your blood rather than a wave of bodies.

THE FALL: quicker down, longer on the floor (0.09/0.11/2.0/0.30). And it was
genuinely SHRUNKEN — the generator draws fall rows smaller than standing rows
(the brute's buckle came back 190 px against a 224 px stance) and _zsheet's one
global scale preserved that faithfully, so the body shrank the moment it
started to fall. A fall row is anchored on its own buckle now, which must draw
at standing height. All seven generated families re-cut: 224/153/98/181 against
the hand-made racket family's 224/154/85/179 — the convergence is the check.

THE FLICK: 0.42 made going out impossible, which fixed the 8-in-10 bug and
overshot it ("too easy. i must be able to play out"). 0.62 puts risk back where
it belongs — ~15% out at quality 0, 5% at 0.75, and never on a perfect flick.
The test now asserts that shape: bounded, monotonically falling, zero at 1.

59 tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 20:07:02 +02:00
wuwiandClaude Opus 5 52b38e4a39 Eight bodies on the card, and the umpire gets his voice back
THE FLICK, again. "i can barely reach the extremes of the court." It could
reach 3.3 m against a 4.115 m line — safe, and too safe. TUNE.flickWidth is
3.95 now (96% of the way to the corner, 17 cm to spare) with flickReach 0.42,
which keeps the guarantee intact: reach x timing + scatter stays inside the
line at EVERY quality, worst case 4.06. 20% more reach, still impossible to
send out by aiming.

A FORWARD FLICK IS A SMASH. Up-screen is the far court, so flicking into it
asked for depth and nothing else — a direction with no consequence behind it.
It buys pace now (smashBoost, scaled by timing). Deliberately NOT distance:
solveShot still lands it on the same spot, so the shot arrives flatter and
sooner rather than longer. A smash you can hit out on a whim is just the old
bug wearing a hat.

THE FALL HOLDS. "the opponent should fall and then lie there for a quick
moment, the player stands up too quick." fall3 goes 0.30 -> 0.62. A corpse
that touches the ground and is instantly upright reads as a stumble.

THE CARD. Ten opponents, six of them NEW bodies off the one-sheet pipeline
(shambler, ball boy, steward, runner, groundskeeper, line judge), with the
club pro's own body and the brute woven in as 7 and 8. It is a ladder of
STAFF: the further you get, the further up the club the thing across the net
used to be. Narrative and difficulty are the same list read twice, and a test
walks all ten — whiff must fall every rung, names must fit the board's 8-char
slot, speed may zigzag by character but never regress.

Steeper per rung (speedPer .08->.11, pacePer .035->.05, flubKeep .82->.76):
"in general this is too easy now". With aimed returns no longer sailing out,
the player stopped handing over free points and the ladder had to earn them
back. The opening got SOFTER at the same time — 0.72 where the old first
opponent was 0.85.

THE UMPIRE WAS MUTED IN THIS MODE and nobody noticed. aside() refused during
any interlude; the match runs on a permanent one. So the single mode that is
literally a tennis match he is sitting over was the one mode he never spoke
in. Same bug shape as sendBoy, same fix. Six dry lines via say -v Daniel: two
on taking a limb, two on your blood, one when there is nearly none left, one
on felling the thing. asideEvery still gates him.

...and sendBoy had the identical bug, so the mop boy now comes out between
games to the FAR court, where one body has been bleeding through four wound
stages. Between games only, never mid-point.

The line judge took two rolls: the first cut to 6 figures in its last row and
_zsheet.py refused it rather than mis-slicing. That refusal is the feature.

59 tests green. Verified in the browser: SHAMBLER on court in its own body.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:45:31 +02:00
wuwiandClaude Opus 5 c1f7268a04 The flick stops losing the point, and the corpse falls and serves
THE FLICK. "when returning a ball with a spin it seems too sensitive, 8/10
times the ball lands out." Arithmetic, not feel. The singles line is 4.115 m;
an aimed shot targeted a flat ax*3.3 and THEN added err() on top — up to
+-2.4 m at quality 0 — so a flicked return landed as wide as 5.7 m, and the
only clamp in the path sat at +-5, outside the court. An UNAIMED return
targets 0 + the same scatter and is therefore always in: aiming was the thing
losing the point, which is backwards for the mode's one act of skill.

TUNE.flickReach scales the aim width by the same quality the scatter shrinks
with, so the two can never sum past the line — worst case 1.49+2.4 = 3.89, and
a perfectly timed flick still gets the full 3.3 with nothing left to scatter.
The test asserts that sum across the whole quality range rather than sampling
rallies. Not named `aimReach`: that key already exists further down TUNE (the
slingshot's 22 m aim ray) and the duplicate silently won until a test caught it.

The placement ring carries `q` now and is the flick's receipt — tight and hot
when your timing earned the full width, wide and dim when you barely got a
racket on it. Without it the rule is invisible: the shot just goes less wide.

THE FALL (handoff item 4). A point that takes a limb knocks the body down and
it drags itself up short of it. The three collapse frames wear the stage it
STILL WAS and the rise wears the stage it has BECOME — that order is the whole
beat, and it is what turns a popping wound stage into something the point did.
Own clock beside the horde's warning, for the reason this file keeps relearning
(a beat that moves the phase under a latch strands it); the 'over' beat it sets
outlives the fall so it cannot expire into setupPoint() mid-collapse. Only
fires when the stage actually moved — a quarter off a full pool is 0.75, so
point one has no limb to explain and a second of collapse for nothing is a beat
the player waits out.

THE OPPONENT SERVES (handoff item 7), and it was mostly switched off rather
than missing. setupPoint already placed a non-player server behind its own
baseline and armed aiServeT; update() already tossed and struck for O. Both
were gated on `G.mode === 'tennis'` and the match runs in splatter under a
permanent interlude — one condition. Genuinely new: service alternates at each
game boundary (the match calls advancePoint directly and the flip lives in
tennis's pointTo, which it bypasses — the same shape as the G.serveNum bug),
the zombie takes O's mark so picture and ballistics agree on where it stands,
and the serve poses are named onto it because O is invisible here.

racketStage() moves to config: drawZombie and the fall must not answer the
same question twice, which is how the boss's skins and its blood drifted apart.

Docs: PROJECT.md gains THE ZOMBIE FAMILY PIPELINE — the measured API contract
(4K is a parameter and the default is 1K; n is 1 so "return both" is not a
thing; aspect is an enum so pad to it), why magenta stays despite green being
the documented key, and the two things _zsheet.py must do globally.

Verified in the browser: brute in the match, z_brfall2 -> z_brw1rise on a
wounding point, and bounce -> toss -> serve off the opponent's own baseline.
58 tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:08:02 +02:00
wuwiandClaude Opus 5 cba2fb8a8b One roll, one character: the whole zombie family from a single sheet
Nine sheets meant nine chances for the character to drift, and all day it took
every one of them — a brute that was three different brutes, a corpse that came
back a living man. Asking for the WHOLE family in one image removes that by
construction: one roll, one light, one palette, 31 figures.

What was missing was the way back down. _fine.py cuts a single row; a family
sheet is nine. _zsheet.py is that step and nothing else — it reuses _fine's
keyer, block detection and column cutter, and adds the two things that have to
be GLOBAL or the family breaks:

  SCALE    taken once from the play row's standing frame. Per-band scaling would
           make the serve strike the same height as the idle stance, and the game
           sizes a sprite off its own PNG height. Measured after: serve 319,
           prone fall 83 — the racket family's are 307 and 85.
  PALETTE  taken once from the largest figure anywhere, so a wound row cannot
           quantise onto slightly different colours than the row above it.

The enclosed magenta rule is INVERTED here, deliberately. _fine.py repaints
magenta trapped inside a figure because the generator used to fill string beds
with the backdrop colour by accident. This sheet asks for exactly that and gets
it, so the bed cuts to transparency and the racket is an open hoop.

_regen.py keeps three fixes found on the way: margins as a PROPORTION (an
absolute pixel margin means nothing to a model that picks its own resolution —
it clipped 3 rolls in 4), reference sheets that fill keyed-out string beds
instead of showing the model a magenta racket, and --verify, which asks the
question no gate here ever asked: is the art already on disk what it claims to
be. It found 27 of the brute's 31 frames dead — z_brw1fall was one drawing
pasted four times, 0.011 apart where a real sequence is 0.53.

The brute is live: ZFAMS gains 'br', the KEEPER wears it. Verified in #bench,
healthy and arm-off, 54 tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 18:37:18 +02:00
wuwiandClaude Opus 5 e2487b784a A match opponent is a FAMILY now, so a generated variant is one row and not dead art
--variant produces 31 frames; nothing could draw them. drawZombie hardcoded the
`z_r` prefix, the preloader spelled all 31 filenames out by hand, and the sprite
bench built its names from the same literal. A new zombie would have sat on disk.

  config.ZFAMS = ['r']        the families that exist
  config.famFrames(f)         the 31 names derived from a key

Everything that needs those names now derives them: the preloader spreads
ZFAMS.flatMap(famFrames), drawZombie reads `zb.fam` (unset = 'r', so the club
pro is untouched) and builds its wound lookups off it, and the bench walks
families on up/down. Adding an opponent is ONE ROW — which also retires 31
hand-typed filenames from the preload list, each of which was a chance to typo
one and have a body silently vanish for a frame. That is the oldest bug in
render.js and it is now impossible in this corner of it.

And the link that makes it worth anything: a LADDER RUNG can name a family.
`fam: 'br'` on a row in LADDER_TRAITS and that rung walks on as a different
body, all the way down its own wound ladder. So the whole path is now:

  _master.py --variant br "…"   ->  generate 31 frames
  ZFAMS += 'br'                 ->  the game loads them
  fam: 'br' on a ladder row     ->  that opponent is in the match

Verified: the bench still walks all 33 cells pulling the right derived name at
every wound stage, and a real match still opens on THE GROUNDSKEEPER wearing
family 'r' and drawing z_r1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 13:04:06 +02:00
wuwiandClaude Opus 5 5c0db0a22e The opponent is complete: 3 wound stages x 11 states, and a gate for wounds that never happened
The serve and the fall now exist at every wound stage, so a one-armed opponent
still serves and a headless one still goes down, instead of reverting to a
whole body to do it. 33 frames, no gaps.

THE BALL IS OUT OF THE SERVE SPRITES. Oliver: it was painted into three frames
and the game draws the ball itself, so every one of them would have ridden
along at the wrong place, the wrong time and in the wrong number. Hands open
and empty now, poses unchanged as if a ball were there.

THE FOURTH GATE, and the one nothing else could see: "copy this exactly, change
ONE thing" loses when the change is a REMOVAL. Taking a head off worked every
time — it is dramatic. Taking an ARM off mostly did not: five of seven wound
frames came back as the reference faithfully redrawn, 0.02-0.05 of their own
silhouette different, and the ladder quietly HEALED the body the score had just
maimed. Frame count, background, clipping, detail, kit and stride all pass that
sheet. A batch now declares how far it must move from the sheet it was drawn
from — a copy scores 0.02-0.05, a real amputation 0.12-0.38, a decapitation
0.27-0.75 — and the whole set now measures 0.18-0.65.

AND THE BACKGROUND RULE MOVED TO THE FRONT OF EVERY PROMPT. Six rejections in
eleven rolls were "background is not magenta", and the generator was not
glitching: it was painting the zombies ON A TENNIS COURT. The chained restyle
subjects are long, and the magenta instruction sat behind three hundred words
of "keep everything exactly as the reference", where it stopped being honoured.
Same sentence, said before anything can bury it.

One design consequence kept rather than papered over: the missing arm IS the
tossing arm, so a wounded opponent tosses off the racket face the way one-armed
players actually do. The wound changes how he plays, which is what slice 3 has
been asking for.

Known and NOT fixed: the shorts hue drifts from -9 to -19 down the ladder. Each
stage is measured against the one before it, so a legal 4-point hop three times
over is a 14-point total nobody authorised. The fix is to anchor every stage to
the healthy frame with a total budget instead of a per-hop one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 12:45:26 +02:00
wuwiandClaude Opus 5 4bbdf6357a The opponent can serve and fall, and there is a bench to judge it on
THE MISSING ART, generated by the whole-sheet restyle method: bouncing the ball
at the baseline, the toss with the ball leaving the fingertips, the strike at
full stretch — and the fall, which slice 4 has been waiting for: buckling, down
on the knees, flat on the court face down, then dragging itself back up. One
zombie is now complete as a tennis player rather than as a body that shuffles.
(Healthy only so far; the wound variants are the next sheet.)

THE SPRITE BENCH, in the back room. No ball, no scoring, no AI — one opponent
on an empty court, walking, at the size and speed it is actually played at,
because that is the only place a walk cycle can be judged. A sheet that looks
fine at 224 px on a contact sheet is a different animal at 30 px crossing a
lawn, and every sprite argument this project has had was really about that gap.

It steps through every state including the ones the game cannot reach yet, so
the serve and the fall could be looked at the day they were drawn instead of
the day they get wired up. It prints the FILENAME it is drawing and the hp the
wound stage was read from — the two numbers that separate "the art is wrong"
from "the wiring picked the wrong art" — and it says HEALTHY ONLY where a wound
variant does not exist rather than implying one does. The stride steps off
ground covered, like the real thing: a bench animating on a clock would flatter
exactly the sprites that foot-slide in play.

It owns the court like every other mode and every start* refuses while it runs.

FOUND BY IT, ten seconds in: `start()` applies the mode the setup screen picked,
so a remembered ZOMBIE TENNIS was taking the court before the back room could —
every back-room entry (weapons, bosses, drill, match, bench) had been silently
refusing since the mode select landed this morning. The back room passes null.

And a third calibration lesson: POSE MOVES A GLOBAL COLOUR STATISTIC. A body
face down shows a fraction of the shirt it shows standing, so `redness` fell 14
points across the fall sheet without a colour changing. The measure that
survives a pose change is the shorts HUE — mid-tone neutrals, present in every
frame in the same proportion — which is why it is the one that caught the drift
Oliver actually saw.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 11:25:29 +02:00
wuwiandClaude Opus 5 ffc9cd3a60 Damage accumulates, because stage 2 is now a RESTYLE of stage 1's whole sheet
The arm kept coming back at the headless stage no matter how the prose was
worded, and the reason was the picture: stage 2's character reference was the
OLD two-armed headless drawing, so the model was faithfully copying two arms
while the words asked for one. Prose does not win an argument with a reference.

Stage 2 now takes stage 1's ENTIRE THREE-FRAME SHEET as its reference and the
job becomes "redraw this sheet with ONE thing changed — the head comes off".
The missing arm rides in the picture. Both tours installed FIRST TRY, where the
previous wording burned twelve rolls and never once got there.

The ladder finally reads: whole -> left arm gone -> headless AND still one-armed,
same body, same charcoal, same shoes, all the way down. In a mode where the
score IS the body that is the whole point, and it has never been true until now.

--audit stays clean: all six stride pairs pass.

This is Oliver's method and it is better than what it replaced. What makes it
work is that each call TRANSFORMS a picture instead of describing one, so the
reference does the specifying and the prose only says what changes. Fewer calls
is the smaller half of it; the consistency is the point.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 11:14:17 +02:00
wuwiandClaude Opus 5 4e7b26f874 Every stride pair now passes the gate — the legs actually move
`_regen.py --audit` is clean for the first time:

  racket_play    z_r2/z_r3       1.00  0.157  0.565   ok
  racket_w1      z_rw1/z_rw1b    1.00  0.112  0.424   ok
  racket_w2      z_rw2/z_rw2b    1.00  0.077  0.372   ok
  racket_f_play  z_r2_f/z_r3_f   1.00  0.049  0.552   ok
  racket_f_w1    z_rw1_f/…b_f    1.01  0.112  0.716   ok
  racket_f_w2    z_rw2_f/…b_f    1.00  0.054  0.793   ok

Against the reference pairs (brute 1.00/0.000/0.638, shambler 1.00/0.070/0.520)
these are the real thing: one body, same camera, legs swapped. Fifteen rolls
were rejected getting here, every one for a named measurable reason — which is
the whole point of the gate: none of it shipped on luck.

Two calibration lessons, both from watching what the gate rejected:

BRIGHT is off for the racket batches. It counts near-white pixels and was
calibrated on the LIVING opponent, whose racket barely registers — these frames
hold a pale-strung racket FACE-ON and it swings 3%..20% purely with how the
racket is angled. It was rejecting rolls for racket geometry. Redness, shorts
brightness and shorts hue are the kit, and hue is the one that caught the drift
Oliver actually saw.

EACH WOUND STAGE IS HELD TO THE STAGE BEFORE IT, not to the healthy frame two
steps away. Stage 2 kept being rejected at hue -4.2 against the healthy -8.7
while the stage it actually follows sits at -5.0 — the roll was more consistent
with its own neighbour than the gate would allow. Chained references stop drift
compounding without demanding stage 2 match a frame the player never sees next
to it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 10:47:19 +02:00
wuwiandClaude Opus 5 1c405f045f THE STRIDE GATE: a walk cycle is one drawing with the legs moved, and now something checks
Oliver, on the sprites, again: "when people on a tennis court walk or run their
legs move... this pattern is almost always failing." It has failed repeatedly
because NOTHING IN THE PIPELINE MEASURED IT. Frame count, magenta background,
clipped edges and detail density all pass cleanly on two unrelated
illustrations of the same character, which is exactly what the generator
produces when asked for "the same frame with the other leg forward".

Three numbers per declared pair, silhouette only, on the feet-aligned centred
overlay drawStatic actually draws:
  HGT  height ratio — off 1.00 means the body grows and shrinks once per step
  TOP  difference ABOVE the hip — near zero for a real pair: same man, same camera
  LEG  difference BELOW it — this is the step
Calibrated on the pairs that read (brute wound lurch 1.00/0.000/0.638, shambler
1.00/0.070/0.520) against the ones that do not (TOP 0.47-0.85, or LEG 0.09).
Opt-in per batch via `stride: [[i, j]]`, because the player's four-frame RUN
sits at TOP 0.23 and is right — a run swings the arms too.

`--audit` runs the same gate over the sprites ALREADY INSTALLED and exits
non-zero on a failure: a gate that only fires on new rolls says nothing about
the art already in the game, which is where this was noticed. Five of the six
racket pairs fail it today.

And the kit gate, which already existed and was never switched on here: Oliver
spotted the headless men's trousers had changed colour. Measured — the headless
set's near-white fraction DOUBLES (9.5 -> 19.5) where losing a head only
accounts for ~1.3 of that, and the wounded swing drifted the other way (shorts
60.5 against its own stage's 85). palette_ref + a per-batch tolerance now catch
all three drifted sets and pass the one that looked right.

Fixed while in there, mine from the last commit: the wounded stride stepped off
walkT, a wall clock, while the healthy one steps off ground covered — the exact
foot-slide this project banned for the heavy bodies.

No art regenerated yet: the gate and the audit come first, so a re-roll can be
told from a lucky one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 10:09:31 +02:00
wuwiandClaude Opus 5 d5942e8aad The match gets its own board, and it names what is across the net
A second plate, drawn rather than tinted (assets/_board.py — _regen.py cannot
do this job: it keys magenta, crops to ink and rescales figures to a common
height, and a scoreboard is a full-bleed rectangle with no figure in it). The
club's bottle-green board is the club's; the match is played on one nobody has
cleaned in a long time, and the ink changes with it — a green 'dim' over dried
blood reads as a printing fault.

Two column changes go with it: the name slot says KEEPER / JUDGE / CHAMPION
instead of THEM (eight characters, `short` on the ladder rows), and the SETS
column is gone in the match, where it was a permanent 0 — the bar under the
board already carries that mode's progress.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 09:37:58 +02:00
wuwiandClaude Opus 5 c629ae1108 The ladder has names, the bodies play tennis, and the stats screen splits
THE SPRITES. The match opponent borrowed the turned CPU's art — a body drawn
to SHAMBLE at the camera with its racket hanging at its side, which is what
was playing tennis points against you. It has its own family now, 'r', drawn
off the LIVING opponent's poses: a ready stance, a shuffle pair and a real
forehand, at every wound stage, men's and women's — 20 frames, six batches.
'c' keeps its shamble untouched, because in the gore mode shambling is exactly
what the turned CPU should be doing. The pose comes from racketAI's own
`pose`, so the picture cannot drift from the behaviour, and a one-armed
opponent still plays tennis instead of reverting to a walk.

Two pipeline bugs found doing it, both fixed at the root in assets/:
- _fine.py keyed EVERY magenta pixel, not just the background, and the
  generator paints racket strings in the backdrop colour more often than not —
  so frame after frame installed a man holding an empty hoop. Background is now
  the region connected to the border; magenta left INSIDE a body is repainted
  pale grey rather than punched out.
- the flood fill silently did nothing, because Image.fromarray images are
  read-only and PIL's floodfill swallows the write error. .copy() plus a real
  error if it ever fills zero pixels.

THE LADDER (handoff slice 2). Rung N takes design N's name off the chapter
roster — the club's dead staff play you at tennis here and come for you in
gore, same joke twice — plus a personality that is DIALS, not new mechanics:
pace, width, flub and speed scalings multiplying numbers racketAI already
reads. A rung is a data row. Name plate on the walk-on, and the kill files
under the rung rather than under the generic body.

THE STATS SCREEN follows the mode split: BODY COUNT / THE LADDER / THE RECORD
behind a mode strip, each with its own three figures, its own roster heading
and its own footer. The weapon shelf and the roster hide in the modes that
have neither, rather than printing a line about something the mode cannot do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 09:34:26 +02:00
wuwiandClaude Opus 5 c89d4a3ff9 Three modes, picked up front — and CLASSIC TENNIS is one of them
The setup screen picks WHAT before WHO and WHERE. Three rows out of a table,
in the court row's own markup:

CLASSIC TENNIS is not the opener with the turn switched off and left there —
it is the mode the other two are built on, so it gets real match scoring: six
games with a two-game lead is a set, best of TUNE.classicSets is the match,
the board shows the sets column (hidden until now outside splatter) and the
bar under it says which set this is. The CPU stops rubber-banding and flubs
less: aiFlubLoss exists to get a new player PAST the opener, and a mode whose
whole promise is a real match cannot be won by an opponent that falls apart
the moment he goes ahead. No tiebreak — 6-6 plays on to 8-6. Marked ponytail.

That needed a second ENDING, the one with no corpse in it: G.done, the same
summary and gated tap as the death screen, its own words (GAME, SET, MATCH. /
BEATEN.) and the man still standing. The three endings now share one block
rather than three copies of it.

Caught by the first classic test: stats banked only the FIRST classic run of a
session. `banked` was cleared by the next KILL, and a classic match has no
kills in it at all — every later match was swallowed by the double-bank guard.
beginRun() is the honest reset and restartRun calls it.

ZOMBIE TENNIS from the menu is one line; the back room keeps its door.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 09:27:49 +02:00
wuwiandClaude Opus 5 66fdad3d74 A match run is a different kind of run, and stops polluting the gore stats
The two modes share no currency: gore counts kills, multi-kills and weapons;
zombie tennis counts opponents felled and has exactly one kill per opponent,
always credited to 'bomb' (matchGameWon's explode). Every one of those was
landing in splatterRuns as a gore run, on the roster as THE CLUB PRO, and on
the KILLS world board — where a five-body match would post a row next to a
300-kill massacre and mean something else entirely by it.

One character on the record fixes all of it: `m`, absent meaning gore (which
is what every existing record is, so no migration). read() takes a mode and
so does endRun, which now compares you only against your own mode and refuses
a name prompt from a match.

The record's two headline slots carry each MODE's two headline numbers — gore
g=games s=sets, match g=felled s=games — so `g` stays the thing everything
ranks on and the match best is derived from the record instead of living in a
second key that drifts. That retires the matchBest key from the last commit.

Found on the way, pre-existing and visible: endRun returned `{...best, kills,
games}` and the later key won, so G.best.games was the run that had just died.
The gore death screen has been printing "BEST <this run> GAMES · <the record>
SETS". The return now carries the best and nothing else.

And stats.check() — the module's own fixture check — was exported and called
by nothing. Wired into the suite, with the mode split added to it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 08:58:04 +02:00
wuwiandClaude Opus 5 8011186cda The blood you have left, the bodies behind you, and a face for staging
Three things, all in service of the match reading as its own mode:

BLOOD, COUNTED. The vignette says "worse" and never says "how many". One pip
per hit left, top-right beside the board, in the panel language the game/sets
bar already uses; the last two breathe. Pips rather than a bar on purpose —
the pool is 4 or 8, and a smooth readout of a small integer is the exact
ambiguity being fixed.

OPPONENTS FELLED IS THE MEASURE (handoff slice 1). Blood-only scoring means a
lost game costs nothing but the blood already spent, so games survived is not
the achievement. The in-play bar prints OPPONENT n · m FELLED instead of the
game/sets line, the death screen says BLED OUT / FELLED n OPPONENTS · m GAMES,
and it keeps its own matchBest — drillBest's pattern. The kills-to-next-point
pips are hidden in a match: nothing there is killed for points, and they were
colliding with the new line anyway.

STAGING WEARS A DIFFERENT ICON. Both builds live on the same phone and two
identical bloody tennis balls is a coin toss. Driven by HOSTNAME, not by the
branch — the block is identical on staging and main, so it never conflicts on
a merge and never has to be remembered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 08:53:02 +02:00
wuwiandClaude Opus 5 78292a2d53 First recorded game win, and the rungs flatten
Fourth recorded session, verdict "better": he beat opponent one — the first
game won in any recorded session — and reached opponent two, which took him
apart with gaps of 1.66-2.13m (60Hz, accurate) while he swung at closest
approach every time. The gradient is real; the step was a wall, because a
rung moves three dials at once. Per-rung increments softened: matchSpeedPer
.12->.08, matchWidePer .4->.25, matchOppPacePer .05->.035. Opponent one
untouched — the bases stay, only the slope flattens.

The contact chain is now verified end to end by recorded play: legs
(matchAssist) -> destination (assistK in intercept + the anyHit short-ball
chase) -> reach (matchReach). Each link was a separate defect found by a
separate recorded session.

49 tests, seeds clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 23:43:57 +02:00