Sixty words on the wall became a dozen. The handoff's own summary of the rule:
Jersey 15 NAMES (screen titles, rooms, cards), Bangers CALLS exactly once per
screen (the fixture, the primary button, a modal headline), Silkscreen INFORMS
(numbers, labels, chips) — and a sentence survives only where it carries a
decision. Everything else moved into a bar, a pip or the modal that opens with
the mode.
· Jersey 15 bundled subset (9 KB) and Bungee retired after one afternoon
· SPLASH: the plaque is one row, the goal line is gone (the fixture button
below said it already), the two doors carry a number each
· PICK A GAME: rooms are one word, posters are their names, the fixture is a
hero with the only second line — the whole wall fits one screen now
· MATCH SETUP: unnumbered blocks, the meters' legend once instead of a
description per court, no summary footer
· DIFFICULTY: three pips instead of three sentences
· TRAINING: the revanche is a full-width row, rungs are numbers, the list
ends on the league that opens the rest of the ladder
· VS: three paragraphs became YOU / HOST / JOIN
· MODALS: one rule per row (the sub was the lead again in other words), the
blood pool as pips on that same line, and the walk-on's two levels in ONE
face at ONE size — a comparison drawn at two sizes is a headline with a
footnote (Oliver)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
10 KB instead of 119 — the face carries only the characters this game sets, and
the command to redo it is in PROJECT.md. The header strip's titles take it at
23px: a strip at the top of a screen is signage, not an announcement, so it
stops leaning. Bangers keeps what it was always for — the primary button, the
ceremonies, and everything the canvas shouts. The poster wall's rooms go back
to acid beside it, with the scale carrying the hierarchy.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bangers has no upright cut — the slant is in the glyphs, which is also why the
chevrons are Silkscreen — so a heading that wanted to shout without leaning had
to come from somewhere. Bungee is an arcade/signage face, upper case by design,
and it stays in-world where the platform's UI sans did not. Bundled beside the
other two with its OFL licence; the Dockerfile already copies assets wholesale.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Four answers to one phone report:
- THE READ: repeat a lane and the opponent anticipates it; break the habit
and it is wrong-footed for that ball — scaled by rung, so surprise is the
weapon that opens the ladder past the HEAD COACH (TUNE block THE READ).
- The opponent walk-on is a real modal now (head, punchline, tap to dismiss)
in arcade, tournament and training — readable without a clock.
- THE TRAINING COURT: third card under ZOMBIE TENNIS, pick any rung, one
game at a time, the felled body walks straight back on, nothing banks,
bleeding out is a popup instead of a death. mode_train.png rolled in the
banner style.
- MOVEMENT READS: sprinting feet kick per-court dust and fast bodies leave
a two-ghost speed streak (trailMin gates both; feel verdict pending).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Oliver, three notes; two of them are here.
"when the opponent serves the player does not stand at the baseline - but
that's how it should be." setupPoint's receiving marks sat over a metre
INSIDE the court on both sides (22.5 near, 1.2 far) — where you stand to
attack a second serve, not where you wait for a first one. They mirror the
server's depth now, in the one line every serving mode shares.
"when serving results in an out there should be a second service... the
umpire should announce 2nd service and the player must get another chance."
True in tennis mode since forever and absent everywhere else: the duel, the
match and the death match had no service rule at all, so a serve long or
into the net was judged as an ordinary ground stroke — a point, and in the
match a point costs blood. The out branch of onBounce's interlude block
routes a serve to fault() instead, so the second serve, the HUD line, the
umpire and the double fault all come for free.
Three things it dragged out with it:
- fault()'s double-fault branch called pointTo — TENNIS scoring — which in
an interlude banks a number and skips the mode that owns the court. It
goes through resolveInterlude there now. Nothing had ever reached that
line from a duel or a match, because those modes could not fault.
- startDuel never reset G.serveNum: harmless while a duel could not fault,
the next duel's death loop the moment it can.
- the match puts the body back on its receiving mark for the second serve
(placeReceiver, split out of matchReady, which resets the serve number).
"Fault. Second service." is one voiceSeq — a second voice() call would be
dropped, one man one microphone. New recording via the usual say/afconvert.
The service BOX is still not enforced in the match, deliberately: this is
strictly more forgiving than what shipped. The lob gesture is OPEN 14.
4 new tests, 110 pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Oliver: "what is this white flashing while applause in the upper right corner of
the crowd". With the photographers switched off entirely it was still there, so
it was never them — it was mine, from this morning. The top fade is baked into
the cached stand, and drawRaisers was painting a spectator's box straight over
it with only the dim applied. Every time that spectator stood up his box lost
the fade and lit up; every time he sat down it came back. Measured on the canvas:
a 146-luminance jump, twice a second, in exactly the two places raisers sit near
the top of the picture.
Both stand treatments now come from one function each and are applied in both
places. The gradient is built in user space, so filling a raiser's sub-rect with
it picks up the right alpha for free. Probed again after the fix: zero bright
pops during applause and during the wave, against the four hot spots before.
While chasing it, two real detector faults turned up and are fixed too: a blob
may not be a pale block (a re-rolled cream shirt is "new material" by the
stand-turned-person test but nothing rises), and it may not cover something
already bright (a genuine arm drawn over a white shirt deletes a white block,
which flashes just as hard). 94 raisers survive both rules.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Oliver: "in the upper parts of the crowd there seems to be a lot more action
going on, but I can barely see it because the hud is covering it." Measured, 31
of 134 raisers were being drawn under the board or the blood panel — a quarter
of every cheer spent where nobody can see it.
The cause is structural: a raise is only detectable where there is empty stand
above somebody's head, and the sheet only offers that above the top row of each
seating block. One of those rows is at the very top of the picture, which is
where the scoreboard lives.
So both ends are fixed. The two arms-up rolls now ask for the standers to be in
the lower two thirds only, and the pass drops any raiser whose box centre falls
under the board or the blood panel. 92 of 101 raisers now sit below the board's
bottom edge, against 103 of 134 before — and because "how many join in" is a
fraction of the table, pruning the invisible ones makes every cheer denser where
it counts. Measured churn during a won game: 2-5.5% of the mid and lower stand
changing every 90 ms, against 0.1-1% under the HUD.
The re-roll also came back with 41 spectators owning both raised poses, up from
28, so more of them clap rather than just going up and down.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Oliver: "it is too dim to see anything on the centre court and the dark court."
Both were the same mistake — standBack was ADDED to a court's own dim instead of
being the default it falls back to. Centre court's crowd sheet is a night-lit
navy that is dark to begin with, and the night court dims the hard-court sheet
on top of that, so the pair of them went to mud.
standDim is now court.dim ?? TUNE.standBack, plus the rot. Centre court carries
a 0.06 of its own, the night court comes down 0.62 -> 0.5 (it no longer has the
base added to it), and clay and hard keep the 0.22 that was approved.
Centre court's sheet is also graded off a LIFTED target now — the old crowd it
matches sat at a mean luminance of 39 against clay's 96, dark enough to throw
the new roll's detail away before any dim ran. A 0.72 gamma on the target brings
the sheet to 60 and the faces come back.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Oliver: "when clapping I barely see any movement at all now.. also all of the
art could be colored a little more dim as it is perspective wise farther in the
background and should not steal too much attention" — and then, "can't we make
an animation sprite sheet of the new crowd?", which is the answer to the first
half of the first one.
A third roll (_crowdroll.py mid) takes the arms-up frame as its reference and
asks for the same standing people with their hands brought together in front of
their faces. It drifts by 53% of its pixels and that does not matter: the same
filter keeps only what was stand and became person. 100 arm blobs in pose 1, 74
in pose 2, 134 spectators once merged, 28 of them owning BOTH poses — those 28
cycle between two raised poses, which is a clap; the rest alternate with the
seated frame, which is arms going up and down.
The point is the cycle, not the pose. A raiser is ~8x6 logical px on a phone and
one that appears and then holds for a second is not something the eye catches;
arms moving at 3.5 Hz is. Measured: 2-3% of the stand band now changes every
90 ms during a won game, against near zero before.
The sheets are one file per court now — three frames stacked, graded as a single
image so they cannot drift apart in colour — and x_crowd2/_c2/_h2 are deleted.
TUNE.standBack 0.22 dims the crowd and the boards on every court before the
night court's own dim and the splatter rot, in the one function both the cached
backdrop and the live raisers already read.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Oliver on the first pass: "the new lighter areas read as rather sloppy artwork.
please regenerate the crowd art and make it more detailed. also the animation
also looks pretty artificial. the flashes from the fotographs are too rare and
the whole thing somehow does not translate well to the other courts. also when
the opponent makes a point there is an aww-sample (which has an applaus trail)
so the crowd should still clap but more subtle."
The paint pass is deleted — recolouring blobs of an old drawing is not drawing,
and the reason it looked necessary was a wrong conclusion: the roll scripts read
OPENROUTER_API_KEY from the repo's .env, so the art could be rolled all along.
_crowdroll.py rolls the crowd itself: individually drawn spectators with hats,
sunglasses, drinks, cameras, flags and banners, empty seats, stairways, and
three zombies sitting in the middle of it behaving like furniture. ONE roll now
serves all three courts through a per-channel histogram match onto each court's
old palette, so anything the art gains shows up on clay and at night too.
_crowdpass.py keeps only what was empty stand in the seated roll and became a
person in the clap roll — the raised arms, 1.2% of the sheet — so the clap frame
cannot drift by construction, and writes a box around each pair of arms into
src/crowd.js. render.js raises ONE SPECTATOR AT A TIME out of that table, each
on their own period: the 16-column strip swap moved a seventeenth of the crowd
as a block, which is what read as machinery. crowd.pop is now a head count, so
the same code covers a dozen people and the whole stand, and past 0.75 the wave
travels person by person.
aww() sets a small crowd.pop of its own — the sample is a groan that settles
into applause, and the stand has to match what you can hear. flashEvery 5.0 ->
1.9, because at a five-second mean over a stand this wide nobody ever saw one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The wobble was never the animation. x_crowd2 was a RE-ROLL of x_crowd, not a
pose of it — 13-16% of its pixels differed, over faces, torsos, seat backs and
the railing — so swapping 16 columns at 4.5 Hz re-hashed a strip of crowd twice
a second. assets/_crowdpass.py rebuilds the pair: the clap frame is now the
seated frame plus 36 genuine raises, each contained in ONE of render's columns,
so any column swap is seamless by construction and an empty column is invisible
instead of being a wobble. The three courts are one composition in three
palettes, so a raise found in one court's roll is transplanted into the other
two through a colour map learnt from the aligned seated pair.
The same tool paints the crowd, because at ~8 logical px a spectator detail can
only mean chroma and silhouette: 55 shirts and 44 heads of hair in colours the
court does not own, 28 caps, 4 flags above the heads, 8 emptied seats, 8 people
permanently standing — and 3 who are already turned, grey-green, mid-row, who
are also the only three who never applaud.
Movement, one draw path and three behaviours: applause scatters per column, a
WAVE crosses the stand for a moment big enough to deserve one (crowd.pop >=
0.75), and with nobody cheering ONE column stands for a third of every
TUNE.fidgetEvery, so the stand between points is no longer a photograph.
Paparazzi now scale with the moment: cheer() records the size of it (vol was
already exactly that at every call site) and queues shutters proportional to it,
drained at a fifth of the normal gap. pop is held at the loudest while an
applause is live, so a won rally landing inside a match-point ovation cannot
demote the stand to a polite clap — that rule has a test.
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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: '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>
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>
Nine sheets meant nine chances for the character to drift, and all day it took
every one of them — a brute that was three different brutes, a corpse that came
back a living man. Asking for the WHOLE family in one image removes that by
construction: one roll, one light, one palette, 31 figures.
What was missing was the way back down. _fine.py cuts a single row; a family
sheet is nine. _zsheet.py is that step and nothing else — it reuses _fine's
keyer, block detection and column cutter, and adds the two things that have to
be GLOBAL or the family breaks:
SCALE taken once from the play row's standing frame. Per-band scaling would
make the serve strike the same height as the idle stance, and the game
sizes a sprite off its own PNG height. Measured after: serve 319,
prone fall 83 — the racket family's are 307 and 85.
PALETTE taken once from the largest figure anywhere, so a wound row cannot
quantise onto slightly different colours than the row above it.
The enclosed magenta rule is INVERTED here, deliberately. _fine.py repaints
magenta trapped inside a figure because the generator used to fill string beds
with the backdrop colour by accident. This sheet asks for exactly that and gets
it, so the bed cuts to transparency and the racket is an open hoop.
_regen.py keeps three fixes found on the way: margins as a PROPORTION (an
absolute pixel margin means nothing to a model that picks its own resolution —
it clipped 3 rolls in 4), reference sheets that fill keyed-out string beds
instead of showing the model a magenta racket, and --verify, which asks the
question no gate here ever asked: is the art already on disk what it claims to
be. It found 27 of the brute's 31 frames dead — z_brw1fall was one drawing
pasted four times, 0.011 apart where a real sequence is 0.53.
The brute is live: ZFAMS gains 'br', the KEEPER wears it. Verified in #bench,
healthy and arm-off, 54 tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Re-running --variant to rewrite a spec re-seeded EVERY placeholder from the
master, and placeholders are copies of the lean red-shirted man the poses came
from. That silently replaced a finished brute's bounce, toss, strike and four
fall frames with racket-zombie art. I restored z_br1..4 by hand afterwards and
did not notice the other seven were gone — so three subsequent passes were
regenerating sheets that already existed and had been good since this morning.
Recovered from 0e92c72, and variant() now refuses to seed over a frame that is
already there: a placeholder is a stand-in for art that does not exist yet, and
nothing else.
Then colour-matched every brute frame onto z_br1 rather than generating: five
moved from failing the palette gate to passing, with no generator calls at all.
That is the point of the correction being arithmetic — it applies retroactively
to art already on disk.
WHERE THE BRUTE HONESTLY STANDS:
HEALTHY 11/11 — stance, shuffle pair, forehand, bounce, toss, strike, and
the full fall. Consistent, colour-matched, done.
ARM OFF the play and serve frames read; the four FALL frames are standing
figures of the wrong build entirely.
HEADLESS not headless — z_brw2 still has its head, and is near-identical to
the arm-off frame it was drawn from. The `changed` gate passed it
because SOMETHING moved; it does not know that the something has to
be the head.
So about a third of the brute is right and it is the third that matters least —
the healthy row was always the easy one. The wound stages are the job.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The character-as-image fix landed and did NOT close the gap, which is the answer
I promised to report rather than paper over. The brute's serve sheet came back
with his build, his garments and his DARK TONES PIXEL-IDENTICAL to the anchor —
(56,56,32) both — and his big pale shirt two shades brighter, (200,200,184)
against (176,176,152). The model was looking straight at the character. It held
identity perfectly and re-lit the largest flat area anyway, because every call
re-renders and re-rendering re-lights.
No prompt fixes that. Six gates spent a day trying to make a generator do
arithmetic.
So the arithmetic happens here now. Pair each of a frame's dominant colours with
its nearest anchor colour, fit one gain+offset per channel across those pairs,
apply it to every pixel. Pairs further apart than NEW_COLOUR are dropped from the
FIT — colours the anchor genuinely has not got, i.e. blood on a fresh wound — but
they still RECEIVE the transform, so a wound stays the right red relative to the
body it is on. The gain is clamped so one odd pair cannot posterise a frame.
PROVEN ON THE REJECTED FRAME BEFORE SPENDING A SINGLE CALL, which is the
discipline I skipped all day: the frame that measured 20.0 away passes after
correction, its shirt pulled from (200,200,184) to (176,176,160).
And the order is now CORRECT, THEN CHECK — the palette gate is a safety net for
what arithmetic could not fix, not a tripwire for what it can.
The model this leaves:
the generator, given references -> identity, pose, composition
the pipeline, deterministically -> exact colour
the gates -> layout accidents only
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The agreed single pass did not land: br_w1 installed, br_serve and br_fall were
refused by the palette gate at 12 to 22 away from z_br1, and everything drawn
from them was correctly skipped rather than built on sand.
'br' comes out of ZFAMS. The art stays on disk and the nine batches stay in the
JSON, so re-registering it later is one line — this is parked, not deleted, and
nothing in the game referenced it (no opponent row names it).
THE FINDING WORTH KEEPING, written at the top of _master.py: the brute's PLAY
sheet is a good brute; its serve and fall sheets are each a slightly DIFFERENT
brute. All three restyle from the master independently, so each invents the
character afresh from the words, and "pale grey-green work shirt" lands
somewhere new every time. The gate is not being fussy — it is measuring three
different zombies.
The fix is to hand the later sheets BOTH references: the master frame for the
POSE and the variant's own first sheet for the CHARACTER, instead of the words
for the character. Which is the lesson the wound stages already taught — a
reference beats a description — applied one level up. Until that lands, a
variant is reliable for its play sheet and a gamble for the other two.
The racket zombie is untouched and complete: 33 frames, every gate green, and
it is the opponent the match actually plays.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Oliver, on the brute's fall frames: "now the fall zombie has a different skin
tone.. man this is all so tiresome." Both halves were right. The skin IS twenty
points lighter across that sheet, and the tiresome part is mine: I had been
adding one hand-picked gate per drift I discovered — redness, then hue, then
`changed`, then no-grids — which is a loop with no end, because naming features
is not the same as comparing pictures.
`kit` names four: shirt redness, shorts brightness, shorts hue, near-white
fraction. Every character breaks a different one. The brute's shirt has no red
in it at all, so its reference redness was 0 and blood scored 70-100 against a
tolerance meant for a garment. A body face down shows a fraction of the shirt it
shows standing, so two of the four move with POSE. And nothing in that list ever
measured SKIN, which is exactly how a brute's flesh drifted across a whole sheet
with six gates green.
The palette gate asks the only question that generalises: are the big colour
areas of this frame the same colours as the anchor's? Top SIX by area — the
garments and the skin. Proportions ignored, so a pose change is invisible; blood
is never in the top six, so a fresh wound cannot look like drift.
CALIBRATED BEFORE IT WAS WIRED IN, which is the discipline I skipped all day.
Frames known to be right score 0.0-8.5; the drifts we could actually see score
12.0-15.9. The threshold is 10, in the gap. Three formulations were measured and
the two that overlapped were thrown away.
And every sheet of a variant is now anchored to that variant's FIRST sheet, not
to the stage above it. Chaining let a legal hop compound into a drift nobody
authorised — the shorts walking -9 to -19 down the wound ladder was that, and so
was this.
Its first verdict on the existing brute: br_play is the character, and every
other sheet sits 15 to 48 away from it. It would have caught all of this on the
first roll.
Also in: the GRID guard from the same session — the valley-cut rescue would slice
a 2x2 sheet into four frames of a torso above someone else's boots, turning a
loud failure into a silent one. Asked per cut PIECE, because a body is vertically
continuous and a piece with a gap in it caught two of them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>