Skip to content

Anima: a 2-D feature animation engine and 100 pop-paradigm assets - #10

Merged
GLESAV merged 25 commits into
mainfrom
claude/anima-engine
Aug 29, 2026
Merged

Anima: a 2-D feature animation engine and 100 pop-paradigm assets#10
GLESAV merged 25 commits into
mainfrom
claude/anima-engine

Conversation

@GLESAV

@GLESAV GLESAV commented Aug 28, 2026

Copy link
Copy Markdown
Owner

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 as dt), 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; AnimaTransform reduces to a single AnimaAffine. Squash is area-preserving via exp(), 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 from lag: 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.swift bridges the engine to the existing pop paradigm. Each of the ten PopFamily values gets a builder; each of the 100 pops in PopCatalog carries an AnimaVariation — 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.html is 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.yml runs the export, gates its size, node --checks the page's script, uploads the artifact, and (on main only) 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.

  • bloom collapsed to spread 0.000 — petals rotated by a pointed their fat end inward, so every tip landed on the origin.
  • frost, chime, prism were floored at 1.000 — a capsule's scale is its radius and length is in radius units, so the size knob cancelled itself.
  • lantern was constant at 1.160 — it had no size axis at all.
  • aurora was exactly 1.000 — the body arc was pinned inside a plain orb.

current was the only family that needed nothing; it composes additively. tools/anima-reach.py is a checked-in faithful port of restReach so the measurement is reproducible outside Xcode.

Final spans, all clearing both gates:

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

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.swift is 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 to main.

Two things need a decision that isn't mine to make:

  • GitHub Pages is not enabled on this repository (Settings → Pages → Source: GitHub Actions). Until it is, the deploy step has nowhere to publish.
  • Because the publish job is gated to 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 reads Anima/ yet. This PR adds the engine and the library; wiring it into the game is a separate, deliberate change.

claude added 25 commits August 28, 2026 17:35
… 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 GLESAV changed the title Anima — a 2-D animation engine for authoring objects, performances and sounds as data Anima: a 2-D feature animation engine and 100 pop-paradigm assets Aug 28, 2026
@GLESAV
GLESAV marked this pull request as ready for review August 29, 2026 05:40
@GLESAV
GLESAV merged commit 92048f3 into main Aug 29, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants