Anima: a 2-D feature animation engine and 100 pop-paradigm assets - #10
Merged
Conversation
… sounds as data
The game was already fully procedural -- there is not one .png or .wav in the
app -- so ownership was never the problem. Throughput was:
* a new 2-D object costs an engineer a day. Five vocabularies already exist
(ellipses in SceneRenderer, lobes in AnimalRendering, gemPath in
SkyRenderer, fireworks, weather) and no two share a line;
* a new sound is a 200-line edit inside makePopBuffer's ten-way switch;
* nothing can be animated on a timeline at all -- motion is physics or a
single SwiftUI easing, so "the ear droops, then the body settles" is
unsayable;
* and seeing any change costs ~5 minutes, a Mac with Xcode, and an engineer.
ANIMA. Foundation and CoreGraphics only in the core -- the GameSimulation
discipline, for a bigger payoff: because sampling is pure, the same sample is
drawn on the glass, asserted in a test, and shipped to a browser previewer.
AnimaCurve easings and keyframes. anticipate/overshoot/settle are named
because they are what makes motion read as performed, and are
what easeInOut cannot say.
AnimaShape seven parametric primitives, emitting OUTLINES rather than
Paths. This is the load-bearing decision: it keeps the core
UI-free and it lets the previewer fill the same polygon the
phone fills, so there is no second implementation to drift.
AnimaFigure parts, hierarchy, poses. Squash and stretch is ONE field, not
two scale channels -- with two, a channel swinging about zero
does not return to rest and a bouncing object slowly shrinks.
AnimaClip timelines. Follow-through is one number: lag delays a part and
everything above it in the hierarchy.
AnimaVoice synthesis as data. Every one of the ten hard-coded voices is
expressible; four are written in the library as proof. The
anti-laser floor, the raised-cosine attack and the loudness
ceiling are inherited from PopSoundEngine and enforced here so
a data-authored voice cannot break them.
AnimaStudio JSON export, written by an opt-in test -- no new target, no new
scheme, and CI writes nothing.
THE PREVIEWER CANNOT LIE, and that is structural rather than careful. It
contains no easing, no interpolation, no transform maths and no oscillator: it
is handed poses and PCM and it fills polygons and plays samples. A test pins
every exported coordinate to the app's own pose at 1e-9.
Two bugs the work found in itself before the compiler could: lag was only
delaying a part's own tracks, so a part with no tracks (most of them) moved in
perfect lockstep with its parent and the whole figure moved as one plate; and
the lantern's light did not descend from its root, so `wake` scaled the body
and left the light full size. Both are now invariants with tests.
NOTHING IS WIRED INTO GAMEPLAY. Guardrail 5 -- the tuning is sacred and
changes go in one at a time. docs/anima.md section 6 sets out the adoption
order, cheapest and lowest-risk first.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFYT11e2Gadw4ZmgBpE9Q6
Two pieces of scaffolding an autonomous loop needs before it can author a hundred assets. THE BACKLOG (docs/anima_backlog.md) is the loop's memory: ordered, checkboxed, one item per iteration, CI-red-is-the-iteration. Nothing else carries state between iterations, so the plan survives a fresh context. It opens with a measured finding rather than a task. The exporter writes a full 64-point outline per part PER FRAME, which is 1.50 MB per object and therefore 150 MB for a hundred -- unshippable, un-openable, and it would blow the 12 MB test cap somewhere around the ninth asset. Four changes get ~55x: an affine matrix per part per frame with each outline exported once, 4dp rounding, 32-point export outlines, 24 fps. The drift rule survives it intact -- export the RESOLVED matrix, so the previewer's only arithmetic stays `x' = a*x + c*y + e` and `exp` never appears on both sides of the fence. THE WORKFLOW exists because the macOS runner is the only toolchain in this picture. library.json is produced by running the engine itself -- that is precisely why the previewer cannot drift -- so generating it needs Xcode, which neither a content author nor the agent writing the content has. It always uploads the export as an artifact (no repository settings required) and additionally publishes to Pages on main, with the deploy job skipped rather than failed when Pages is not enabled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XFYT11e2Gadw4ZmgBpE9Q6
CI red is the iteration. The new workflow failed in the same second it was created, with no job attached and no logs to read -- the signature of a workflow that never parsed rather than one that ran and failed. GitHub also displayed the run as its own filename instead of `Anima Studio`, which is the tell that the `name:` key was never read. Cause: the `Report the export` step embedded a multi-line `python3 -c "..."` whose body sat at column 0. That terminates the YAML block scalar the `run:` is inside, so everything after it parsed as top-level keys. Indenting the Python would have fixed the YAML and broken the Python, which rejects a uniformly-indented -c script -- so it is one line now, with a comment saying why it may not become three again. Also added a parse check over every workflow in the repository, which is the cheap thing that would have caught this before the push. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XFYT11e2Gadw4ZmgBpE9Q6
…the variable THE ENGINE COMPILES. Buried under a red job: ** TEST SUCCEEDED **, six AnimaStudioTests executed, five passed, none failed. ~2,600 lines of Swift that had never met a compiler built clean on the first uncancelled run. The job then failed on the step that looks for library.json, because testWriteTheStudioExport had SKIPPED. The workflow set ANIMA_EXPORT_DIR on the xcodebuild step, but the test runs inside the iOS Simulator in a process that does not inherit the invoking shell's environment. xcodebuild forwards only variables prefixed TEST_RUNNER_, stripping the prefix on the way in. What makes this worth a careful fix rather than a one-word patch is that it fails QUIETLY: the test skips, the test run stays green, and the export simply never appears. Nothing says the variable was ignored. So: the workflow now exports TEST_RUNNER_ANIMA_EXPORT_DIR; the test reads either spelling and says which one it wants when it skips; the "wrote nothing" error names this as the likely cause; and the same wrong command in the test's doc comment, docs/anima.md and the previewer's header -- all three of which would have sent a human into the identical wall -- is corrected. Also: the test now creates the export directory rather than assuming it, so a first run produces the export instead of a write error. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XFYT11e2Gadw4ZmgBpE9Q6
CI measured what the backlog predicted. Format 1 wrote 9,253,479 bytes for SIX objects -- 1.54 MB each against an estimate of 1.50 -- so a hundred assets really would have been ~154 MB. The 8 MB gate caught it on the sixth asset rather than the hundredth, which is what the gate was for. FORMAT 2. Each part's REST outline is written once; each frame carries only the resolved affine matrix and opacity. Seven numbers per part per frame instead of a hundred and twenty-eight, plus 4dp rounding and a 32-frame cap per clip. About 30 KB an object, so a hundred fits in roughly 3 MB. The matrix comes from AnimaTransform.affine, and AnimaTransform.apply now routes through it, so the app and the export are not merely consistent -- they are the same arithmetic, because there is only one copy of it. THE DRIFT CLAIM IS NOW SMALLER AND IS WRITTEN DOWN AS SMALLER. The previewer does one affine multiply it did not do before. It still has no easing, no keyframe interpolation, no hierarchy, no lag and no exp -- every one of which is a place two implementations plausibly disagree; what is left is arithmetic a reviewer checks by eye. The verification test was rewritten to do exactly what the page does -- matrix times rest outline -- and hold it against the app's own posed outlines at 0.002, which is the rounding rather than slack. docs/anima.md now says all of this instead of claiming the page contains no geometry at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XFYT11e2Gadw4ZmgBpE9Q6
The two failures were mine and they were the same mistake twice: format 2 bumped the exporter and left the shape test asserting "anima-studio/1". Both assertions, nothing else -- and everything that actually mattered passed, including testExportedFramesReconstructTheApplicationsOwnPoses, which is the one that proves an affine matrix and a rest outline really do reproduce the app's own posed geometry. Format 2 is sound. Rather than edit two literals, the class of bug is gone. The revision now lives once, as AnimaStudio.revision, with formatName derived from it, and the test asserts the export against those constants instead of against numbers typed a second time. The third copy is the interesting one. tools/anima-studio/index.html holds the same number in JavaScript, where no Swift test was looking, and an OLD PAGE AGAINST A NEW EXPORT DOES NOT FAIL: under format 2 a frame stops being a list of parts and becomes a flat list of numbers, so the page would draw nonsense, silently, to an author with no reason to distrust it. That is exactly the "previewer quietly lies" failure the whole design is meant to make impossible, arriving through the back door. So testThePreviewerExpectsTheRevisionTheExporterWrites reads the previewer via #filePath -- the path of the test's own source at compile time, so it can find the repository without being told where it is -- and fails if the two numbers disagree. Skipped, not failed, when the source tree is not reachable from the test bundle. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XFYT11e2Gadw4ZmgBpE9Q6
…eir invariant 437 tests ran; 16 failures, all in Anima, everything else green -- the whole pre-existing suite, WorldRegressionTests 14/14, WorldRenderTests 20/20. BUG 1: CLAMPING IS NOT TOTAL UNDER NaN (14 failures, one per easing). AnimaEase.shape claimed in its own doc comment to be total and was not: min(max(.nan, 0), 1) is .nan in Swift, because every comparison against NaN is false and both clamps pass their input straight through. The engine would have propagated a NaN time into a transform, where it does not crash -- it silently stops drawing the part, which is the hardest failure of this kind to trace back. Guarded in all three places on the time path (shape, curve sampling, clip localTime); a non-finite time now answers the rest pose. BUG 2: AN INSTRUMENT WHOSE LOUDNESS WAS A FUNCTION OF ITS PITCH (1 failure). Reproduced by porting the synthesis loop to Python rather than guessing: `breath` peaked at exactly 1.0 -- on the clamp -- at 180 Hz and 0.096 at 1200 Hz. The cause is that an all-pole resonator's peak gain rises steeply as its centre nears DC: measured 590x at a 396 Hz centre against 91x at 2640 Hz, so a fixed output scale was tuned for one frequency and wrong at every other. The clipping was only the symptom; the defect underneath is a synthesiser where pitch and volume are the same knob. Fixed by dividing by the resonator's own computed peak magnitude, which gives unit gain at any centre and turns the output scale into an actual level (bandLevel = 6.0, chosen by measuring the worst case across six pitches and five noise seeds at 0.34 -- where the pitched voices already sit). Measured after: breath 0.126-0.216, a 1.72x spread against 10x before. And the invariant that should have caught it is now a test: testNoVoicesLoudnessDependsOnItsPitch, gated at 3x across the musical range. All eight voices pass it -- verified numerically before pushing, worst ratio 1.72. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XFYT11e2Gadw4ZmgBpE9Q6
… computed CI green on e2f266a -- world, classic and the export all pass, and the export artifact came out at 154,501 bytes zipped for six objects, so E1 is verified in production rather than in arithmetic. E2. AnimaClip.reduced, computed rather than authored: it cannot go stale when a clip is retimed and an author cannot forget to write one. 04 section 11 asks two things that pull in opposite directions -- every motion needs a reduced variant, and none may carry information -- so the two kinds of clip reduce differently, and the split is the whole design: * A LOOPING IDLE REDUCES TO STILLNESS, structurally, by carrying no tracks at all. "A reduced idle is still" then holds exactly instead of to within a tolerance. Its opacity goes with it, which is not an oversight: SkyView already settled this for the stars -- "the breath is an affordance, never information, so removing it may not also dim them" -- so a reduced idle is fully lit and still, not held at some mid-pulse dimness. * A ONE-SHOT KEEPS ITS OPACITY EXACTLY and damps everything else to 35%. Here the opacity IS the information: a part fading to nothing in `release` is the whole message, and damping it would lose meaning, which is the second clause. Not damped to zero either -- deleting the motion deletes the feedback and trades an accessibility problem for a usability one. The three direction-reversing easings flatten to easeInOut. A reversal is what a vestibular system objects to, far more than distance travelled. Five tests, and they measure PEAK TRAVEL FROM REST over the whole clip rather than inspecting curve values -- which would only have proved the arithmetic, not the result. One of them asserts a reduced one-shot still does something, because a variant that does nothing passes every other check here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XFYT11e2Gadw4ZmgBpE9Q6
…half CI green on 1a06228, Reduce Motion tests included. E3 binds Anima to the pop paradigm. AnimaPop.object(for:) answers a drawable object for every one of the hundred catalogue pops -- wearing that pop's own paints, never invented ones, and played on the instrument its own definition asks for. THE FOURTH FAMILY SIGNATURE. PopFamily already carried three -- voice, hapticPattern, burst -- and they are why the catalogue is coherent rather than a hundred unrelated noises. This adds SILHOUETTE, on exactly the same terms, and it is the most immediate of the four because shape reads before colour, at arm's length, in the dark. Ten forms: a disc with a moon, a flame and its sparks, a drop over a ripple, petals about a heart, a radial crystal, a hanging bar, a body with a handle, a streamer, a hard shard with a beam, stacked open bands. A test computes each family's structural fingerprint -- part count plus primitive kinds -- and fails if any two match, because two families that cannot be told apart as black shapes are one family. Verified separable before pushing. THE TEN NOTES INSIDE A FAMILY come from AnimaVariation: four knobs whose meaning each family's builder decides for itself. Not named for shapes on purpose -- a knob called `earLength` is meaningless in nine families out of ten and the set would grow until nobody could hold it. Unauthored pops derive a variation from their own number, deterministically, so all hundred are previewable NOW and each phase-A batch replaces derivation with intent rather than filling a blank. VOICE COVERAGE had to be finished for the mapping to be total: tone, pluck, crackle and shimmer added, each verified numerically against every voice invariant -- no clipping, not silent, loudness not tracking pitch -- before a line of Swift was written. voice(for:) has no default:, so a new SoundVoice case stops the build instead of quietly making two families sound alike. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XFYT11e2Gadw4ZmgBpE9Q6
CI green on a6fdf54, including the tests that generate and validate all hundred pops. THE GALLERY. tools/anima-studio/index.html is now a hub page: all hundred catalogue pops plus the six reference figures, grouped by family, filterable by family and rarity, searchable by name, number and flavour, each tile carrying its number, rarity and flavour line beside the animation. Three things it needed beyond the page. VOICES BECAME A TABLE (revision 3). A hundred pops share ten instruments, so inlining the PCM per object shipped each instrument about ten times -- some 2.5 MB of duplicated audio, most of the file, for nothing. Written once at the top level and referenced by name, the audio is ~350 KB however many objects there are. A test asserts the table and its references agree in both directions: a dangling name draws a silent card, an orphan entry is dead weight in a file that has to stay openable. EVERY PERFORMANCE SHIPS ITS REDUCED VARIANT beside it, and the page has a toggle for them. 04 section 11 is not only a property of the app: an author reviewing a hundred assets has to be able to see what someone who asked for less motion will actually get, and the only honest way to show that is the engine's own `reduced`, exported through the same path rather than approximated in JavaScript. A reduced idle carries no tracks, so frameCount now answers ONE for a trackless clip -- thirty-two identical frames is a third of the gallery otherwise. ONLY VISIBLE TILES ANIMATE, via IntersectionObserver. A hundred canvases redrawing at once is a lot of somebody's laptop for no benefit when most of them are off screen. Still dependency-free, and the page's only arithmetic is still the one affine multiply. Size estimated before pushing rather than discovered by the gate: 2.16 MB for all 106 objects against an 8 MB ceiling, 18 KB an object against 120 KB. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XFYT11e2Gadw4ZmgBpE9Q6
All three failures were mine and all three were the same root cause: E4 changed
export()'s default from the six reference figures to the whole 106-object
gallery, and three assertions still described the old shape.
* the object-count assertion compared against AnimaLibrary.objects.count;
* the size test divided total bytes by six rather than by what was actually
exported, overstating per-object cost about seventeen-fold and reading as
a real regression rather than a stale denominator;
* the reconstruction test indexed clips POSITIONALLY, which broke the moment
each performance began shipping a reduced variant beside it -- clip 1 now
sits at position 2, so it was comparing `wake` against `breathe (reduced)`
and reporting drift that was really a mis-pairing.
The third is the one worth the care. Fixed by looking clips up BY NAME, which
is index-independent and cannot silently re-pair itself again the next time
the export grows a per-clip companion. The other two now derive from
AnimaStudio.galleryObjects, so they describe whatever the export actually is
rather than what it was when they were written.
No engine change. The measured size was right: the gate the size test guards
was never in danger.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFYT11e2Gadw4ZmgBpE9Q6
…t is not mine to fix CI green on 9308387. The export measured 673,791 bytes zipped for the full 106-object gallery. VERIFYING THE PAGE, which no Swift test can do because the page is JavaScript. Driven in headless Chromium against a hand-built revision-3 fixture: 100 cards rendered, 10 family groups and buttons, search narrows to 1, the frost filter to 10, the Reduce Motion toggle moves all hundred onto a (reduced) clip, and 16 of 100 canvases painted -- which is the IntersectionObserver doing its job, not a defect, since only visible tiles animate. It found two real defects. A missing favicon: the browser requests /favicon.ico unprompted, gets a 404, and logs a console error on a page whose entire value is being trusted. And "1 instruments". Both fixed; the smoke run is now clean. CI also syntax-checks the page now. A JavaScript error there fails nothing upstream -- it ships a BLANK GALLERY, to an author with no way to tell a broken page from an empty library. `node --check` over the extracted <script> costs nothing (the runner has node) and adds no repository dependency. The Chromium run is deliberately NOT in CI: wiring it up means adding Playwright to a repository whose first rule is zero dependencies. It is documented as a procedure to repeat when the page changes. WHAT IS STILL BLOCKED, stated rather than ticked away: 1. GitHub Pages is not enabled (Settings -> Pages -> Source: GitHub Actions). The publish job skips rather than fails without it, so nothing else is held up, but there is no public URL until it is flipped. 2. The publish job runs only on main -- correct, since a PR branch must not overwrite a live site -- so the deploy path itself is unproven until this branch merges. Neither is something the loop can resolve, so E5 is ticked as "built and verified as far as it can be from here", with both named in the backlog. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XFYT11e2Gadw4ZmgBpE9Q6
… lines Phase E is complete and green. Phase A begins: replacing derived variations with intent, a family at a time. Vesper's silhouette is a body with a companion, so trait is HOW FAR the companion sits, accent is HOW LARGE AND HIGH, tilt is the angle of the pair. Each of the ten is authored to the line the catalogue already gives it rather than spread evenly across the range -- an even spread is a gradient, and a gradient is not ten things: #3 Eventide dead level; water finds its level and that is the line #4 Halflight the exact midpoint on both axes; being between IS it #7 First Star far and tiny -- distance PLUS smallness is what makes a point of light read as a star rather than a moon #10 Last Light the companion nearly rivals the body and will not leave #1 IS PINNED. It is the reference implementation of the game's look, so it is the most orb-like of the hundred: companion tucked in, reach exactly 1.00 against a plain disc's 1.0. Guardrail 5 now has a test rather than a comment. Three new authoring tests, all measuring rather than asserting: * two pops closer than 0.08 in their family's variation plane are ONE POP DRAWN TWICE, which nobody reviewing a hundred tiles would catch by eye (closest actual pair: 0.13); * an authored family whose silhouettes span less than 0.15 of reach is one shape at ten sizes, so the batch changed nothing an eye could see (actual span: 0.33); * an authored key that is not a real catalogue number is silent -- it authors a pop that does not exist while the one that does keeps its derived shape. All numbers verified numerically before pushing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XFYT11e2Gadw4ZmgBpE9Q6
A1 green. Ember is a flame with sparks leaving it: trait is how pointed the flame is, accent how far and large the sparks fly, count how many, tilt the lean. THE FAMILY'S OWN LINE CONSTRAINS THE WHOLE BATCH. "Warm, not burning. There is a difference." is #11's flavour, but it governs all ten -- nothing in this family is a blaze, and the widest reach here (Bonfire, 1.37) is barely past Vesper's widest (1.33). A family whose members escalate is not a family. Authored to the lines rather than spread: #13 Hearth upright and evenly ringed -- a hearth is level, nothing about it leans #014 Cinder blunt, fallen over, sparks scattered: spent and still going #016 Slow Burn long and tight; the flame that is going nowhere #017 Marigold radial and even, so it reads as petals rather than sparks #020 Solstice the longest flame and the most turned of the ten -- the line "it turns here" names its own tilt Verified before pushing: closest pair in the variation plane 0.16 (gate 0.08), reach spread 0.32 (gate 0.15), eight distinct tilts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XFYT11e2Gadw4ZmgBpE9Q6
…as right about
A2 green.
THE TEST CAUGHT A REAL DEFECT, IN THE BUILDER RATHER THAN THE CONTENT. Tide's
`accent` moved only the ripple's SWEEP, so every one of the ten had an
identical silhouette extent and the spread gate would have failed the batch.
The content was not the problem and neither was the gate. A wider swell
genuinely reaches further -- "High Tide: everything the sea meant to say at
once" must visibly out-reach "Lagoon: shallow enough to see your own feet",
and sweep alone cannot say that. So `accent` now moves the ripple's size and
offset as well, which is what the family meant all along. Reach spans
1.05-1.48 where it previously spanned nothing.
This is the second time a test written for one purpose has found something
better than it was looking for: the gate exists to stop ten pops being one
shape at ten sizes, and it turned out to be measuring whether the FAMILY had
been given a real axis of variation at all.
Authored to the lines:
#023 Seaglass the bluntest drop in the family -- softened is the line
#024 Driftwood come to rest at an angle, which is how driftwood lies
#025 Moonpull a small sharp drop over a very wide swell; the gentle thing
is the small one
#027 Deepwater the sharpest drop, dead level -- still is not slack
#030 High Tide the widest swell of the hundred so far
Verified before pushing: closest pair 0.16 (gate 0.08), spread 0.43 (0.15).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFYT11e2Gadw4ZmgBpE9Q6
A3 green.
WHICH KNOB LEADS IS A PER-FAMILY DECISION, and bloom is the family where
`count` does. Three petals and nine are different objects at a glance, in a
way that a length change simply is not, so these ten spread across SEVEN
distinct petal counts (3,4,5,6,7,8,9) rather than crowding one number and
varying size. In vesper the same knob was inert; here it carries the batch.
That is the argument for four abstract knobs rather than named ones. A field
called `petalCount` would have been dead weight in nine families; `count`
means whatever the family's builder needs it to mean, and bloom needs it to
mean everything.
Authored to the lines:
#033 Clover exactly three, round rather than pointed -- the COUNT is
the line, and nothing else needed to say it
#034 Willow long sharp petals under the hardest lean in the family;
"bend, it is not the same as breaking" IS the lean
#035 Meadow many small petals -- a meadow is a lot of small things,
not one big one
#037 Fern longest, sharpest, fewest: a frond, not a flower
#039 Late Spring the fullest count here; "nothing is missing" is a number
#040 Lotus broad, round, perfectly level -- untroubled drawn as level
Verified before pushing: closest pair 0.14 (gate 0.08), reach spread 0.29
(gate 0.15), seven distinct counts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFYT11e2Gadw4ZmgBpE9Q6
One failure across 459 tests: bloom's ten silhouettes spanned exactly zero. Every one measured 1.00 -- the floor. THE CAUSE IS A RENDERING DEFECT, NOT A MEASUREMENT ONE. A limaçon petal's fat end sits at local theta = pi, its -x side. Bloom rotated each petal by the same angle as its offset, which turns that fat end back toward the centre, so every bloom folded its petals over its own heart -- and because the offset magnitude and the scale are equal, the tips landed exactly on the origin. The family drew at a fraction of its intended size, and all ten measured identically because the floor swallowed the difference. Checking the other families for the same class of error found two more: ember's flame and tide's drop were both lying on their sides, since an unrotated petal points its mass at -x. A flame's mass is at its base and it tapers upward; a drop's fat end falls toward the ripple beneath it. Both now stand up. AND A CORRECTION TO MY OWN METHOD. I had been verifying each batch against an analytic reach estimate that assumed a primitive's furthest point lies along its offset. For a petal that is exactly backwards, so the estimate was wrong for every family that places one -- three of the four authored so far. The estimate said bloom spanned 0.286; the truth was 0.000. Verification is now a faithful port of restReach -- outline sampling, the transform stack, the parent chain -- so it computes what Swift computes rather than what I believe Swift computes. Re-run across all four authored families after the fix: vesper 0.325, ember 0.316, tide 0.429, bloom 0.286, all past the 0.15 gate, and #1 still pinned at exactly 1.000. A test written to stop ten pops being one shape at ten sizes found a family drawn inside out. That is the second time this gate has been more useful than its own description. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XFYT11e2Gadw4ZmgBpE9Q6
… left the floor
A4's orientation fixes are green.
FROST HAD BLOOM'S FAILURE BY A DIFFERENT ROUTE, and the faithful port caught
it before it cost a CI cycle. A spoke's `scale` shrinks its LENGTH as well as
its thickness -- length is in units of the capsule's own radius -- so a scale
of 0.13 on a length-3 capsule is a 0.2-unit stub. The "radial crystal" never
left the 1.0 reach floor, and all ten members measured identically because the
floor swallowed every difference. A long thin spoke needs a LARGE length and a
modest scale, not a small scale on a short one. Reworked: reach now spans
1.07-1.37 with nothing on the floor.
The same run also caught two of my authored points sitting 0.071 apart, inside
the 0.08 separation gate. Both fixed before pushing rather than after.
Authored to the lines:
#042 Firstsnow shortest and finest -- harmlessness drawn as slightness
#044 Glacier longest and thickest, and only four: mass, not speed
#045 Snowmelt short and thick, tipping -- loosening drawn as thickening
#046 Icelight the thinnest spokes of the hundred
#047 Winterglass three broad panes: broad enough to be a window rather
than a lattice
#049 Stillness dead level, perfectly even -- the stillest thing here
THE PORT IS NOW CHECKED IN as tools/anima-reach.py, with a header saying what
it is and what it is not: a development tool for verifying a batch before it
costs a cycle, not a second implementation of anything shipped. Nothing
imports it and the Swift tests remain the authority.
Also lands the A4 backlog note that failed on a bad anchor last iteration, and
a "how to verify a batch" section recording why an analytic estimate must not
be used again.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFYT11e2Gadw4ZmgBpE9Q6
… the same family
A5 green.
CHIME HAD NINE OF ITS TEN ON THE 1.0 REACH FLOOR, caught before pushing. The
cause is the same shape as frost's and it is worth naming properly, because it
has now appeared three times in three different families:
A PRIMITIVE'S `scale` IS NOT A SIZE KNOB. It multiplies the primitive's own
parameters. For a capsule, `scale` is the RADIUS and `length` is measured in
radius units -- so scaling a bar down to make it thin makes it short as well,
and the two ideas a chime needs ("long and slender", "short and thick")
collapse into one. Bloom hit the same wall through rotation, frost through
length-in-radius-units, chime through both at once.
Fixed by authoring the two quantities the family actually varies -- a
thickness and a half-length -- and DERIVING the capsule's `length` by
dividing. Reach now spans 1.20-1.48 with nothing floored.
Authored to the lines:
#052 Belltone shortest and thickest -- as close to round as a bar gets
#053 Windbell thin and swinging hardest; the breeze IS the tilt
#054 Carillon small, turned the same way as its neighbours -- agreement
drawn as a shared angle
#059 Glasschord the longest and thinnest: fragility is the ratio, not the
size
Verified against the checked-in port before pushing: closest pair 0.16 (gate
0.08), spread 0.28 (gate 0.15), nothing on the floor.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFYT11e2Gadw4ZmgBpE9Q6
…is at all
A6 green.
I probed lantern's reach BEFORE authoring it rather than after, which is the
habit the last three batches taught, and it came back CONSTANT: 1.160 at every
corner of the knob range. Sides were driven by `trait` and roundness by
`accent`, and neither of those is an extent -- the handle sits at a fixed
offset and dominates everything.
This is a fourth defect and a new shape of one. Frost, bloom and chime were
all drawn WRONG. Lantern was drawn correctly and had no axis of variation that
an eye could measure: ten members differing only in facet count read, at a
glance, as one object drawn ten times.
Sides moved to `count` -- which is the knob for "how many of something", and
lantern was the only family not using it -- and `trait` became a real size.
Reach now spans 1.06-1.44 across six distinct side counts.
Authored to the lines:
#062 Firefly the smallest of the hundred so far; smallness is the
entire claim
#063 Paper Lantern the roundest thing in the family, barely a case at all
#064 Candlewick four sides is as close to a line as this family gets
#065 Porchlight boxy, four-square, level -- a porch fixture is a box, and
it is exactly where you left it
#069 Nightmarket large and leaning: the posture of having nowhere to be
#070 Beacon largest, hardest-edged, dead level -- staying put is the
whole line
Verified against the checked-in port: closest pair 0.13, spread 0.38, nothing
on the floor.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFYT11e2Gadw4ZmgBpE9Q6
…id not contain MY PREVIOUS COMMIT PUSHED A FALSE CLAIM. It ticked A7 in the backlog and described the lantern rework in its message, but contained neither: the edit script's first anchor did not match (it used an `at(...)` helper form the file does not use), the assert aborted before any write, and only the separate backlog edit reached the commit. The repository then said lantern was authored while AnimaPop.swift still ended at #060. That is worse than a failed edit, because the record disagreed with the code and nothing in CI would have said so -- the backlog is prose. Landing the real change now, verified by grep after each step rather than by an assert alone: the lantern builder is reworked and #061-070 are present (10 entries). WHAT THE CHANGE IS. Probing lantern's reach BEFORE authoring -- the habit the last three batches taught -- returned a CONSTANT 1.160 at every corner of the knob range. Sides came from `trait` and roundness from `accent`, and neither is an extent; the handle sits at a fixed offset and dominates. This is a fourth defect and a new shape of one: frost, bloom and chime were drawn WRONG, while lantern was drawn correctly and simply had no axis an eye could measure. Ten members differing only in facet count read as one object drawn ten times. Sides moved to `count` -- the knob for "how many of something", and lantern was the only family not using it -- and `trait` became a real size. Reach spans 1.06-1.44 across six distinct side counts, nothing on the floor, closest pair 0.13. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XFYT11e2Gadw4ZmgBpE9Q6
… no fix
A7 green with the real code in.
Probed the reach range before authoring, as now standard. Current came back
HEALTHY -- 1.00 to 1.54, with both knobs genuinely moving the extent. It is
the first of eight families to need no builder change at all, which is worth
recording as much as the four defects were: the pattern is not "every builder
is wrong", it is "a builder is wrong whenever a knob multiplies a primitive's
own parameters rather than adding to its extent".
The only care needed was avoiding the low-trait/low-accent corner, which draws
shorter than a plain orb and lands on the 1.0 floor where members stop being
distinguishable. One entry sat exactly on it at 1.0000 and was lifted to
1.0065 before pushing.
Authored to the lines:
#072 Spark Gap the shortest trails here and only two: a jump, not a
journey
#073 Filament long, and the thinnest trails in the family -- the
thinness is the whole ask
#075 Circuit even and dead level; "nothing strained" drawn as nothing
tilted
#077 Violetvolt the widest trails of the family -- kindness drawn as
softness, not as smallness
#079 Arclight longest and spare: a bridge is a span, not a crowd
#080 Stormglass the secret one, and the largest reach of the hundred
Verified: closest pair 0.15, spread 0.50 (the widest of any family), nothing
floored. Code presence confirmed by grep before committing, after the previous
iteration pushed a claim its commit did not contain.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFYT11e2Gadw4ZmgBpE9Q6
…econd time
A8 green.
Probed before authoring: prism measured EXACTLY 1.000 at every corner of its
knob range. Two compounding causes -- the beam never reached past the body
(its `scale` is a radius, so a thin beam is a short one), and the body itself
was smaller than a plain orb, so the floor hid both.
Same fix as chime, and it now has a name worth reusing: author the THICKNESS
and the HALF-LENGTH, derive the capsule's `length` by dividing, and centre the
beam half its own length along its heading so it emerges from the body instead
of sitting inside it. Reach 1.19-1.43, nothing floored.
That is five families of eight needing a builder fix, and the split is
informative rather than embarrassing: every one was a knob that MULTIPLIED a
primitive's own parameters instead of ADDING to its extent. Current, the one
family that needed nothing, composes additively.
Authored to the lines:
#083 Opal a large body and barely a beam -- it is a stone first, and
the colour is what it does inside
#086 Moonbow the smallest body and the longest beam: almost all throw,
almost no stone
#087 Facet the most sides -- the line is a count, so it is drawn as one
#088 Kaleido the most turned of the ten; the instruction is the shape
#090 Prismheart large and three-sided: a true prism, the shape the family
is named for
Verified before pushing, and code presence confirmed by grep after writing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFYT11e2Gadw4ZmgBpE9Q6
A9 green. Aurora floored at exactly 1.000 across its whole knob range, like prism before it -- but for the simplest reason of the six: the body arc was pinned at 0.9 scale, inside a plain orb, so the reach floor swallowed the family. The only one of the six defects that was just "the object is too small" rather than a knob multiplying a primitive's own parameters. `trait` is now the size and `accent` the sweep; reach 1.03-1.26, nothing floored. #100 MORNING STAR -- "Vesper's other name. It was you all along." -- is the largest and narrowest sweep in the family, which is the closest an arc comes to closing into the circle that #1 is. The catalogue ends where it began, and the shape says so without a word of it being special-cased. Authored to the lines: #092 Northglow two arcs, two truths -- the count is the sentence #094 Zenith the narrowest sweep, tall, dead level: straight up is not a tilt #096 Polaris the smallest here and immovable -- faithfulness drawn as not moving at all #098 Nebula large and soft, only two bands: no hurry drawn as very little happening #099 Afterglow small and wide, still going after the large ones finished ALL HUNDRED VARIATIONS ARE NOW PRESENT, 1-100, verified by parsing the file rather than by trusting the edit -- a habit earned two iterations ago when a commit claimed work it did not contain. Six of the ten families needed a builder fix, and the pattern held all the way through: a builder is wrong whenever a knob MULTIPLIES a primitive's own parameters instead of ADDING to its extent. None of it was visible without measuring. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XFYT11e2Gadw4ZmgBpE9Q6
All ten families verified independently against the checked-in port, on the finished library rather than batch by batch: family span min max closest vesper 0.325 1.000 1.325 0.130 ember 0.316 1.052 1.368 0.158 tide 0.429 1.053 1.482 0.158 bloom 0.286 1.002 1.288 0.141 frost 0.305 1.065 1.370 0.206 chime 0.280 1.200 1.479 0.164 lantern 0.382 1.058 1.441 0.130 current 0.504 1.006 1.511 0.149 prism 0.241 1.186 1.427 0.130 aurora 0.228 1.032 1.260 0.156 Gates are span > 0.15 and closest pair > 0.08; every family clears both with margin. Whole-catalogue reach 1.000-1.511, and #1 sits at exactly 1.000 -- the most orb-like of the hundred, as guardrail 5 requires. CI green on both configurations. Export 673,791 bytes zipped for 106 objects against an 8 MB gate. Hub page driven in headless Chromium: 100 cards, family grouping, search, filters, the Reduce Motion toggle, zero console errors. THE BACKLOG NOW CARRIES WHAT THE LOOP FOUND, because none of it was visible without measuring. Six of the ten builders were wrong and one rule explains every one: a builder is wrong whenever a knob MULTIPLIES a primitive's own parameters instead of ADDING to its extent. Two tests did nearly all the finding and neither was written for it. An analytic estimate hid a real rendering defect behind a plausible number for four batches. And a commit once claimed work its edit had silently failed to make. Z2 does NOT mark the PR ready for review: the user has not asked for that, and E5's two blockers -- Pages not enabled, publish gated to main -- are theirs to decide on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XFYT11e2Gadw4ZmgBpE9Q6
GLESAV
marked this pull request as ready for review
August 29, 2026 05:40
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Anima — a 2-D feature animation engine for Vesper
A zero-dependency, pure-Swift engine for authoring Vesper's own animated objects, their sounds, and their silhouettes — plus a browser hub page that previews the whole library. Everything here is generated from the repository's existing pop data; no assets are imported and no dependency is added.
What it is
Vesper/Anima/— pure (Foundation/CoreGraphics only, no SwiftUI, time arrives asdt), deterministic, unit-tested:AnimaShape— 7 closed-polyline primitives (orb, petal, capsule, arc, shard, ring, drop), sampled at 64 points.AnimaFigure— parts in a parent/child hierarchy;AnimaTransformreduces to a singleAnimaAffine. Squash is area-preserving viaexp(), so squash and stretch are exact inverses.AnimaCurve/AnimaEase— keyframe tracks with anticipate / overshoot / settle easing; total on non-finite input.AnimaClip— poses a figure at a time. Follow-through comes fromlag: time travels up the hierarchy, so lag accumulates down a chain without any per-part bookkeeping.AnimaVoice— declarative synthesis: partials (ratio/gain/decay/detune) plus a noise band, rendered offline. The all-pole resonator is normalised by its computed peak magnitude, so a low-pitched voice no longer clips while a high one is inaudible.AnimaRenderer— three-pass Canvas draw (halo, bodies, highlight); never strokes, so nothing reads as harsh.Reduce Motion is a computed variant (
clip.reduced), not a second authoring path: loops become empty tracks and expressive eases flatten to easeInOut.The hundred assets
AnimaPop.swiftbridges the engine to the existing pop paradigm. Each of the tenPopFamilyvalues gets a builder; each of the 100 pops inPopCatalogcarries anAnimaVariation— four abstract knobs (trait,accent,count,tilt) whose meaning the family's builder decides. That yields a silhouette per pop as a fourth signature alongside the existing voice, haptic pattern and burst, with all 100 authored and asserted.The hub page
tools/anima-studio/index.htmlis a static previewer with no build step and no network calls. The exporter (an opt-in test) writes the library as affine matrices — six numbers per part per frame rather than a full pose — which took the export from 154 MB to 674 KB zipped for 106 objects, while an anti-drift test still reconstructs the application's own poses from the export.anima-pages.ymlruns the export, gates its size,node --checks the page's script, uploads the artifact, and (onmainonly) deploys to Pages.What the loop's gates caught
Two authoring gates — silhouette spread > 0.15 across a family, and closest pair > 0.08 — found that six of the ten family builders were wrong, and one rule explains every case: a builder is wrong whenever a knob multiplies a primitive's own parameters instead of adding to its extent.
bloomcollapsed to spread 0.000 — petals rotated byapointed their fat end inward, so every tip landed on the origin.frost,chime,prismwere floored at 1.000 — a capsule'sscaleis its radius andlengthis in radius units, so the size knob cancelled itself.lanternwas constant at 1.160 — it had no size axis at all.aurorawas exactly 1.000 — the body arc was pinned inside a plain orb.currentwas the only family that needed nothing; it composes additively.tools/anima-reach.pyis a checked-in faithful port ofrestReachso the measurement is reproducible outside Xcode.Final spans, all clearing both gates:
Pop #1's reach is exactly 1.000 — the v1.0 pop is unchanged, per guardrail 5.
Guardrails
No gameplay constant changed. No new dependency, no network call, no analytics. The palette stays muted; copy stays lowercase-calm.
GameConfig.swiftis untouched.Verification
CI green on
3b8c77d: Build & test (world), Build & test (classic), Export the library. Publish to Pages is skipped by design — the deploy job is gated tomain.Two things need a decision that isn't mine to make:
main, the deploy path stays unproven until this branch merges. The export artifact is downloadable from the workflow run in the meantime.Left deliberately undone, and written up in
docs/anima.md§6: no in-app authoring UI, and no runtime adoption — nothing in the shipping game readsAnima/yet. This PR adds the engine and the library; wiring it into the game is a separate, deliberate change.