Back to News & Insights
JavaScript October 5, 2026 · 6 min read

Snapshot-testing a particle effect in PixiJS 8: what has to stay fixed for the same frame twice

Particle effects are random on purpose, which makes them awkward to review. Two people look at the...

Snapshot-testing a particle effect in PixiJS 8: what has to stay fixed for the same frame twice

Particle effects are random on purpose, which makes them awkward to review. Two people look at the same pull request, see two different bursts, and can't tell whether anything changed.

NixieFX, the particle runtime I used here, says its simulation is deterministic: the same effect with the same seed replays the same way. I wanted to know exactly what "the same" requires in practice. So I stepped one effect to a fixed time under different conditions, captured the particle positions and the rendered pixels, and compared them.

Short version: the seed isn't enough on its own. You also have to fix the timestep.

Note: I'm involved with the NixieFX project. The runs were done in a Claude Code session, and this post was drafted with AI and reviewed by hand. Every number and image below comes from those runs.

Setup nixie-fx 0.1.17, pixi.js 8.22.0, WebGL renderer A Chromium 152 browser on macOS, one machine Canvas 320 × 240 (76,800 pixels), 1 effect unit = 60 px

The effect is a CLI-generated 2D effect, edited into a one-shot burst: 36 particles from a small circle, random speed 1 to 3 units/s, random lifetime 0.5 to 1.1 s, color and size over lifetime, additive blending.

effect create expects a vfx-editor.prj project file in the folder and errors without one. I wrote that small JSON file by hand.

getParticleDebugQuads() is the useful part. It returns the screen-space quad for every live particle, so you can compare the simulation without reading back a single pixel.

Baseline: seed 7, stepped to t = 0.35 s in steps of 1/60 s. Every other run changed one thing.

| Run (vs. baseline) | Particle state | Pixels changed | Max position drift | |---|---|---|---| | Same seed, same step, again | identical | 0 | 0 px | | Same, reusing one renderer for two effects in a row | identical | 0 | 0 px | | Seed 8 instead of 7 | different | 2,772 | 104.35 px | | Step 1/30 s instead of 1/60 | different | 1,915 | 3 px | | Step 1/120 s instead of 1/60 | different | 1,686 | 1.5 px | | One 0.35 s step | different | 1,584 | 59.93 px |

Same seed and same step gave byte-identical pixels, which makes this usable as a test. A different seed gives a visibly different burst, as it should.

The step size is the catch. Changing only the step from 1/60 to 1/30 moved particles by up to 3 px. You can't see that by eye, but every particle's pixels changed, so a pixel hash fails. Doing it in one big step moved some particles by almost 60 px.

That matters because the usual game loop passes ticker.deltaMS / 1000, which varies frame to frame. That's correct for playing the effect, and it means a live frame won't match a stored baseline. Tests need their own fixed-step loop like the one above.

Checking more than one time point is cheap and catches different bugs. At 0.70 s only 20 of the 36 particles were still alive, so a lifetime change shows up there even if the 0.35 s frame looks fine.

Turning it into a test Make the seed injectable. In the game, a random seed per trigger keeps every burst different. In tests, pass a constant. Store the particle state JSON (or its hash) for two or three time points next to the effect file. In the test page, re-run snapshot() with the same seed, step and times, and compare against the stored state. When the effect is meant to change, regenerate the baseline in the same commit, so the diff shows up in review.

I'd make the state JSON the main check and keep the pixel hash as a second, stricter one. The state comes from the simulation. The pixels also depend on the GPU and browser.

My first version of this effect also set gravity and drag. Its runs came out with exactly the same hashes as a copy with both set to zero. Looking at the runtime source, gravity is only sampled when the emitter's velocity module is enabled, and I had left that module off. The values were in the effect file and validation passed with no warnings, but the simulation ignored them. A snapshot test with a known baseline catches that kind of quiet no-op. Looking at the effect doesn't.

Limits One machine, one browser. I didn't compare across GPUs, operating systems or browsers. Pixel hashes may not match across them; I'd expect the particle-state JSON to travel better, but I haven't verified that. Rounding. I rounded state to 0.01 px and alpha to 0.001. Tighter rounding makes the test stricter and more fragile. 2D backend only. Everything here ran through the PixiJS adapter. The validator also flagged that depthTest and depthInk export as 2.5D draw-order rules on Pixi, not a real depth buffer, so don't expect depth-sorting settings to behave like they do in 3D. Per the docs, lit shading and mesh-surface emission aren't supported on the Pixi backend either. Needs a browser. The test page loads Pixi and renders through WebGL. Running it in CI means a headless browser; I didn't set that up for this post.

Want to discuss this further?

Book a free strategy call with our team to see how these insights apply to your specific business goals.

Book a consultation