Oliver's idea, tested: give the generator a WHOLE POSE SHEET and ask it to
redraw it as a different zombie. It works, and better than what I was doing —
the master set's stride pair is 0.99/0.157/0.565 and the restyled brute came
back 1.00/0.148/0.618. The pose relationship SURVIVED the character swap, in
one call, four frames.
It was reported as a failure only because the four brutes stand shoulder to
shoulder and their bellies overlap, so segmentation by empty columns saw ONE
blob and the sheet was discarded. That same rejection ate five rolls earlier
today — "found 2 frames, expected 3" — and the sheet it lost last was the best
roll of the day.
A row is evenly spaced by construction, because that is what we ask for. So
when the blob count comes up short of a count we ALREADY ASKED FOR, look near
each even division and cut at the thinnest column in that window: between two
figures the ink thins even where it does not vanish. Never used to guess how
many figures there are, only to reach the number requested — a wrong guess
would install garbage silently, and the frame count is the one thing this
pipeline has always been able to trust.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`_regen.py --audit` is clean for the first time:
racket_play z_r2/z_r3 1.00 0.157 0.565 ok
racket_w1 z_rw1/z_rw1b 1.00 0.112 0.424 ok
racket_w2 z_rw2/z_rw2b 1.00 0.077 0.372 ok
racket_f_play z_r2_f/z_r3_f 1.00 0.049 0.552 ok
racket_f_w1 z_rw1_f/…b_f 1.01 0.112 0.716 ok
racket_f_w2 z_rw2_f/…b_f 1.00 0.054 0.793 ok
Against the reference pairs (brute 1.00/0.000/0.638, shambler 1.00/0.070/0.520)
these are the real thing: one body, same camera, legs swapped. Fifteen rolls
were rejected getting here, every one for a named measurable reason — which is
the whole point of the gate: none of it shipped on luck.
Two calibration lessons, both from watching what the gate rejected:
BRIGHT is off for the racket batches. It counts near-white pixels and was
calibrated on the LIVING opponent, whose racket barely registers — these frames
hold a pale-strung racket FACE-ON and it swings 3%..20% purely with how the
racket is angled. It was rejecting rolls for racket geometry. Redness, shorts
brightness and shorts hue are the kit, and hue is the one that caught the drift
Oliver actually saw.
EACH WOUND STAGE IS HELD TO THE STAGE BEFORE IT, not to the healthy frame two
steps away. Stage 2 kept being rejected at hue -4.2 against the healthy -8.7
while the stage it actually follows sits at -5.0 — the roll was more consistent
with its own neighbour than the gate would allow. Chained references stop drift
compounding without demanding stage 2 match a frame the player never sees next
to it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Oliver: "look at the headless men — the trouser color changed." He was right and
every number in kit() said he was not. Brightness sat at 85 against the healthy
frame's 89, comfortably inside tolerance, while the shorts' blue-minus-red
walked from a warm charcoal -8.7 to a cold +0.2 across the wound stages. All
three existing measures are greyscale or red-channel; none of them can see a
grey going blue, which is most of what "the colour changed" ever means here.
Fourth number: the shorts' hue. It immediately fails the headless men AND finds
a second drift in the wounded women that the brightness test had passed.
The tolerance default went to four entries deliberately — kit_off zips got/ref/
tol, so a three-long tuple would have SKIPPED the new check rather than failed
it. That is the same shape of silent no-op as the read-only floodfill.
Also: DAMAGE NOW ACCUMULATES. Stage 2 said only "headless", so the arm torn off
at stage 1 came back — in a mode where the score IS the body, taking a point off
it made the body look healthier. Stage 2 is now headless AND still one-armed.
And the stride rule got its counterweight: "copy it exactly, only the legs
change" has a second failure mode where the model copies it exactly INCLUDING
the legs and returns the same picture twice (upper 0.017, legs 0.022 — a body
sliding along with frozen feet, the very thing being fixed). Both halves are
stated now: identical above the hips, obviously different below.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Oliver, on the sprites, again: "when people on a tennis court walk or run their
legs move... this pattern is almost always failing." It has failed repeatedly
because NOTHING IN THE PIPELINE MEASURED IT. Frame count, magenta background,
clipped edges and detail density all pass cleanly on two unrelated
illustrations of the same character, which is exactly what the generator
produces when asked for "the same frame with the other leg forward".
Three numbers per declared pair, silhouette only, on the feet-aligned centred
overlay drawStatic actually draws:
HGT height ratio — off 1.00 means the body grows and shrinks once per step
TOP difference ABOVE the hip — near zero for a real pair: same man, same camera
LEG difference BELOW it — this is the step
Calibrated on the pairs that read (brute wound lurch 1.00/0.000/0.638, shambler
1.00/0.070/0.520) against the ones that do not (TOP 0.47-0.85, or LEG 0.09).
Opt-in per batch via `stride: [[i, j]]`, because the player's four-frame RUN
sits at TOP 0.23 and is right — a run swings the arms too.
`--audit` runs the same gate over the sprites ALREADY INSTALLED and exits
non-zero on a failure: a gate that only fires on new rolls says nothing about
the art already in the game, which is where this was noticed. Five of the six
racket pairs fail it today.
And the kit gate, which already existed and was never switched on here: Oliver
spotted the headless men's trousers had changed colour. Measured — the headless
set's near-white fraction DOUBLES (9.5 -> 19.5) where losing a head only
accounts for ~1.3 of that, and the wounded swing drifted the other way (shorts
60.5 against its own stage's 85). palette_ref + a per-batch tolerance now catch
all three drifted sets and pass the one that looked right.
Fixed while in there, mine from the last commit: the wounded stride stepped off
walkT, a wall clock, while the healthy one steps off ground covered — the exact
foot-slide this project banned for the heavy bodies.
No art regenerated yet: the gate and the audit come first, so a re-roll can be
told from a lucky one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A second plate, drawn rather than tinted (assets/_board.py — _regen.py cannot
do this job: it keys magenta, crops to ink and rescales figures to a common
height, and a scoreboard is a full-bleed rectangle with no figure in it). The
club's bottle-green board is the club's; the match is played on one nobody has
cleaned in a long time, and the ink changes with it — a green 'dim' over dried
blood reads as a printing fault.
Two column changes go with it: the name slot says KEEPER / JUDGE / CHAMPION
instead of THEM (eight characters, `short` on the ladder rows), and the SETS
column is gone in the match, where it was a permanent 0 — the bar under the
board already carries that mode's progress.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
THE SPRITES. The match opponent borrowed the turned CPU's art — a body drawn
to SHAMBLE at the camera with its racket hanging at its side, which is what
was playing tennis points against you. It has its own family now, 'r', drawn
off the LIVING opponent's poses: a ready stance, a shuffle pair and a real
forehand, at every wound stage, men's and women's — 20 frames, six batches.
'c' keeps its shamble untouched, because in the gore mode shambling is exactly
what the turned CPU should be doing. The pose comes from racketAI's own
`pose`, so the picture cannot drift from the behaviour, and a one-armed
opponent still plays tennis instead of reverting to a walk.
Two pipeline bugs found doing it, both fixed at the root in assets/:
- _fine.py keyed EVERY magenta pixel, not just the background, and the
generator paints racket strings in the backdrop colour more often than not —
so frame after frame installed a man holding an empty hoop. Background is now
the region connected to the border; magenta left INSIDE a body is repainted
pale grey rather than punched out.
- the flood fill silently did nothing, because Image.fromarray images are
read-only and PIL's floodfill swallows the write error. .copy() plus a real
error if it ever fills zero pixels.
THE LADDER (handoff slice 2). Rung N takes design N's name off the chapter
roster — the club's dead staff play you at tennis here and come for you in
gore, same joke twice — plus a personality that is DIALS, not new mechanics:
pace, width, flub and speed scalings multiplying numbers racketAI already
reads. A rung is a data row. Name plate on the walk-on, and the kill files
under the rung rather than under the generic body.
THE STATS SCREEN follows the mode split: BODY COUNT / THE LADDER / THE RECORD
behind a mode strip, each with its own three figures, its own roster heading
and its own footer. The weapon shelf and the roster hide in the modes that
have neither, rather than printing a line about something the mode cannot do.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The setup screen picks WHAT before WHO and WHERE. Three rows out of a table,
in the court row's own markup:
CLASSIC TENNIS is not the opener with the turn switched off and left there —
it is the mode the other two are built on, so it gets real match scoring: six
games with a two-game lead is a set, best of TUNE.classicSets is the match,
the board shows the sets column (hidden until now outside splatter) and the
bar under it says which set this is. The CPU stops rubber-banding and flubs
less: aiFlubLoss exists to get a new player PAST the opener, and a mode whose
whole promise is a real match cannot be won by an opponent that falls apart
the moment he goes ahead. No tiebreak — 6-6 plays on to 8-6. Marked ponytail.
That needed a second ENDING, the one with no corpse in it: G.done, the same
summary and gated tap as the death screen, its own words (GAME, SET, MATCH. /
BEATEN.) and the man still standing. The three endings now share one block
rather than three copies of it.
Caught by the first classic test: stats banked only the FIRST classic run of a
session. `banked` was cleared by the next KILL, and a classic match has no
kills in it at all — every later match was swallowed by the double-bank guard.
beginRun() is the honest reset and restartRun calls it.
ZOMBIE TENNIS from the menu is one line; the back room keeps its door.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three things, all in service of the match reading as its own mode:
BLOOD, COUNTED. The vignette says "worse" and never says "how many". One pip
per hit left, top-right beside the board, in the panel language the game/sets
bar already uses; the last two breathe. Pips rather than a bar on purpose —
the pool is 4 or 8, and a smooth readout of a small integer is the exact
ambiguity being fixed.
OPPONENTS FELLED IS THE MEASURE (handoff slice 1). Blood-only scoring means a
lost game costs nothing but the blood already spent, so games survived is not
the achievement. The in-play bar prints OPPONENT n · m FELLED instead of the
game/sets line, the death screen says BLED OUT / FELLED n OPPONENTS · m GAMES,
and it keeps its own matchBest — drillBest's pattern. The kills-to-next-point
pips are hidden in a match: nothing there is killed for points, and they were
colliding with the new line anyway.
STAGING WEARS A DIFFERENT ICON. Both builds live on the same phone and two
identical bloody tennis balls is a coin toss. Driven by HOSTNAME, not by the
branch — the block is identical on staging and main, so it never conflicts on
a merge and never has to be remembered.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Oliver played it: "the auto aim just mean no skills involved at all. this
should just go away.. there is no penalty at the moment.. make some
variations of the position on the court and the color".
The first two notes are one finding. With the shot locked to the called body
it was unmissable, so the fault could never fire — there was no penalty
because there was no way to earn one. Both snaps are deleted. What replaced
them is one line: aimTie is 0 during a drill. That is not auto-aim, it is
removing the guess — the ordinary magnet lets a nearer body inside the same
cone steal a shot aimed past it, which is right for a crowd you are mowing
down and exactly wrong where hitting the wrong body IS the failure. An
unaimed tap now lands on grass.
And the variation:
- six colours (purple, white) with the court dealt three to five of them, so
which colours turn up and how many decoys there are are live questions. At
four out of four every round dealt every colour and only the order moved.
- PLACEMENTS: fan, cluster, column, stagger, wing — weighted, mirrored per
round, lanes shuffled against the bib order. column stacks bodies in one
lane, and the ball hits the first thing it touches, so feet matter.
- pips are dice faces now, shared by bib and HUD chip. A row of five dots has
to be counted; a die is recognised.
Measured over 30 deals: all five shapes, counts even, all six colours, and
the first target screened by another body in ~10% of rounds. Verified end to
end in the browser that FAULT and DOUBLE FAULT both fire.
26 tests, including a named regression for the auto-aim staying gone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The late game is repetitive because the optimal move never changes: kill the
nearest thing, now. This is the first swing at a mode where it does — a
coaching drill where the umpire CALLS a colour sequence aloud while bibbed
bodies take their marks, and you must put them down in that order while they
walk at you. Uncalled colours are decoys you have to ignore. Two faults is a
double fault and the round is theirs.
Back room only (DRILL TEST, or #drill): this is a feel-test for one question —
tense, or just noisy? — so it keeps its own round count and touches neither
tennis scoring nor the run.
- voiceSeq: voice() drops a line that would start while the umpire is still
talking, which is fatal for a list. Decode first, queue at accumulated
offsets, and the anti-overlap rule is satisfied instead of fought.
- Four colours with PIPS as well as hue — red/green alone makes the mode
unplayable for ~8% of men.
- drawBib is a ponytail-marked overlay that knowingly breaks the sprite rule;
it is the thing to delete if the mode survives.
- BOTH target pickers snap (flickTarget and zombieTarget). Snapping only the
slingshot left fifteen tap-serves in a row scoring nothing.
- Contact costs the point, not the run — permadeath stays in the gore mode.
- startDrill disarms the ordinary schedule; updateDrill redeals and logs an
INVARIANT if anything empties its court.
Eight new tests (24 total) pin the ownership contract both ways, the call
beat, the win, the double fault, the un-winnable-round guard and the death.
PROJECT.md carries the four open questions for the playtest.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
THE LEGS. Measured as lower-third silhouette difference between the two stride
frames: the groundskeeper, whose walk has always been right, is 0.80. The seller
was 0.017 — the same legs twice, which is why he hopped along on one of them.
Both sheets regenerated with the stride as the entire subject (one foot planted
ahead, the other trailing, and they SWAP): seller 0.42, champion 0.67.
Three things the champion's re-rolls proved, all of them the same lesson —
THE REFERENCE IS WHAT IT COPIES. Anchored on his own one-legged frame it drew
that pose again. Given a working walk from another boss alongside him "for the
legs only" it blended the two and returned a slim side-on walker, then a
pot-bellied man in cream. One character in the reference, one character out —
and pick the keeper by EYE, because the metric happily crowned the side view.
THE CHARGE. It rushed, then withdrew a fraction of what it covered, so it ended
the fight parked on top of you: dodge or die every two seconds with no window to
hit it, and nothing but a hitscan laser quick enough to use the window there
was — Oliver, "they come too close, they seem unbeatable". A rush now RETREATS
to its line afterwards, and to keep it from being toothless the other way (from
the band it ran out of clock 8 m short) the rush is TIMED rather than paced: it
covers whatever ground the retreat left in bossChargeT, so every charge arrives
and every charge has the same readable run-up. Measured over 30 s: five rushes,
each one reaching you, 13% of the fight in your face instead of all of it.
And the back room moved off the whole splash onto an invisible box in the TOP-
LEFT CORNER. Five taps in the middle of the screen — which is where your thumb
goes on the way to PLAY MATCH — now do nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The roster ran out at chapter 6 and then repeated itself; game 70 got the
groundskeeper again, bigger. bossArt() now walks nine designs, one per chapter,
and hands out THE CHAMPION on every tenth — the floor(ch/10) term takes his
chapters back out of the count so chapter 11 opens the second lap on the
groundskeeper rather than skipping him.
70 THE STRINGER — leather apron, bandoliers of gut string, racket frames on
his back. He takes your lane and puts two dead-straight ones on it.
80 THE TOURNAMENT DIRECTOR — blazer, lanyard, radio. `adds` is new: he calls
stewards onto the court while you fight him, which is the one thing a boss
fight could not do before, since the stage is cleared for it.
90 THE STRAWBERRY SELLER — tray of cream, apron soaked red, ladle. A tank
that sprays four at once and then comes for you.
100 THE CHAMPION — torn whites, a laurel of strapping tape, the trophy still
in one fist. `rally`: anything under splatter quality comes straight back
at your head, so he is the one body you have to out-HIT rather than
out-shoot. `phases` lays three rows over his own as his health falls — he
holds the baseline and rallies, then runs the line drilling volleys, then
drops to a charge — and each turn is an event: cry, shake, the stand up.
Thirty-six frames through assets/_regen.py, the mascot's three-batch recipe
(2 stride, 3 wounds, 4 throws). The trap that cost the re-rolls: a throw batch
references its own wound frames, so a wounds batch that fails leaves the throw
sheet drawn against the SEED — two of the four came back half groundskeeper.
Wounds first, verify, then throws.
And the physio, who Oliver reported "does basically nothing but move towards
the net": he kept 4.2 of every 6.3 m he charged, so the fight was always over
before he arrived. `withdraw` is his own number now (0.2) and he charges half
again as fast — measured, he is on top of you in 11 s instead of never — and he
drills three balls a cycle on the way in.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- x_hand.png generated to Oliver's reference pose (index up, curled
knuckles, thumb) — drawGhostHand draws the art, vector stays as fallback
- fingertip anchor measured off the PNG, not guessed
- RESUMING panel top no longer floats above the glyphs
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- opening CPU beatable in a game or two (softer cap, more whiffs, rubber band)
- modals are for calm courts only; live-court news is a card now
- the transition howto is gone: a ghost hand teaches the pull, the toss,
the apex and the swing, each on the exact struggle it answers
- a shot barrel keeps your weapon and glows its slot instead
- switching weapons clicks, the live tile is unmistakable
- barrels come faster the deeper the run goes
- the umpire triple-tap cheat is deleted
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
EATEN_GRAB (write-only marker), ball.firstLand (written five places, read
nowhere), zombies' unused VOLLEY import, three scoreboard palette fields
from the pre-PNG board, the x_tin.png nothing references, and a dozen
export keywords on functions no module ever imported.
Kept on purpose: o_run3/o_run4.png — the SPR list excludes them so both
tours run on two frames (the women's set has no 3/4 twins), per the comment
at the preload list.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The court art had three faults: a hard-edged pale panel sitting on the near
half, mown stripes that stopped and restarted where the court began, and the
blood pool stranded behind the baseline where a portrait phone cropped it off.
Redrawn with the stripes running unbroken the full depth and the stain out on
the court where it can be seen.
The picture also read as zoomed in, which was not the art but the crop: at 4:6
`cover` was throwing away a third of the width on a phone. The sky is extended
to 1:2 instead, so the whole arena survives the crop.
Menu palette rotated off olive towards a cool desaturated blue-green with much
quieter borders, and the boxed BEST RUN / WORLD RANK bar is gone from the
splash — it sat on the best part of the art to say what the two buttons under
it already said.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The menus were placeholders next to the art. Three screens now: a title over
an empty, dimmed, blood-stained court; a MATCH SETUP screen where each court
wears meters read straight off its own bounce numbers; and BODY COUNT, which
needed the game to start measuring anything at all.
stats.js keeps one capped array of per-run records — all time, last match and
seven days are three filters over it rather than three counters that can drift
apart. Kills file under the body AND the weapon: killZombie only ever knew an
index, so every damage loop stamps the cause on its way past.
The tutorial is gone. Marvin explained what the in-game modals already teach,
and his stage is where the stats live now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four things Oliver caught on the phone.
The screen title is the ANNOUNCEMENT face now. A menu heading is a thing that
shouts, the same register the canvas gives SET and CHAPTER — it was wearing the
HUD font, which is the voice for scores and key hints. Bangers carries its own
weight, so it takes size and air instead of Silkscreen's letter-spacing, plus
the padding under it he asked for.
Her toss and smash were shrinking the whole woman. The slice anchors frame 0 to
224 px, and frame 0 was the toss — whose arm is straight up, so the BODY got
squeezed to make room for it. This is the trap already written down for the
raised racket; it just moved to a different sheet. Re-sliced the retained sheet
at the men's toss height (250) instead, so toss/smash/pull come out 250/293/191
against his 250/320/213 and her body matches her own idle again. No re-roll.
The pre-serve ball did not follow the MIRROR. drawChar flips the whole sprite
when a server faces left, so dodging during the bounce swapped his hands over
and left the ball hanging out by the racket — one `* sv.facing` on the offset.
It was wrong in the men's world the whole time, it just took a second body
standing next to it before anyone saw it.
And she reaches further out and higher than he does, so BOUNCE_IDLE's pair is
overridable per tour now (0.46/0.78 against his 0.36/0.68). Measured off the
drawn hand by eye, which is the only way those two numbers have ever been set.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The court screen picks WHO as well as WHERE now: two tiles above the court
rows, men's or women's singles, remembered like the court is. No new stage —
three rows and a pair of buttons already fill a phone, so the tiles go side by
side and the tap count from splash to play does not change.
Everything downstream is one line: drawChar already built every frame name off
a single prefix, so SK() appends '_f' when the women's tour is picked and falls
straight through to the men's frame for anything without a twin. The horde, the
officials and the ball boys are shared — they are staff, not the draw — but the
turned CPU is not: a male corpse opposite a female player reads as a bug.
Her frames came back as a different woman in every batch — blonde in one,
auburn in the next, greyscale line art in a third — because each was anchored on
the MAN whose pose it was. "newart" repaints the character and "state" is welded
to the corpse palette, so neither could say "the same living woman, a new pose".
That mode now exists, and every batch anchors on her own settled frame.
The death is drawn to frame 3 and recoloured after it. The generator will not
draw a dead woman in any wording worth using; frames 4 and 5 survive that
because by then she is flat and then a heap of cloth, so they are the men's
drawing with the blue shorts painted into her white (see assets/_die_f.py). The
collapse still falls 224/159/117/103/43 straight through the handoff.
Her first bounce frames reached to the shoulder, which is a catching pose, not
patting a ball — redrawn low rather than bending BOUNCE_IDLE to fit a wrong
drawing, so both tours share one apex and the ball meets her palm.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
He died at every chapter boss because he could not move, and there were two
independent reasons for it. The movement block is 'rally'-only — a settled feel
decision the six boss AI styles were written around — and a boss is fought from
'pretoss'. On top of that canAimServe() is true through a boss fight, so his one
finger was already spent on the slingshot: there was no gesture left over that
could have meant "move".
So it is a SIDESTEP, not a walk. One committed step on a cooldown, carrying
invulnerability that beats both damage channels — the chip damage in hurtPlayer
and the instant-death contact test. The rule about walking stands untouched.
The gesture is picked by WHICH WAY the drag leaves the deadzone, decided once and
never revisited for that touch: down-ish is the pull the game already teaches,
sideways is the step. No timing window, no invisible mode, so "one gesture means
one thing" still holds. The finger-steer modes are untouched — stick.aimable is
false in a horde, so the drag is the absolute steer it always was. The apron demo
now alternates PULL DOWN and SWIPE TO DODGE, and Marvin has a page for it.
The rest of the playtest list:
- Bangers is bundled beside Silkscreen, and the register IS the category. The
quiet face keeps the HUD, the rack, the control hints and RESUMING IN; the loud
one takes SET, CHAPTER, EPIC!, PERFECT and every free-floating flash. Floating
text gets one shared y and a drop shadow, so it survives crowd, clay and blood.
- Modals queue instead of being dropped. That was why the trainer and the weapon
had to be crammed into one card after a won duel; the heal gets its own popup
with the x_cross tin over it — a sprite loaded since that merge and drawn by
nothing.
- weaponModal's first-sight gate moved inside the function. The barrels gated
themselves and the tin ladder did not, so four weapons re-explained themselves
every three games while a barrel weapon explained itself once.
- Ten rack slots. Past five it grows a second row upward rather than shrinking
ten tiles under the thumb target; slots 0-4 stay welded to the bottom edge.
- The boss win holds for its own 3.6s and says EPIC!, with no GAME n NEXT line —
the bar under the scoreboard already prints that permanently. That bar went
from 6px to 7px, which is the smallest type in the game becoming words.
- The turn punches the camera in on him and the ground shakes harder at each beat.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The rack had five hardcoded slots where the index WAS the weapon, spelled out in
five places: SLOTS, slotStock, WDESC, drawRack's three parallel arrays,
deliverTin's w+1, and bare wsel === 1|2|3 / G.weapon === 4 tests across five
files. config.WEAPONS is the one table now, G.rack holds up to four {id, n} in
the order you GATHERED them, and slot 0 stays BALL. Everything reads through
slotId/slotStock/slotEntry, so nothing else knows how the rack is stored.
A fifth weapon is always allowed, per Oliver: it rides slot 0 as G.temp until
spent, then slot 0 goes back to the infinite ball. A barrel arms on the spot and
is spent quickly, so refusing it would mean a barrel you shot did nothing. And a
spent weapon LEAVES its slot -- with gathered slots, an exhausted weapon holding
one is a quarter of the rack you can never pick up into. (The old rack kept its
empties dimmed, which was right when a slot meant "BOMB goes here".)
Four new weapons, and they are all the ONE existing pellet loop. firePellet(w)
stamps the WEAPONS row onto the round -- r, dmg, hp, pierce, boom, ignite, col,
speed, life -- so the beacon cannon, flamethrower, laser and rocket needed no new
array and no new update; the break after the first body was the only thing making
a pellet single-target. All four go through the same isBoss branch, which is why
none can silently no-op on a boss the way three of five once did. Measured:
gun/beacon/laser/rocket reach past 16 m, the flamer is a ~6 m cone that ignites.
G.mgT stays the mode timer for all of them, so the eighteen !(G.mgT > 0) guards
across five files did not have to change. kind:'burst' auto-fires at its own rate
for len seconds; kind:'shot' holds the same braced stance and fires one per tap.
The barrels reuse the ball machine's shape: a rolling, shootable non-body. All
three of the registrations that fail silently are done -- blocksClear (loot must
not gate the hand-clear), the contact guard (furniture cannot eat you), and
hitR/GORE_MUL/zombieH. It scores nothing.
The pink had to become procedural. The chroma key deletes every pixel where red
and blue both beat green by 40, so a magenta drum and a magenta laser pistol both
came back as hollow outlines -- 19% and 15% solid against 50-56% for the icons in
other colours. The drum is grey steel with hazard chevrons and every bit of its
pink is the halo drawBarrel paints; the laser went electric blue. Both are solid
now (92% and 58%), and the rule is written down in HANDOFF.
tinTriples 3 -> 10 as asked, and the four dead TUNE ammo constants are gone --
nothing read them once the table landed, which is a panel value you can tune all
day for no effect.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A chapter boss had no AI at all: it matched no branch in the variant chain and
fell through to the crowd's aimless wobble, so "the boss AI owns its x" was a
comment describing code that did not exist. Movement is now data on the BOSS_ART
row -- pace, shadow, surge, charge, bounce -- and bossAI is the one place it
happens. Every behaviour field is optional, so a row carrying only art behaves
exactly as a boss did before.
Three earlier drafts of those styles failed the same way: written as "walk toward
the player's x", which in splatter means arrive in four seconds and stand still,
because the player is rooted in pretoss for nearly all of a boss fight. Every
style now moves under its own power and only biases toward him. Measured over a
simulated minute each: 50-136 m travelled, none static.
Three new designs -- THE HEAD COACH (a 5-shot drill 0.14s apart), THE PHYSIO
(charges, and withdraws less than it charges, so it reaches you at 38s and that
is the fight's clock), THE SPONSOR MASCOT (gap 0, so the whole wall of five
leaves in ONE frame and is dodged rather than parried). Harder to kill: 16 -> 20
hp, 9 -> 10 per chapter, plus per-design multipliers.
Three new bodies: the ball boy (3 hp, 2 skins, from game 3), the bouncer (6/3,
game 8) and the roller man (9/4, game 15), each with a 4-frame walk and a drawn
two-frame lurch for every wound stage.
Three wound rules became one. The brute had a hardcoded stage array, everything
else a single skin behind 'abd'.includes(...), and the boss a fraction of hpMax.
The boss's rule reproduces the other two exactly -- verified: the brute still
draws z_gw/gw2/gw3/gw4 at hp 4/3/2/1 -- and keeps working when hp is not
one-per-skin, which all three new bodies need. So a multi-hit body is now a row
in config.ZWOUND and no code. bleed() reads the same call, which also gave the
steward the trickle his wound skin had always implied.
Fixes a real crash on the way past: killPlayer empties shots[] while the shots
loop is mid-iteration, so every remaining index read undefined and the frame
threw. The mascot's five-at-once volley makes it easy to hit; the shipped 3-shot
volley could always have hit it.
_regen.py gains two style blocks and a check tier. One constant used to say
"keep every colour exactly as the reference" -- right for a remaster, and it
fights every word of the subject otherwise. newart is for a new character, state
for the same character wounded or mid-throw; using newart for the wound sheets
licensed a repaint and got one, five came back with living tan skin. And the
detail gate ratchets against the file it replaces, so check: any exists for
re-rolling already-fine art to fix something the gate cannot see, such as colour.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four things from the phone.
THE BAND NO LONGER BLINKS OUT. Cutting it at the instant of release made a
fast rally strobe — band, gone, band, gone. hitServe now records the arc it
actually fired (error included, so the ball tracks what it is consuming to
within a centimetre) and the ball eats it from behind as it flies. The aim
resolves into the shot instead of vanishing. It ends early when the ball does,
because a shambler swallowing the shot is a cut you want.
THE TARGET MARK SITS ON THE BODY. A ring on the grass pointed at the one part
of a shambler the shot has nothing to do with — the ball strikes anywhere under
1.9 m and the wound lands on the torso. So the mark rides at chest height when
there is a body to hit and lies flat only when the lane is empty. Filled and
washed out in the band's own colour instead of a hard 2px ring, which stops it
reading as a decal stuck to the lawn.
x_howto REDRAWN for the current gesture: thumb dragging back to a print, the
shot leaving as a curved band onto the shambler's chest, shadow dots beneath.
The old one still taught a straight arrow.
MACH_H 1.15 -> 1.55. Waist high read as a prop you could ignore.
Verified on a cache-busted build (the pane was serving a stale config.js, so
the first pass measured nothing): preview velocity and gravity still identical
to the fired ball, same flight time in the trail, unravel smooth to the hit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The logo now sits exactly as it does on the title screen — same width, no menu
override — with 30px under it so the blood running off its bottom edge stops
touching the first button.
That button also had no top border to cut into: the crop was a BOTTOM band of a
taller plate, so it carried the plate's bottom border and nothing above. Both
plates are cropped to their interior now and CSS draws one consistent frame on
each, gold for START and slate for TUTORIAL.
Lettering stays live text and goes to 15px with wider tracking.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The tutorial was borrowing a court tile, the in-game how-to icon and a weapon
icon, none of which were drawn to be looked at. Four purpose-made 2:1 panels
now: the serve with a tapping hand, the slingshot pull with chevrons into a
zombie, the weapon rack, and the floodlit duel. They get the full card width
above Marvin instead of being squeezed into the text column beside him.
The menu wears the title logo instead of a Silkscreen headline, and the two
buttons wear generated plates — START GAME bloodied, TUTORIAL nearly clean.
The lettering stays Silkscreen on top: generated type is a lottery and would
not match the rest of the HUD.
Two things the art needed. Quantise the panels at 128 colours, not 64 — big
flat fields with saturated accents is where a small palette shows, and 64
turned the tennis ball beige. And find a plate by its BRIGHT BORDER, never by
the corner colour: one plate came back on a near-black margin, the other on a
transparency checkerboard that differs from itself everywhere and matched the
whole canvas.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The overlay is a four-stage machine now — splash -> menu -> {courts, tutorial}
— swapped by data-stage while the backdrop and card frame stay mounted, so the
live court never flashes through between screens.
Court tiles SELECT only. Tapping one used to start the match on the spot, which
gave you no way to compare the three and no way back out; START GAME and BACK
are their own buttons now. Headlines are 15px against the 11px body, so a screen
reads as a screen rather than as a list.
The tutorial is a data table: a Marvin pose, an optional picture already in
assets/, and his lines. Seven pages covering tennis, the turn, the slingshot,
the rack and the duel. Marvin is four new frames off x_boymop1 — waving,
pointing, shrugging, and clutching his mop in terror.
The theme no longer starts at boot. A title screen that plays at you before you
have touched it is rude, and the autoplay policy would have held it silent until
that first tap anyway.
Death returns to the menu instead of the title. The reload stays — it is the
honest full reset — and a sessionStorage flag is what picks the stage on the way
back in.
Machine-gun rounds are tennis balls, so they are drawn as balls and take the
perspective. A fixed 4x4 fillRect was exactly as big at the far baseline as at
your feet, which read as square confetti pinned to the screen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every roll drifts the palette and nothing measured it: a white headband on six
frames, pale grey shorts on the toss twice, brick-red shirts and brown shoes on
the swing all sailed past --tries. check: "same"/"loose" skips colour_match, and
colour_match is a global mean/sd fit far too weak to pull brick-red back to red
— a nearest-colour palette lock is worse still, it drags the duller reds into
the racket's browns.
So _regen.py measures three numbers and a batch opts in with palette_ref:
redness (r minus mean(g,b) over saturated warm pixels — brightness alone cannot
tell brick-red from red), shorts brightness (charcoal 61 vs the drift's 83), and
the near-white fraction that is the shoes and socks (brown drops it to 0.9%,
pale shorts push it to 7.9%, correct frames sit at 2.8%). Frames known to be
right cluster tightly on all three; every drifted frame fell outside on at least
one. It rejected five bad rolls in a row, then passed a good one.
The swing also kept re-inheriting its own drift: ref_sheet is built from
[lead] + the sprites being replaced, so it was showing the generator the very
brick-red frame it was about to overwrite. Copying a canonical frame over the
targets first breaks the loop — it passed on the next roll.
Swing is now a real backswing and follow-through (silhouettes differ by 1.0,
racket low behind vs up over the shoulder), both at full standing height, and
the toss and smash wear charcoal shorts again.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The "smaller when swinging or tossing" was an anchor bug, not art. _fine.py
scales a whole sheet so the --anchor frame is 224 px, and drawChar draws every
sprite at img.height/SPRITE_H — so anchoring opponent_play on the TOSS, with
arm and ball above the head, made 224 px cover the raised arm and shrank every
body on that sheet. o_swing1 was 162 px against a 224 px standing pose. Each
opponent batch now leads with a standing `ref` frame, which _fine.py anchors on
and _regen.py discards. Correctly anchored the toss is 278 px and the man is
full size; the swing is 221.
The receiver no longer slides 0.12 m side to side while waiting on a serve —
sliding a sprite sideways reads as skating, and the split-step swap to the run
frames (removed earlier) had already been the other half of that.
The idle bounce ball had one hardcoded offset for both men. They reach to
opposite sides of the screen — the player is drawn from behind, the opponent
from the front — so the opponent's ball sat out by his racket. dx and apex are
per-man now, and o_bounce/o_bounce2 were redrawn with a patting hand to put it
under.
Also: the opponent wears a RED headband, and a prompt of mine that said "white"
had quietly repainted it on six frames.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
zombie-mode: 5.9 MB of 24-bit WAV down to 287 KB. Its master is 982597 frames
at 48 kHz, so loopEnd is the un-round 20.47077 — the decoder hands back
20.4787, and those 7.9 ms of AAC padding would tick once a loop. Nothing in
assets/audio/ is a multi-megabyte WAV any more.
o_run1/o_run2 had no racket in either hand. The cause was the batch prompt:
opponent_run never said "racket", and its reference sheet was built from the
racket-less frames it was replacing, so there was nothing to copy one from. It
is a ref_only batch off o_idle1 now — same man, front-facing, racket in his
right hand — with the subject spelling out that he still carries it mid-stride.
The stride reads as well as the old one did.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8.3 MB of WAV down to 768 KB, on a file that downloads on every page load
before the player has decided to play anything.
loopBed takes the full filename now, because the beds are a mix of .wav and
.m4a — fadeBed's key has to match it exactly, extension included, or a duck
silently no-ops.
It also takes an explicit loopEnd, and the theme passes 48. AAC pads its last
packet, so Chrome decodes this 48.000s master to a 48.0174s buffer: 17ms of
silence spliced in at every seam, once a loop, on the title screen. Trimming
the seam is what keeps the loop seamless.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
background-music.wav loops under the title screen and the court picker, ducks
out when a court is picked, and fades back in at the death summary — the end
screen is a menu too, so the zombie bed ducks out as this one comes in.
It starts at BOOT, not on the first tap, so the title screen already has music.
That needed loopBed to call audio() before its already-started early return: the
theme plays into a context the autoplay policy holds suspended, and the call
that wakes it is a second menuMusic() from the first tap, which used to return
at the beds.has guard without reaching AC.resume().
fadeBed also now records a duck for a bed that is still loading, and loopBed
starts that bed silent. An 8 MB file ducked mid-fetch would otherwise come up at
full level, seconds later, over the match. Same latent bug applied to zombieDuck.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three courts, picked on a second start-screen stage: CENTRE COURT (grass),
TERRE ROUGE (clay) and THE SLAB (hard). A court is a row in config.COURTS and
four PNGs — render() never learns their names.
Each row carries two palettes, because every court still rots: what "drained"
means on orange clay is not what it means on green lawn. Mow stripes became a
flag rather than a colour — recolouring the lawn and leaving the bands on still
read as grass. Bounce is two numbers copied into TUNE on selection, so the three
bounce sites in gameplay.js and the T panel stay untouched: grass skids low and
fast, clay sits up and drags, hard is the old baseline.
Per-court crowd sheets and ad strips. Each court's clap frame is generated from
its OWN seated sheet, not from x_crowd2 — pairing one court's crowd with
another's applause cross-fades between two different crowds.
The picker is a second panel in the existing #intro overlay, rows built from the
table. Last pick is remembered and comes up pre-highlighted; 1..N or Enter on a
keyboard.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Oliver, after playing the four-stage version on the phone: idle, then the racket
dropping on the pull, then the drive on release — "that's all, forget all the
others". At thumb speed the middle stages are never read, so they are deleted
rather than kept around: p_pull is the wind-up, p_smash is the drive, and both
come out of the photo-referenced sheet, so the whole serve is one drawing style.
The dead batches and the arm_left guard go with them. assets/_pose_ref_serve.png
is kept as the recipe: his photo sequence over our own sprites, which is what
turned a fifteen-generation prose argument into one generation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Oliver's idea, and it is the fix for the process as much as for the frames: give
the generator the real serve sequence as an IMAGE and let it copy the poses,
instead of describing a pose in prose and re-rolling until it lands. One
generation — a composite of his five photographed frames over two of our sprites
— returned a magenta row of the whole serve in the game's own character and kit.
The four stages are sliced straight out of it, anchored on the standing figure.
_fine.py gets --defringe: its two de-fringe passes each eat a one-pixel line, and
a racket frame IS a one-pixel line — that is what had been leaving the men holding
a grip and nothing else.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The racket kept changing hands because the art disagreed with itself: p_idle,
p_bounce and p_toss held it in his LEFT hand while p_smash and p_swing used his
right, so every new frame copied whichever reference it was handed. The idle, the
bounce pair, the toss and the four wind-up frames are all regenerated with the
side named — drawn from behind, his right hand is the right-hand side of the
image, and only the racket HEAD crosses behind his back.
What separates the four wind-up stages is how far the racket has fallen, and that
needed the racket head pinned to a body landmark per frame — above his head,
beside his ear, between his shoulder blades, at the back of his thighs. Described
as "further into it each time" it drew four near-identical poses twice running.
They are wired late in the pull, Oliver's call: frame 1 owns the first half and
the three coiled frames are packed into the top half.
assets/_derive.py is deleted. Fixing generated art by mangling it afterwards is
always worse than regenerating; three guards in _regen.py turn the bad rolls that
used to install silently into automatic re-rolls — a portrait reference canvas
(the generator answered with a 2x2 grid), a non-magenta background (a green sheet
keyed away four racket heads), and a mirrored frame.
The bounce pair is one drawing from one sheet now, differing only in the free
hand, so BOUNCE_IDLE moved to his left side and was re-measured off the sprite.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Oliver, off the file browser: "the racket changes the hand in 3 and 4". It did —
the generator drew frames 1-2 right-handed and 3-4 left-handed. Both mirrored into
line with the rest of the serve (a back-view figure mirrors for free), and the
batch prompt now names the side, since the generator will not hold a prop in one
hand across a sheet unless told to.
The convention the rest of the art already uses: racket on the LEFT-HAND SIDE OF
THE IMAGE, free arm on the RIGHT — the side the game drops the bounced ball and
tosses the served one. p_smash keeps the racket on the right: that is the frame
where it has come over the top to meet the ball it is hitting.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four new frames (p_pull1..4) turn the slingshot drag into the serve's wind-up:
racket above the head, behind the head, behind the back, fully coiled, picked off
pullFrac — the same 0..1 that sets the shot's power — so the gesture and the man
are one movement and the release snaps to the smash.
The two bounce frames are one drawing now. p_bounce2 is p_bounce with the free
arm sheared up 12px and nothing else touched, so the swap cannot flicker, and it
follows the ball instead of its rising phase; BOUNCE_IDLE was re-measured off the
sprite so the ball tops out under the palm. p_toss had the tossed ball drawn into
it — cut, canvas height kept, because the game scales him off img.height.
The runner: hip centres sat -2.0/+7.1/-6.0/-0.1 px off the sprite centre, a 13px
lateral jump at 12 frames a second, and Oliver's diagnosis was right that all four
frames leaned the same way and lifted the same leg. Frames 3 and 4 mirrored, all
four padded to one width with the hip on the centre line.
assets/_derive.py holds the three derivations, since "the same drawing with one
small difference" is the one thing the generator cannot draw.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Oliver spotted it before testing: a whole-frame mirror swaps the DRAGGED POLE
into the other hand as well as the legs. True for the one wound stage that still
has an arm holding something, so that stage gets a leg-band mirror about the hip
centre instead — legs swap, pole and stump stay put. The other three stages have
no arms and no props, so a full mirror is right for them.
The honest cost: a leg mirror leaves both feet in the same two places, so that
one frame reads as a leg swap rather than a stride. Written up with the fix that
worked for the shamblers (a passing pose, feet together) and the measurement that
tells them apart.
HANDOFF gets the session: what changed, the two lessons that cost the most (the
prompt sets the art resolution; the reference sheet is what the generator
copies), the rule that anything which must stay identical between two frames
belongs in code, and the four known limitations ranked for whoever picks it up.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
All four stages drawn from the healthy body in two sheets, so he keeps the same
bulk the whole way down instead of slimming as the wounds get worse — one arm and
his pole, both arms, ribs open, then headless.
The second frame of each stage is now a MIRROR of the first, not a generation.
Every attempt at drawing them moved the injury between the two feet — this is the
third fix for the same complaint — and a mirror physically cannot: the wound
stays on the same part of the body, and flipping a standing figure IS the stride
swap the B frame exists for.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Oliver had the diagnosis exactly right: the raised racket is part of the sprite,
so the toss and smash frames were 29% racket by height — and the game scales a
sprite off its TOTAL height, which shrank the body to fit. Measured 71% body
against the standing frame's 98%. Both are scaled so the BODY lands at standing
size and the racket now extends above the frame instead of squeezing him into it.
The bounce frames are redrawn too: racket in the left hand, right hand empty and
reaching down to the side the game actually drops the ball on, and no ball drawn
into the sprite at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The generator drew the second frame of that wound stage as a MIRROR of the first,
so the missing arm and the dragged pole swapped sides on every step — which is
the "wound on the left arm on one foot but not the other" Oliver saw, and it was
on the sheet, not in the frame assignment. Flipping the frame back puts the
injury where it belongs, and a horizontal flip IS the leg swap a second stride
frame wants, so the walk still reads.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The playtest list, art side. The generator had been copying the very frames I
wanted changed, so:
- The player is drawn FROM BEHIND again in every pose — standing, bouncing,
serving, swinging. He had turned to face the camera in the redraw, which is
also why he "looked at you" in different poses.
- The serve frames are back at full size. That batch had been anchored on the
toss frame, so the whole serve set came out about a fifth too small.
- The runner CHARGES AT YOU down the court instead of sprinting sideways across
it, which is what he actually does in the game.
- The brute's wound stages are drawn a pair at a time from their own A frame, so
the injury stops moving between the two feet.
- The bounce frames had a ball painted into them while the game draws a live one
on top — he was bouncing two. The painted one is gone, and the live one now
falls on the side the sprite actually reaches for.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The reference sheet is what it imitates, so building that sheet out of the very
sprites I was trying to change is why the front-facing player came back
front-facing and the side-on runner came back side-on. A batch can now name
`ref_only` — the frames it looks at — separately from the frames it replaces:
the back view is drawn from the one genuine back-view frame, the charging runner
from a front-facing zombie, and each brute wound pair from its own A frame so the
injury cannot wander between the two.
Also stopped colour-matching a REPOSE. Fitting a differently-posed frame onto the
old one's colour statistics is what greyed out the serve shorts; when the pose
changes the generator's own palette is the better answer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three from the playtest, all code:
Every side-on sprite is DRAWN facing left, and nothing ever mirrored them, so a
zombie walking right moonwalked — obvious the moment they close on you across
your own half. They face where they last walked now. The front-facing ones (the
turned CPU, the machine, a chapter boss) have no left or right and are left alone.
The racket zombie holds a position and only moves when the ball makes him, so a
time-driven walk cycle flicked him between two frames on the spot. His stride
steps off ground covered, like the brute's — standing still is standing still.
And the pack no longer stands on the body twitching for as long as the summary is
up: once it appears they break up and shamble away up-court. Measured — feeding
at 3s, dispersed to 3m by 6.5s.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The two that kept failing land as two-figure sheets — a dragged pole or a
held-up chair is wide enough that four to a sheet either merge into one frame or
run off the canvas whatever margin the prompt asks for. The chair spectator came
out the best of the lot (0.15-0.24 to 0.42-0.52).
Also caught x_boyrun3 and x_boyrun4 sitting at the old fidelity next to the two
frames of the same run I had already redrawn — my batch list only covered half
the cycle.
The roster now averages 0.341 inner-edge density across 111 character sprites,
against 0.237 for z_mach2. What is deliberately left alone: the chapter bosses,
the judge, the ball machine and the weapon icons — Oliver named those as the ones
that were already right.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four more batches: the racket zombie's two wound stages with their stride twins,
the umpire and the ball boy's sprint, his mop-carry walk and his mopping cycle.
Detail roughly doubles on all of them.
Also stripped the last dozen key-colour pixels off p_die5's rim — edge pixels
only, because a racket's strings are legitimately pink and a blanket filter
would eat them.
The brute's wound stages and the chair spectator are re-running as smaller
sheets: four figures carrying a dragged pole or a held-up chair either merge
into one frame or run off the canvas whatever margin the prompt demands, so
they go two at a time.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both full sets through the same pipeline: stances and ball-bounce, serve toss,
smash and both swing frames, the four-frame run, and the five-frame death. The
opponent's ten frames with them. Inner-edge density roughly doubles across the
board — the serve frames go 0.22-0.32 to 0.41-0.54.
_regen.py retries a rejected batch itself now (default three attempts). A
rejection is usually just a bad roll — two figures drawn close enough that the
slicer sees one frame, or a limb off the canvas — and re-rolling fixes it without
me sitting in the loop.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>