Skip to content

Particles: pin the randomFrame contract, drop an unreachable clamp - #1730

Merged
obiot merged 1 commit into
masterfrom
feat/particle-frames-polish
Oct 11, 2026
Merged

obiot merged 1 commit into
masterfrom
feat/particle-frames-polish

Conversation

@obiot

@obiot obiot commented Oct 11, 2026

Copy link
Copy Markdown
Member

Follow-up to #1728, which merged without a CHANGELOG entry. Adds that entry, crediting @snowyukitty, plus two findings from reviewing the change.

The surviving mutation

#1728's suite is strong: of eleven mutations I ran against it, ten were caught. The one that survived was randomFrame particles animating instead of holding their frame, which is half of what that setting promises.

The existing test looks like it covers this. It mocks Math.random, reads the frame, updates, and reads again:

vi.spyOn(Math, "random").mockReturnValue(0.75);
// frames: [2, 1], randomFrame: true
expect(crop(p).slice(0, 2)).toEqual([8, 0]);
p.update(999);
expect(crop(p).slice(0, 2)).toEqual([8, 0]);

It cannot catch it. 0.75 picks the last of two frames, and the age-based index at 99.9% of a 1000ms lifetime is also the last frame. The two answers coincide, so the assertion passes whether the particle held its frame or animated to the end of the sequence.

Mocking 0.25 picks the first frame, which an animating particle leaves almost immediately. The mutation then fails on both backends, which is the point of the test.

The unreachable clamp

The frame index was wrapped in Math.max(0, Math.min(length - 1, ...)). Nothing can reach either bound:

  • timer.update already pins a negative delta to zero at the source (timer.ts:238, the long-standing fix for the startup timestamp some browsers report), so dt is never negative and life only shrinks.
  • A particle whose life reaches 0 returns before that line.

So ageRatio is in (0, 1], the floored progress is in [0, length - 1], and the clamp is dead code. Removed, with both of those facts written down next to it, because an unreachable guard is worse than an absent one: it reads as the thing keeping the index in range, and the two facts that actually do would be free to change out from under it.

A new boundary test guards the removal by walking a whole lifetime in 10ms steps and asserting every frame drawn is one of the sequence's own cells, with the first and last both reached. It catches round, ceil and an off-by-one on the length.

One documentation gap

randomFrame: true on its own does nothing, because the grid check keys off framewidth/frameheight/frames and a random choice needs a grid to choose from. Now said in both the skill and the setting's doc comment, along with the fact that the variety is in the frame rather than the colour, tint being one value per emitter.

Verification

  • Full suite: 325 files, 7907 passed, 10 skipped
  • pnpm lint: 0 errors (175 warnings, the unchanged baseline)
  • Four mutations against the two new tests, all four caught

🤖 Generated with Claude Code

https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t

Follow-up to #1728, plus its changelog entry.

Two test findings from reviewing it. The suite caught ten of eleven
mutations, and the one that survived was `randomFrame` particles
ANIMATING instead of holding their frame, which is half of what that
setting promises. The existing test looks like it covers this: it mocks
`Math.random` and re-checks the frame after an update. It cannot, because
`0.75` picks the LAST of two frames and the age-based index at 99.9% of
the lifetime is also the last frame, so the two answers coincide and the
assertion passes either way. Mocking `0.25` picks the first frame, which
an animating particle leaves almost at once, and the mutation then fails
on both backends.

The second is a clamp around the frame index that nothing can reach.
`timer.update` already pins a negative delta to zero at the source, so
`life` only shrinks and a particle whose life hits zero returns before
that line; `ageRatio` is therefore in `(0, 1]` and the floored progress
cannot leave the sequence. Removed, with the two facts that keep it in
range written down, and a boundary test that walks a whole lifetime and
asserts every frame drawn is one of the sequence's own cells. That test
catches `round`, `ceil` and an off-by-one on the length.

Also documents that `randomFrame` on its own does nothing, since the
grid check keys off `framewidth`/`frameheight`/`frames` and a random
choice needs a grid to choose from, and that the variety is in the frame
rather than the colour, `tint` being one value per emitter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
Copilot AI balanced review requested due to automatic review settings October 11, 2026 04:31

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@obiot
obiot merged commit 73baf73 into master Oct 11, 2026
6 checks passed
@obiot
obiot deleted the feat/particle-frames-polish branch October 11, 2026 04:40
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