A Capacitor iOS project (`npm run ios`), and the first three bullets of LATER item 2 built. The web
build is untouched and stays primary: every line here degrades to a no-op in a browser.
- HAPTICS ride the sound map, as the plan said they would: six call sites, all of them places an
sfx already fires — your racket contact, the serve strike, `killZombie` (the one funnel every
weapon routes through), `explode`, `hurtPlayer`, `killPlayer`, the weapon swap. The restraint
rule lives in `haptics.js`, not at the call sites: a minimum gap per kind, so a bomb that kills
twelve bodies is felt twice and the machine gun is a metronome rather than one long rumble.
Pinned by a test. Untested on hardware — the simulator has no Taptic Engine.
- The plugin is reached through the INJECTED bridge, `Capacitor.nativePromise('Haptics', ...)`.
`Capacitor.Plugins.Haptics` reads like the zero-dependency answer and is silently empty without
@capacitor/core's registerPlugin — it would have fallen through to the web path inside the app
and looked exactly like a phone with haptics switched off. No package JS ships.
- PORTRAIT is enforced where it can be: one orientation in Info.plist, so the app never rotates.
That retires landscape as a surface rather than fixing it (OPEN 6). The web half is best-effort —
`orientation` in both manifests and one guarded `screen.orientation.lock()`.
- SWIPE LEFT GOES BACK. The gesture finds the screen's one visible chevron and clicks it, so the
shape of the wizard stays in the handlers that already own it. Not passive on purpose: a swipe
inside a poster card also fires that card's click, and preventDefault is what stops the screen
answering forward in the frame the chevron walked it back.
- The app has no server behind it, so `config.API` and one branch in `net.js` point the shell at the
live host by name. Consequence: the app always talks to PROD, never staging.
`node test.js` green (105) over four seeds; builds, launches and reaches the native plugin on the
iOS 26.5 simulator; swipe-back walks the whole wizard in the browser pane.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A tour is which body you play in, so it belongs in the look message next to the
kit, the skin and the name. Without it both phones drew the friend in whatever
tour THIS phone had picked, and a men's/women's pairing showed each player
someone their friend was not using.
`oTour()` is the one accessor — yours everywhere, theirs in netplay — and it
feeds the three things that must agree about a body: the sprite suffix, how the
art takes a colour (her whites wash, his blue dyes) and the palm the idle ball
bounces under. An older build sends no tour and falls back to ours, which is
what it did before.
The palm knobs were re-checked against the new front sheets rather than assumed
— the poses were copied, so the ball still lands under the drawn hand.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
His colour landed on the shirt while yours lands on the shorts, because he was
drawn on the roster opponent's red sheets and dyed on an arc of his own. Red is
the one hue that art cannot hang a kit on: the men's shorts are neutral grey at
saturation 0.05, so no rotation ever reached them, and the women's red top sits
at her skin's and her hair's hue — a dark pick was painting her arms and her
ponytail, live, on the shipped build.
So he gets his own sheets. `assets/_frontroll.py` is `_famroll`'s trick pointed
at the tennis poses — image 1 is the ten `o_*` frames composed from disk, image 2
is the player from behind — and two calls drew all twenty. He rides the PLAYER's
arcs and the tour's own mode now: his shorts dye, her whites wash. `TINT.oKit` is
gone. The roster CPU keeps the red art; it is not a person.
The setup screen shows that front too — picking a face off the back of a head was
only ever what the art allowed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
'No that's way too difficult now - I am dying constantly of out. revert
it.' aimAnchor is gone — the lane is absolute again, every out invariant
back exactly as tuned. What survives is the answer to his follow-up: the
ball sometimes flew a LITTLE RIGHT out of a left flick, because a
mistimed left flick's target sits only slightly left of CENTRE — which
is right of a player standing in the left service box. The absolute
lane's blind spot, stated the other way round.
The aimed target is now clamped to the player's own x on the flicked
side, bounded at flickWidth so a body parked in the alley clamps to the
corner rather than conjuring an out. From the left box a left flick goes
at least down your own line, from centre it can no longer cross centre,
a perfect corner flick and the crosscourt sweep are untouched, and the
outward scatter tail keeps the whole tuned out-risk. Verified in his
scene: ten rushed weak left flicks land -2.0..-2.7 where the same case
used to target -0.7..-1.3 (a visibly rightward flight); the clean full
flick still lands -3.92.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Third round of the flick report, and this was the core all along: 'when
the opponent and the player are both standing in the left service box it
is virtually impossible to flick the ball into the left out. no matter
how hard/good I flick.' The lane was absolute — a full flick asked for
flickWidth off the court's CENTRE — so the gesture saturated near a
sideline: from x -2 a perfect left flick moved the ball under 2 m and
could never pass the line, and the better the timing the firmer the cap,
because scatter was the only thing that could cross it. 'Left is
impossible, right works' was never left-vs-right: it was
toward-the-near-sideline vs across-the-court.
TUNE.aimAnchor: the anchor slides from the court centre to the player's
own x as the swipe commits (ch.x * |sway| * aimAnchor), so a flat tap is
exactly what it always was and a full flick is a full sweep from where
you stand — including past the line. Out-by-aim is a choice now, per
Oliver's explicit direction; the placement ring already turns red for
it. 0 restores the absolute lane, blendable live in the panel. Player
rally lanes only; the opponent's lanes and the centre-stated pOut
invariants are untouched. Verified in his exact scene: full left flick
from the left box lands -5 (the alley), half swipe -3.07, full right
+1.93.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Second round on the service-line flick report. The crossing-fired swing
alone did not move the feel, and Oliver's sharpened observation — the
flick only lands left when the opponent already stands left — pointed at
a magnet that does not exist: measured with the body teleported at
contact, the aim never tracks it. The real mechanism: at rushed quality
the symmetric scatter (aimError x (1-q)) outweighs the quality-shrunk
aim, so a mistimed left flick landed anywhere in a ~4 m blob around
'slightly left' — usually centre — and the eye anchors that on the
opponent. Its position was a proxy for preparation time all along.
TUNE.aimTrust: an outward scatter roll is untouched — the committed
swipe's chance of sailing keeps its exact tuned shape, flickReach stays
the only out dial — but an inward roll keeps only 0.3 of itself. Bad
timing costs width, never direction. Player lane shots only; the
opponent's symmetric scatter is its width mechanic. Verified at
worst-case q~0.08: 14/14 rushed left flicks landed left, 9/9 right
flicks right, where before it was a coin toss. The suite pins the
direction guarantee and the aimTrust/aimError/flickReach relationship.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Oliver's service-line report: flicking the incoming ball LEFT was near
impossible mid-court at the service line while RIGHT worked, easing toward
the baseline. The lane math was symmetric to the centimetre — the defect
was latency. The swing waited for the RELEASE, so the whole gesture ran as
dead time on top of the windup: ~120 ms of lateness drops contact quality
1.0 -> 0.08, where flickReach halves the width and aimError drowns the
rest. A left swipe is a right thumb's slowest direction, the service line
leaves ~700-870 ms of budget (measured), and any swipe over FLICK_MS was
silently dead. Left + service line was the slowest gesture meeting the
shortest budget.
Now, in rally modes: crossing flickMin fires the swing (once per gesture,
rally phase only) and the vector stays LIVE — trySwing reads G.swipe at
contact, with the released flickAim carrying the aim only when the ball
arrives after the finger lifts. The FLICK_MS deadline goes with it where
the crossing can fire; it survives where it cannot (gore free-aim, the
serve strike). Serve gesture, duel, gore and keys untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The header block in tools/scenes.json is now the whole schema — every key with
its range and default, what each of the EIGHT weapons does (fire, laser and gun
were missing from both the docs and the validator), the five beats, the crowd
roster diversity walks in order, the bosses by chapter, and the traps worth
knowing: a serve tin is spent in about four seconds of autopilot fire, a burst
tin owns its own window, a horde locks the rack, a boss cannot start inside a
match, and `movement` fights the match's own auto-run legs.
Two scenes added from the campaign's own wish list: `epidemic` (the fire ball
lighting a whole wave off the body it hit) and `champion` (chapter ten, the
night court). Both verified on film.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tools/scenes.json says WHAT to shoot; tools/clips.js only knows how. Every
knob Oliver asked for is a key: seconds, setting (gore/zombie), beat, court,
boss, weapons, movement, density, diversity, gore, games, screen — documented
in the file's own "_" block, validated in capture.py so a typo names itself
instead of coming back as fifteen seconds of empty court. Nine scenes ship in
the default list, across all four courts.
The frame is a PHONE now (393x852 -> 1080x2340), not 9:16: the portrait branch
derives its logical height from the canvas box, so a 9:16 take was never the
framing a player sees. The player has legs — held arrow keys through the real
axisX, so horde and flamer are no longer a statue with a flamethrower. The
crowd tops itself up where the game's own spawner puts it, diversity walks the
roster, and `gore` paints in the run's history a fifteen-second clip has no
time to earn. The pilot is picked by what the court is doing rather than by
config: tennis mode taps to swing, splatter draws the slingshot.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`python3 tools/capture.py` fills promo/clips/ with 1080x1920 h264/aac takes of
the moments the campaign plan (item 4) already named — transition, horde,
flamer, mopboy, death on the gore side, boss and match on the zombie-tennis
side. capture.py serves the repo with tools/clips.js injected, opens a
throwaway-profile Chrome and converts each POSTed webm with ffmpeg; clips.js
stages every scene through the real doors on window.T and drives it with the
reachability soak's own mediocre autopilot. Nothing is faked for the camera.
The game gains four names on window.T (start, intoSplatter, startHorde,
sendBoy) and nothing else: the frame is pinned by the canvas box rather than
the window, RES 4 comes from lowPower 0 plus device-scale-factor 3, and the
audio is tapped by making BaseAudioContext's `destination` a gain node — so
audio.js needs no master bus for a tool that ships to nobody. tools/ and
promo/clips/ are ignored by git and docker alike.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Hidden behind TUNE.aimCloud rather than deleted: the reading it gives — how
much width the swipe is buying, before the ball leaves — is the only teacher
the gesture has, and the panel can flip it back live for the next look at it.
And "a little harder to hit the out" is a number: flickReach 0.62 -> 0.52,
which halves the worst-case rate from about 15% to about 7%, with the test
tightened so it cannot drift back. The shape is untouched — a perfect flick
still never sails, a badly timed full swipe still can — but a mistimed one now
lands nearer the middle than the line, which is the only half of that trade a
player who cannot see his own timing can do anything about.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The live flick readout was a ring on a ray from the player's feet — a HUD
element laid across the court, and read as one: "pretty bare bones... the line
must go away, it is distracting". It is a soft cloud on the ground now, in the
register the dust and the blood already use: it sits where the ball is being
asked to land, grows with the length of the swipe, and warms from pale lime
through gold to red as that ask approaches the width no timing can keep in. The
threshold stopped being a flip, so too hard is something you watch coming.
Three things it took to make it read, kept in the comments so the next pass
does not re-learn them: it is sized in METRES off the projection, or it is an
eight-pixel smudge at the far corner where it actually lives; it is a stain and
not an additive glow, which turned the red end yellow over green grass; and the
ramp starts pale rather than green, because green on green showed nothing at
all across the whole safe half of the range.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
On touch, a drag in zombie tennis and classic still walked the player — and
that was not a harmless second option. A manual axis suppresses the assist, so
holding a drag traded a full-speed run to the intercept point for a 0.35x
shuffle aimed wherever the thumb went instead of where the ball will be.
Walking was strictly worse than standing still, which is most of "i tend to not
be able to reach the balls".
touchAxis is no longer written under rallyFeel. The drag keeps its one rally
meaning — released quickly it is the aimed swing — keys stay a deliberate
desktop override, and gore keeps its drag-walk, where the assist recovers depth
only and holding your own lane is the entire point.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The rally flick was read as three lanes: sideways past 0.38 of its own length
took the whole corner, a hair under it took dead centre, and nothing lived in
between — "sometimes applied very strong, sometimes it barely does anything",
with no way to tell which one you had asked for. swayOf reads the swipe's LENGTH
as a continuous strength, exactly as the serve already did, and doHit spends
that instead of the lane vote. Keys keep the full lane; the opponent's aim has
no length and is untouched.
That is also what hands the OUT back: going out was never a choice, because
every aimed shot took the full width and then the scatter on top. Below about
0.7 of a full swipe — the line minus aimError, over the width timing can earn —
the worst mistiming in the game cannot sail it now, and above it the old risk
curve is exactly as tested.
And the aim is drawn while the thumb is still moving: the placement ring is a
receipt that arrives with the ball already gone, so the same ring is shown live
with a ray from the player's feet, cream while the ask is safe and hot once the
sway crosses that derived threshold. The receipt goes red when the shot really
is out, so the two rings together are the lesson.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
HANDOFF-MATCH.md was always temporary — written when a session ran out of
context mid-slice, and carrying its own instruction to fold into PROJECT.md and
be deleted once the slice was done. The slice is done, so it is.
What crossed over is the contract, not the diary: what the mode is, the
blood-only scoring and the misreading that forced the blood readout, the four
ladder dials, the card of ten, the wound dials, the fall's frame contract, the
flick arithmetic and its lesson, the umpire and the boy, the opponent's serve
games, classic as its own mode, the record shape, the traps, and the do-nots
(now including the scratched body-shot thrower). The reasoning behind the
numbers already lived in the drill/match session logs and was not copied twice.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The flick lesson rode the incoming ball, over the player's shoulder — the one
moment he has no attention to spare for reading anything. It moves into the
room this game already keeps for teaching gestures: the serve stance, standing
still, mimed on the apron by the very demo the slingshot lesson plays. Same
clock, same hand, same words slot — one teacher, three lessons, and nothing new
drawn over a live rally.
The words carry both halves now, SWIPE AS YOU HIT TO AIM: WHEN alone teaches
nothing, and WHAT FOR alone gets tried at the wrong moment and read as a
gesture that does not work.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The gate said `G.mode === 'tennis' || G.match`, and mode 'tennis' is two
different things: classic, and the single prologue game a gore run plays before
the court turns. Placement is not the skill that prologue is asking for.
rallyFeel() already draws exactly the line wanted — classic and zombie tennis,
the two modes that ARE rallies — so the hint rides that instead of a mode
string. Verified both ways on a phone-sized preview: the lesson beside the
player in zombie tennis, and gone under gore-opener conditions with everything
else identical.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Score-is-the-body was a picture: the opponent lost an arm and swung exactly as
far, lost its head and missed exactly as often. Two dials now read the same
racketStage the SKINS are picked with, so what you watch it lose is what it
loses — deliberately small (6% reach, 17.5% whiff per stage), texture under the
ladder rather than a second difficulty curve, with a test on that ceiling. An
unhurt body still returns exactly 1 and 1, and the whole thing is gated on
G.match: the gore duel's racket zombie is untouched.
And the placement flick, the mode's one act of skill, was taught nowhere on
touch — the placement ring is a receipt, not a lesson, and you only get one if
you already knew to swipe. The ghost hand teaches it now, in the same register
as PULL DOWN and TAP AS IT ARRIVES, riding the failure it answers: a run of
returns that left with no aim on them. The tap lesson still wins when both
qualify — placement is the wrong thing to teach someone who cannot reach the
ball.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
index.html conflicted only on the cache-buster: staging was at 276 and the
branch at 275, so the merge takes 277 — a merge that reuses either number
leaves phones on the old build.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The slid-serve test asked for 6 of 10 sliced serves returned. Measured over 200
trials a seed across eight seeds, the receiver's real rate is 0.85..0.92 — so
the assertion was sound and the SAMPLE was too small: at p ~= 0.88, 10 trials
land at or below 5 with probability ~0.004, one seed in 230, and SEED=80 was it.
n is now 40 at the same 60% bar, which puts the threshold five standard
deviations below the true rate while still failing instantly if the receiver
regresses toward the 0-in-24 the test was born from. It is free: a returned
serve breaks the frame loop early, and the suite still runs in ~0.11 s.
Green on seeds 1..400.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
His family sheet was rolled against a description that disagreed with every picture
of him — "stained work overalls and a shapeless hat" against a green club blazer and
braces — so the 31 frames averaged the two and came out a third character. The card
portrait and the boss on court have been different men ever since.
He now anchors on his own throw frame redrawn at the grain the other bosses ship at
(density 0.34 against the legacy 0.21, tracked as _ref_gk_hi so the roll stays
reproducible), and the description names what the art actually shows. The hopper
basket is called out as gone: a prop at head height is the one thing a 31-figure
sheet cannot hold.
Rolled through _famroll, cut with _zsheet. Density 0.26 -> 0.36 across the family.
First roll came back with a lilac panel over the top rows and was thrown away — the
cutter keys the corner, so that panel would have shipped as opaque background.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Six head icons recut by hand; the new cuts are square, and a 1:1 portrait beside a
long punchline pushed the head against the left edge while the line itself ran off
both. The sub lines now wrap to the same room the title already shrank to fit, and
the plate grows with them — one line each still lands on 30/44, so every card that
fit is pixel-identical.
THE BULK is THE BRUTE: the gore mode's stats screen has called that body THE BRUTE
since it dragged a pole, and one character may not carry two names.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The drill spawner measured its best-of-9 lane spacing only against bodies still
in the back half (drillNearZ 9 m). On a court where lanes are HELD for life and
speed is stamped at spawn, a body 12 m down still owns its lane — handing it to
a faster arrival books an overtake for ten seconds later, mid-court, as two
sprites walking through each other. That is what the seed-sensitive test failure
(SEED=1234) was reporting: the assertion was right, the stream was not.
Spacing now measures against every live drill body; drillNearZ is deleted (no
other reader). Still best-of-9, no retry loop, so the stream cannot stall.
60 seeds x 150 s of drill: before, 7 seeds overlapped with narrowest clearance
0.00 m; after, zero overlap frames, narrowest 0.46 m (the test asks > 0.3).
Suite green on 20260811/1234/777/99/424242/8 and on 299 of seeds 1..300; the
straggler (SEED=80, the slid-serve return rate) is unrelated and logged as open.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The trick: hard serve, slight slide, and the first ten opponents died where
they stood. Measured headless — that serve was returned 0 times out of 24 at
EVERY rung. Three things were wrong and all three were the same thing: he
waited on the tennis mark 1.3 m off the centre line (covering the T, conceding
the whole corner), he took no first step, and he chased the ball's LIVE
position, which during a serve is still up by the server's baseline.
Now: he waits on the bisector of the serves he has to cover, takes a
returner's first step (serve-scoped — the rally is untouched), and runs at the
bounce instead of at the ball. The slid serve comes back from rung one; a
flush serve into the very corner of the box still aces the bottom of the
ladder, which is the reward placement is supposed to earn.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Six sessions of asking an image model for a row of running figures, and it
never once drew a gait: three phases repeated with the same leg leading,
"ropeskipping, not running". A frame-by-frame LEFT/RIGHT skeleton spec, a
closing checklist, a loop clause, six figures instead of eight — all lost.
One 5-second image-to-video clip has no such problem, because it moves a
body instead of drawing poses. ~$0.42 a character through the key already in
.env, pose pinned at BOTH ends (pinned only at the front, she rotated into
profile halfway), stride period MEASURED off the frames rather than sampled
by eye — hers repeats in 6 clip frames, his in 23 — and the phases cut and
palette-snapped through _stride.cut. assets/_vidroll.py films and cuts it;
her first frame came from one still roll that took his approved pose as a
picture instead of a paragraph.
Wired: runN probes the sheet through SK() so the women's tour counts HER
frames, the player is no longer clamped to a two-frame pair, runHz 2.4 ->
1.9 ("this looks like roadrunner"), and the bench grew two PLAYER stops —
a cycle only ever seen mid-rally is a cycle nobody has watched.
Also in: the difficulty retune (medium is a real step above the shipped CPU
and is where `smart` first appears; hard stepped back off superhuman; easy
closed its gap) with MEDIUM's dial-for-dial pin to TUNE removed from the
test, and Oliver's own transparency fixes on the male frames.
SPRV 138 — the frames were right on disk and wrong on screen for an hour
because assets are cached for a year unless the ?v= moves. main.js v269.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A URL only opens the installed app on Android; on an iPhone it opened a second
copy of the game in Safari, with its own storage and none of the name picked in
the app. So the share sheet now sends "JOIN ME IN SPLATTER TENNIS — THE CODE IS
ABCD" and nothing else, and the button says SEND THE CODE.
The #net-ABCD door stays — it is exactly what a deep link will carry the day
this is a real app with universal links. Nothing sends it in the meantime.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An Android phone opens the link in the installed app (the manifest's scope
covers the site, so its WebAPK claims these URLs). An iOS home-screen app can
claim nothing, so the link always opens in Safari — which keeps its own storage
and knows nothing about the name picked inside the app. Four letters in the same
message is the whole fix: a friend who has it installed types them instead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Multiplayer setup moves out of a canvas modal and a prompt() dialog into one
menu screen: your name, the two ways to host, and the friend's code. The socket
opens from the menu and the game stays parked behind the overlay until the room
fills — netHooks.paired takes the court one line before netStart writes to it —
so the room code can carry a real SHARE button and a wrong code is a typo you
fix rather than a CONNECTION LOST card over a match that never began.
The name is the world board's name (stats.saveName), crosses on the existing
look message, and the scoreboard finally names the person instead of CPU.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Oliver, on the picker: the gender tiles "themselves have the player but front
facing and that does not react to the kit and skin — so that's double somehow
and wastes space", the screen "is pretty crowded now", and the women's wash was
"a little too subtle on the white — could be more".
THE TILES ARE GONE. They were a second drawing of the same man, facing the wrong
way and deaf to every colour on the screen, and they cost the height that pushed
the court list under the fold. Who-you-are is two words now, and the live player
— which does react — stands beside every control that changes him, so a tap and
its result are a thumb apart. Four stacked sections became one block; all four
courts and SERVE UP fit a phone without scrolling.
THE WASH goes 0.30/0.93 -> 0.55/0.86: dyed from across the court, folds intact.
Past about 0.70 it stops reading as tennis whites at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three things off the back of the picker.
THE LIVE PLAYER on the setup screen is the real sprite through the real tint, not
a mock-up of the pick — so the menu cannot promise a colour the court will not
keep. It redraws on every tap of a tour, a kit or a skin.
THE WOMEN'S TOUR IS NO LONGER SAT OUT. Her set is drawn head to toe in whites, so
there was no hue to rotate — the answer is not a dye but a WASH: the pick laid
over her whites at a low saturation with the lightness kept, which is how you dye
a white kit in life and gives rose, powder blue, mint, lilac. Her hair and skin
fall out of it on lightness alone, which is exactly what a dye could not manage.
THE FRIEND ACROSS THE NET now wears what they picked on their own phone. Their
look crosses on pairing, and since the opponent art is red rather than blue the
kit rides its own hue arc — safe there for the one reason it would not be on the
player's own sheets: the opponent sprites carry no drawn blood. An older build
simply never sends it and the opponent draws as before; the match still plays.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Oliver's hunch was right: the tap unlocks audio and starts a 768 KB fetch, but
the source starts seconds later inside a promise with no gesture near it. iOS
suspends an idle context in that gap, and a source started into a suspended
context is silent for good — nothing on the splash ever asks for audio again.
Resume once more when the buffer lands, which the first tap has already
authorised, and again when the app comes back to the foreground.
Intermittent because it only bites when the decode outlasts the browser's
patience: a phone, a cold cache, a slow radio.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Testers asked for some control over how their player looks. From behind — the
only way you ever see him — there are two things worth controlling, and neither
needs a single new asset: the kit and the skin are one hue-bucket pixel pass over
the sheets already loaded, cached per frame and built on first draw.
The buckets are measured, not guessed, and three neighbours had to be fenced off
one at a time: the shirt's warm HIGHLIGHTS (which spattered the man with mud on
every dark pick), the drawn BLOOD on the death frames (which turned the pool
brown — the worst bug this file could ship), and the women's HAIR, which is the
same hue as his shorts and cannot be told apart from it by any rule. Her set is
played in whites and has no kit to dye, so it is flagged out and the row says so
rather than silently doing nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every run starts the ambience, and the ambience was un-ducking both court beds
at once — so once you had played the gore mode, its music followed you into
classic tennis for the rest of the session. Leaving the court is one event;
arriving is not. Each bed brings itself back and nothing else.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Splatter gore no longer makes you win a game of tennis first — the mode you
pick is the mode you get, with the man you came to play already turned. The
transition itself is untouched and still reachable; it just isn't a toll booth.
The menu theme could only ever be started, never brought back, so the first
run of a session silenced it for good while the court's ambience followed you
into the menu. Starting a bed now un-ducks it too.
Three world boards instead of one: kills, opponents felled, and games taken
off the CPU, each with its own name prompt and its own row per player.
...and the gore economy stops paying a barrel for every game won past game 16,
while the shamblers, the mix and the ball machines keep escalating past the
point they all used to stop.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Six new opponent families (head coach, stringer, strawberry seller, chair
umpire, spectator, roller man) through the famroll/zsheet route, placed by
hierarchy into an 18-rung ladder; the champion steps up behind them.
THE DRAW: picking the tournament now rolls its own bracket in front of you —
only the final was ever settled — and what lands is what walks on.
Netplay, off Oliver's cellular report: the mid-flight early-decide was missing
its !G.net gate, so the striker judged its own outbound flight and the two
scoreboards drifted a point apart into a court nobody could serve on. The
host's court now rides the room, its scoreboard rides the pretoss stream, and
a called ball rolls to its own halt instead of freezing where the call caught it.
Plus the night court, and somebody in the stand is taking pictures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two retest findings, one root: the death match borrowed the roster's
body. Gone — nothing spawns, no walk-on card, the living opponent is the
whole picture. His damage reads as blood (bleedO: an anchor per point
taken this game, love to game point, thin drops as ever), the game
point bursts him where he stands, and IT RISES AGAIN brings him back
clean. The heal beat became a card in netplay — a modal freezes this
world while the other phone's keeps moving.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three chips under VS A FRIEND — the host's pick rides the room and the
start message rebuilds both courts on pairing. In the death match your
opponent IS the zombie: their lost points tear its wound stages, their
won points draw your blood, and bleeding out crosses the wire as the
other phone's win. The same receiver-is-judge verdicts drive the match
machinery through resolveInterlude, so the fall, the mop beat and the
burst come along unmodified. New: gushProne — the game-winning body
bleeds out on the ground, from prone-height anchors, until it bursts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Regenerated off the classic poster instead of the zombie one — sunset
oranges into violet, warm bulbs, neon rim light on the two players. Each
mode row now owns a palette: day, night green, blood red, golden hour.
SPRV 125 so cached phones refetch it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One event on the toss, replayed as a real toss over O — update() already
poses the server off the phase and drops an uncaught ball back to pretoss
by plain physics, so the wind-up, the cancel and the re-toss all carry for
free. The strike's shot event overwrites whatever drifted.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
VS A FRIEND on the mode screen (host a code, join a code), a ~110-line
RFC6455 relay in server.py that holds no game state, and src/net.js: each
phone plays itself near-side and the opponent's court is this one rotated
half a turn about the net. The phone a ball flies toward makes every call
on it; the striker's copy is picture. Six tests pin the rule from both
seats. No reconnect yet — a dropped radio is a card and a reload.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two live verdicts off the bench. The 1px run bob was a fixed 18 Hz wall-clock
toggle — over the calm two-frame cycle it read as independent HOPPING — so it
now shares the exact phase counter that flips the frames: one dip per step,
always in sync at any cadence. And with bob and feet locked together the
cadence itself came up for retuning: 1.7 read as a jog, doubled to 3.4 it is
a run — "that's it! way better." One knob moves both, which is what made the
doubling a one-word change.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The live bench session's verdict, in Oliver's words: "this looks better than
anything before." Six and eight real phases at speed read as flicker on a
far-court body — the zombies never had this problem because their walks are
FOUR well-chosen frames at a calm rate, and the fix was to stop out-drawing
them. Each opponent now runs a TWO-frame true leg-swap pair — his frames 1+2
of the grid roll, hers 7+8 of the face-transplant cycle, both picked live on
the bench with SPR swaps — at TUNE.runHz2 = 1.7 (the old hardcoded 4.5 for
short cycles was most of a stride per display frame; that is the strobe).
The tuning rig ships too: TUNE.runN (frame-count override, 0 = probe),
T.SPR for live frame swaps, and the bench's living slots — the next cadence
question gets answered by watching, not rolling. Twelve run sprites become
four; the SPR list says why. SPRV 124, main.js v248.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Oliver: "add a normal player male and female to the hidden bench and let it
run there." The fam walk (up/down) now runs two slots past ZFAMS — PLAYER
MALE and PLAYER FEMALE. In a living slot the racket zombie steps outside the
frame, G.hideO lifts (splatter set it when the match turned), and the real O
runs the walk stop's own back-and-forth at the classic CPU's speed class, so
the cadence on the bench is the cadence the game plays. SK() reads the slot
for the _f suffix, so both tours review without touching the picked tour.
The HUD swaps the wound readout for the tuning one: runHz and aiSpeed, the
dials a live session turns through T.TUNE. T also gains a keyPressed alias.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The female run took the whole evening because the model would not draw her
RUNNING: roll after roll came back with the same leg forward in every frame
— amplitude wiggle, no gait ("she never changes her leg... floating while
strobing"). The set that ships is the FACE TRANSPLANT: her old cycle's poses
(which always alternated properly — swaps at positions 2 and 6, 78%/74%,
where a runner's legs actually cross) redrawn with only the friendly face
and open beds changed. The model returned that roll on a painted CHECKERBOARD
— it has no concept of transparency, the word alone summons the pattern —
and the figures were custom-keyed off it: light checker blanket, dark
checker border-flood only (the dark cell is nearly her skirt colour; the
first blanket pass shredded the pleats), verified whole over a light ground.
Oliver gated every strip by eye. The alternatives lost on their own terms:
hex-only prompts obeyed the background but drew her dainty (max 44%, no
swap); the explicit leg-swap clause got alternation but clustered it in the
back half (step-step-skip). His pick: the transplant.
The lesson is in PROJECT.md now, next to the bed flag: NEVER say
"transparent" to the model — name one flat hex — and never freehand
background prose in one-off scripts; import _regen.py's CANVAS block, which
has said it correctly all along. SPRV 123, main.js v246, all 14 run frames
verified 200.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Oliver's verdict on the friendly cycles: wrong on two axes. The new rolls
were PURE PROFILE — "what we actually need is running slightly towards or
away from the player... this was better before although the art was not
coherent" — and the racket re-imagined itself every frame: "it's now jitter
town, strobe like because of the rapidly changing rackets."
Take two runs on TWO references, which is the recipe that has worked all
day: image 1 the friendly identity (anchor frames), image 2 the OLD run row
pulled from git history as the CAMERA law — three-quarter front, chest to
the viewer, exactly the orientation the far baseline needs — with the prompt
pinning the racket ("as if copy-pasted between figures; only the LEGS run").
One-row layouts kept coming back interlocked (nine figures, one 3141px blob,
uncuttable), so the ask became a 2x4 GRID — separation the model actually
delivers, re-laid locally. His roll drew 12 clean figures; the smoothest
8-cycle was chosen by maximising the weakest adjacent stride: 72.7%, against
20-30% on every earlier roll. Hers landed exactly 8. Oliver gated both
strips by eye before install — the automated gates sat this one out on his
call. SPRV 122, main.js v245, all 16 frames verified 200.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One paragraph in the asset pipeline so the next fresh session does not
rediscover it: prompts ask for magenta beds ON PURPOSE, _fine.key's grey
repaint defeats them unless bed=True is passed, and the solid white paddles
were exactly that interaction.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Oliver's batch, delivered in dependency order. "The opponents look pretty
harsh, almost already pre-zombie" — so BOTH tennis opponents got a new
identity: warm skin, human eyes, an easy near-smile, rolled anchor-first so
every later sheet inherits the face. The male's bounce pair also ADOPTS the
female's palm-out posture (his old crouched receiver stance was the wrong
pose for bouncing a ball — her better one is now the shared convention), and
the pre-serve ball was re-measured onto the drawn palms: BOUNCE_IDLE's oDx
0.45 -> 0.24 / oApex 0.55 -> 0.72 for his close carry, with her wider reach
riding TOURS (oDx 0.48 / oApex 0.73) the same way the player's pair already
does.
"parts of the body as well as the inner of the racket are solid" — the beds
were the cutter's own doing: _fine.key repaints enclosed magenta pale grey
(protection against accidental magenta strings), which defeated the beds we
now draw magenta ON PURPOSE. key()/main() grow a bed=True switch (the
famroll convention, used by _runroll and the refresh cuts); the run cycles'
beds went from ~70% to ~44% opaque — strings, not paddles. Enclosed pockets
of a roll's off-spec background (one sheet came back on blue) are scrubbed
by corner-colour distance too.
Both RUN CYCLES re-rolled off the friendly anchors: his landed 8 clean
frames (racket +0.05..+0.30, one side); hers took four rolls — the gates
caught a white background, a crossing racket, near-dup neighbours — and the
keeper drew a 10-frame cycle, from which the best 8 were chosen by
maximising the minimum adjacent stride (29.7%, smoother than his 22.7).
o_run7/8 return to the SPR list.
AND THE CHASE STOPS DITHERING (Oliver: "switching between a left and right
movement increasingly faster the nearer the ball comes"): intercept()
re-solves every frame and the earliest-reachable point jumps around its
solution as the flight shortens, flipping his run direction each time. The
CPU now commits to its stored target unless a fresh solve moves it by more
than 0.35 m. SPRV 121, main.js v244, 66 tests, every opponent frame verified
200 in the live game.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Her run cycle carried a DETACHED SHOE floating behind her in every frame —
a cut that kept two blobs per figure — spotted while fixing the men's
flicker. Four rolls to a keeper: roll 1 closed on a duplicate (8->1 at
4.4%, gated), roll 2 came back a grid, roll 3's grid was re-laid into one
row locally but its racket flipped sides frame to frame (-0.42..+0.31, the
exact "flashing left and right" the warning exists for), roll 4 landed:
eight frames, racket steady at +0.05..+0.17, one face, no luggage. SPRV 119.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
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>
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>
"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>
"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>
"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>
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>
"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>
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>
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>
"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>
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>
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>
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>
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>
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>
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>
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>
Once the player's winner is past the net its landing is closed-form knowable
(predictLanding is exact), so a ball that will bounce in beyond the body's
provable reach resolves while still in the air — the collapse starts as the
ball flies past, roughly half a second earlier than the bounce-1 call, which
stays as the fallback for anything too close to call. Same conservative
margins, same willFall gate, same decided-latch discipline.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>