<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[NixieFX Notes]]></title><description><![CDATA[NixieFX Notes]]></description><link>https://nixiefx-notes.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sat, 07 Nov 2026 14:57:32 GMT</lastBuildDate><atom:link href="https://nixiefx-notes.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Five level-ups in one frame: what stacking does to a PixiJS portrait halo]]></title><description><![CDATA[One XP reward can grant five levels. Stacking five halo instances dropped label contrast to 2.01:1; one coalesced instance kept 10.4:1. Code and numbers.]]></description><link>https://nixiefx-notes.hashnode.dev/pixijs-level-up-halo-multiple-level-ups</link><guid isPermaLink="true">https://nixiefx-notes.hashnode.dev/pixijs-level-up-halo-multiple-level-ups</guid><category><![CDATA[pixijs]]></category><category><![CDATA[GameDev]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[WebGL]]></category><category><![CDATA[Accessibility]]></category><dc:creator><![CDATA[Steven Volkov]]></dc:creator><pubDate>Fri, 16 Oct 2026 16:44:08 GMT</pubDate><content:encoded><![CDATA[<p>Most level-up halo examples fire once. Real games often don't. A boss kill or a quest turn-in can push the player through several levels in a single frame, and the halo code written for one level-up gets called five times.</p>
<p>This post looks at that case for a halo drawn behind a character portrait, with a level label underneath and a Continue button below that. I compared four ways to handle repeated triggers and measured what each one does to the label, the particle count and the timing.</p>
<p>Setup for every number below: PixiJS 8.22.0, nixie-fx 0.1.21, headless Chrome 155 with SwiftShader WebGL, a fixed 1/60 s step and fixed seeds. The portrait is a placeholder drawing, not game art.</p>
<h2>The scene</h2>
<p>Layers, bottom to top:</p>
<ol>
<li>background (<code>#17131f</code>)</li>
<li>the halo, in its own <code>Container</code></li>
<li>the portrait, a 56 px radius circle, opaque</li>
<li>the label <code>LEVEL n</code> in <code>#ffe7a3</code>, 18 px bold, <strong>no backing plate</strong></li>
<li>a Continue button</li>
</ol>
<p>The halo is behind the portrait, so the opaque portrait hides its center and only a ring shows around it. It isn't behind the label, though. The glow's lower edge reaches about y = 220 px and the label sits at y = 182 to 212, so the label is drawn on top of part of the glow. The button starts at y = 236 and never overlaps it.</p>
<pre><code class="language-javascript">const haloLayer = new Container(), portraitLayer = new Container(), uiLayer = new Container();
app.stage.addChild(haloLayer, portraitLayer, uiLayer);

const vfx = new PixiVfxRenderer({
  parent: haloLayer,
  projection: createPixiVfx2dProjection({
    originX: 180, originY: 112, pixelsPerUnit: 100, yAxis: "up",
  }),
});
</code></pre>
<h2>The effect</h2>
<p>The halo was authored as NixieFX JSON and exported with <code>npx nixie-fx export .</code>. Two emitters, both one-shot, additive, gravity and drag set to 0 explicitly:</p>
<ul>
<li><strong>glow</strong>: one particle, 0.9 s lifetime, 1.5 units (150 px) growing to 1.45x, alpha 0 → 0.6 at 20% of its life → 0</li>
<li><strong>motes</strong>: a burst of 16 small particles on a 0.62-unit circle, moving outward at 0.25 to 0.5 units/s, 0.6 to 0.9 s lifetime</li>
</ul>
<p><code>nixie-fx validate</code> reported 0 errors and 0 warnings. The export's support report lists the effect as <code>supported</code> on the Pixi backend and on Three, with no unsupported or partial modules, so nothing in this effect is silently dropped. One instance peaks at 17 active particles.</p>
<p>If you're building this kind of effect with a web game particle editor such as NixieFX, the <a href="https://nixiefx.com/pixijs-particle-effects/">PixiJS particle effects guide</a> covers the export bundle, <code>loadVfxExportBundle</code> and the renderer setup used here. This post only covers what happens after the effect exists.</p>
<h2>How the label was measured</h2>
<p>For every frame I rendered the scene with the label hidden, read back the pixels inside the label's 120 x 30 box with <code>app.renderer.extract.pixels</code>, and took the brightest one. The scene starts with an opaque background rectangle, because <code>extract</code> only reads what's in the stage. The background colour you pass to <code>app.init</code> isn't part of it, and without the rectangle the halo is measured against transparent black. Then I computed the WCAG contrast ratio between the label color and that pixel.</p>
<p>That's a worst-case number. It treats the single brightest background pixel as if the whole label sat on it. With no halo at all the label measures 14.97:1 against the background.</p>
<h2>Four policies</h2>
<pre><code class="language-javascript">// stack:    a new instance per level-up
live.add(vfx.createEffect(halo, { position: [0, 0, 0], seed: seed++ }));

// restart:  one instance, reset on every level-up
single.reset({ position: [0, 0, 0], seed: seed++ });

// queue:    one instance; extra level-ups wait for the current halo to finish

// coalesce: one instance; level-ups in the same frame merge, and level-ups
//           during a halo request one follow-up halo when it ends
</code></pre>
<p>I ran each policy on three trigger patterns: 3 level-ups in the same frame, 5 in the same frame, and 3 spaced 0.2 s apart.</p>
<table>
<thead>
<tr>
<th>Policy</th>
<th>3 together</th>
<th>5 together</th>
<th>3 spaced 0.2 s</th>
<th>Peak particles</th>
<th>Halo time (5 together)</th>
</tr>
</thead>
<tbody><tr>
<td>stack</td>
<td><strong>4.27:1</strong></td>
<td><strong>2.01:1</strong></td>
<td>5.79:1</td>
<td>85 (5 instances)</td>
<td>0.9 s</td>
</tr>
<tr>
<td>restart</td>
<td>10.69:1</td>
<td>10.69:1</td>
<td>10.69:1</td>
<td>17</td>
<td>0.9 s</td>
</tr>
<tr>
<td>queue</td>
<td>10.39:1</td>
<td>10.39:1</td>
<td>10.39:1</td>
<td>17</td>
<td>4.6 s</td>
</tr>
<tr>
<td>coalesce</td>
<td>10.39:1</td>
<td>10.39:1</td>
<td>10.39:1</td>
<td>17</td>
<td>0.9 s</td>
</tr>
</tbody></table>
<p>Contrast is the worst frame in each run. The small 10.69 vs 10.39 difference between restart and the others comes from different mote seeds, not from the policy.</p>
<p><img src="https://sonaarajyan03.github.io/assets/pixijs-level-up-halo-multiple-level-ups/halo-stack-vs-coalesce.png?v=2" alt="Five stacked halo instances wash out the top of the LEVEL 12 label; one coalesced instance leaves it readable" />
<em>Same frame (0.52 s after the level-ups), same effect file. Left: five instances. Right: one.</em></p>
<h2>What each policy actually did</h2>
<p><strong>Stack</strong> is what you get by default if the level-up handler calls <code>createEffect</code>. The instances are additive and start on the same frame, so their glow alphas add up: the summed glow alpha reached 1.75 with three and 2.92 with five. Three stacked instances already took the label to 4.27:1, just under the 4.5:1 that WCAG asks for normal-size text. With five, the brightest pixel behind the label went from 0.031 to 0.378 relative luminance and contrast fell to 2.01:1. Particle count scaled with it, 17 per instance up to 85.</p>
<p><strong>Restart</strong> keeps one instance, so brightness is capped. The cost shows when level-ups are spread out. <code>reset()</code> clears the live particles immediately. With level-ups 0.2 s apart, the glow alpha climbed to 0.556 and dropped to 0 on the next frame, three times in a row. On screen that reads as a blink rather than a celebration.</p>
<p><strong>Queue</strong> looks safe in the table, but the player waits. The halos played one after another, about 0.9 s each. In this implementation the label advanced when its halo started, so after five level-ups it read LEVEL 9 while the player was already level 12, and reached LEVEL 12 3.7 s later. If the number is tied to the effect, the effect is now delaying game state on screen.</p>
<p><strong>Coalesce</strong> gave one halo for any number of same-frame level-ups, the same peak particle count and contrast as a single halo, and the label shows the real level immediately. For the spaced pattern it played the first halo plus one follow-up, 1.8 s in total, never more than one follow-up however many level-ups arrive.</p>
<p>The coalesce logic is short:</p>
<pre><code class="language-javascript">let playing = false, playedFrame = -1, again = false;

function onLevelUp() {
  level++;
  label.text = `LEVEL ${level}`;          // the number never waits for VFX
  if (playing) {
    if (frame &gt; playedFrame) again = true; // later level-up: one follow-up
    return;                                // same frame: merge
  }
  start();
}

function start() {
  single.reset({ position: [0, 0, 0], seed: seed++ });
  playing = true;
  playedFrame = frame;
}

app.ticker.add((t) =&gt; {
  frame++;
  vfx.update(t.deltaMS / 1000);
  if (playing &amp;&amp; !single.isActive) {
    playing = false;
    if (again) { again = false; start(); }
  }
});
</code></pre>
<p><code>single</code> is created once with <code>autoStart: false</code>. <code>isActive</code> goes false after the last particle dies, which in this effect is about 0.9 s after the reset.</p>
<h2>Drawing on top doesn't block taps, but it does cover the portrait</h2>
<p>The NixieFX guide notes that a renderer parented to <code>app.stage</code> draws above your UI in normal stacking order. I checked what that means for input by making the portrait tappable (<code>eventMode = "static"</code> with a circular <code>hitArea</code>) and hit-testing two points on the portrait that the glow covers, 0.2 s after a level-up.</p>
<p>With the renderer parented to the halo layer and with it parented to <code>app.stage</code>, <code>rootBoundary.hitTest()</code> returned the portrait for both points. The particle sprites weren't hit-test targets in this setup, so the halo didn't steal the tap.</p>
<p>The pixels are a different story:</p>
<p><img src="https://sonaarajyan03.github.io/assets/pixijs-level-up-halo-multiple-level-ups/halo-parent-behind-vs-stage.png" alt="Left: halo layer under the portrait. Right: renderer on app.stage, the glow washes over the face" />
<em>Same frame. Input still reaches the portrait in both, but on the right the additive glow is drawn over the face.</em></p>
<p>So for a halo that should sit behind something, give the renderer its own layer under that thing. Don't rely on low alpha to keep it out of the way.</p>
<h2>Limits</h2>
<ul>
<li><strong>Software WebGL.</strong> These are headless Chrome runs on SwiftShader with a fixed time step. I didn't measure frame time or test a phone GPU.</li>
<li><strong>Worst-pixel contrast is conservative.</strong> It doesn't account for font weight, the label's own anti-aliasing, or how people actually read the screen. Use it to compare policies, not to certify accessibility.</li>
<li><strong>One layout.</strong> The glow touched the label because of these sizes. With a label plate, or a halo that ends above the label, stacking would brighten the ring but not change the label.</li>
<li><strong>Additive blend only.</strong> A normal-blend halo would stack differently, and I didn't test it.</li>
<li><strong><code>reset()</code> cut the old particles off in this build</strong> (nixie-fx 0.1.21). I didn't look for a fade-out option on reset, so treat the blink as what the default call did here.</li>
</ul>
<p>My recommendation for a portrait halo: decide the trigger policy before tuning the particles, and test the five-level case instead of the single level-up the effect was designed around.</p>
<p><em>Drafted with AI assistance and reviewed by me. Every number and both screenshots come from the local demo described above.</em></p>
]]></content:encoded></item><item><title><![CDATA[A PixiJS shield break that vanished during hit-stop]]></title><description><![CDATA[In PixiJS with NixieFX, update(0) never fires a burst, so a shield break vanishes during hit-stop. Three clocks measured, plus a 1 ms fix.]]></description><link>https://nixiefx-notes.hashnode.dev/pixijs-shield-break-hit-stop</link><guid isPermaLink="true">https://nixiefx-notes.hashnode.dev/pixijs-shield-break-hit-stop</guid><category><![CDATA[pixijs]]></category><category><![CDATA[GameDev]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[Game Development]]></category><category><![CDATA[animation]]></category><dc:creator><![CDATA[Steven Volkov]]></dc:creator><pubDate>Fri, 16 Oct 2026 10:24:44 GMT</pubDate><content:encoded><![CDATA[<p>Hit-stop is the few frames where a game freezes on a big impact so the hit lands. I added it to a PixiJS shield break: the third hit empties the shield, the shield shatters into shards, and the whole game stops for six frames. The obvious way to freeze the effect with the game is to stop feeding it time. Done that way, the shatter disappears for the whole freeze. The shield ring is gone, and nothing replaces it until the game starts moving again.</p>
<p>This post is about why, what the three possible clocks look like side by side, and the one-line fix. Everything below was measured in PixiJS 8.22.0 with nixie-fx 0.1.21 (released 16 October 2026) in headless Chrome 155 on macOS, stepping a fixed 1/60 s clock so every run is repeatable.</p>
<h2>The setup</h2>
<p>The effect is a shield shatter made in NixieFX, a visual particle editor for PixiJS and Three.js. It's a project file in the editor's JSON format, validated and exported with the <code>nixie-fx</code> CLI, then played by its PixiJS renderer. Two emitters, both one-shot bursts at time 0:</p>
<pre><code class="language-json">{ "id": "core",   "duration": 0.25,
  "spawn": { "bursts": [{ "time": 0, "count": 1 }], "shape": "point" },
  "initializeParticle": { "lifetime": { "mode": "random", "min": 0.2, "max": 0.2 } } },
{ "id": "shards", "mode": "mesh", "duration": 1.0,
  "forces": { "gravity": 4, "drag": 1.2 },
  "spawn": { "bursts": [{ "time": 0, "count": 28 }], "shape": "circle", "radius": 0.9,
             "rotation": [90, 0, 0], "sphericalDirectionAmount": 1 },
  "initializeParticle": { "lifetime": { "mode": "random", "min": 0.6, "max": 0.95 },
                          "velocity": { "mode": "shapeDirection",
                                        "speed": { "mode": "random", "min": 1.0, "max": 2.6 } } },
  "mesh": { "renderMode": "pixiShard", "template": "triangleShard" } }
</code></pre>
<p>(Trimmed: colour, size and rotation curves are left out.) The projection maps 1 unit to 100 px, so the 0.9-unit circle sits on the 90 px shield ring.</p>
<p>Validation passes with one warning, and the export marks the effect <code>partial</code> for the Pixi backend because of it:</p>
<pre><code class="language-text">warning [unsupported-module] shield-shatter.json:emitters.1.mode: Mesh mode exports as Pixi 2D shard geometry semantics, not true 3D mesh rendering.
</code></pre>
<p>In practice that means flat triangle shards, which is what a 2D game wants here.</p>
<p>The hit-stop works the way many games do it. The damage handler sets <code>timeScale = 0</code> and starts a counter of six real frames (0.1 s at 60 fps). Game logic runs first each frame, then effects. So on the frame the hit lands, the effects already see the freeze.</p>
<h2>Three clocks, measured</h2>
<p>I ran the same fight three times, changing only what <code>vfx.update()</code> receives:</p>
<ul>
<li><strong>ticker</strong>: the real frame time, freeze or not</li>
<li><strong>game</strong>: frame time × <code>timeScale</code>, so 0 during hit-stop, including the hit frame</li>
<li><strong>game + prime</strong>: the same, plus one 1 ms step on the new shatter instance when it's created</li>
</ul>
<p>Particles on screen and the horizontal spread of all particles, per frame after the hit. The first six rows are the freeze.</p>
<table>
<thead>
<tr>
<th>Frame</th>
<th>ticker</th>
<th>game</th>
<th>game + prime</th>
</tr>
</thead>
<tbody><tr>
<td>hit</td>
<td>35 / 186 px</td>
<td><strong>7 / 63 px</strong></td>
<td>36 / 186 px</td>
</tr>
<tr>
<td>+1</td>
<td>33 / 189 px</td>
<td><strong>7 / 63 px</strong></td>
<td>36 / 186 px</td>
</tr>
<tr>
<td>+2</td>
<td>33 / 193 px</td>
<td><strong>7 / 63 px</strong></td>
<td>36 / 186 px</td>
</tr>
<tr>
<td>+3</td>
<td>31 / 200 px</td>
<td><strong>7 / 63 px</strong></td>
<td>36 / 186 px</td>
</tr>
<tr>
<td>+4</td>
<td>29 / 206 px</td>
<td><strong>7 / 63 px</strong></td>
<td>36 / 186 px</td>
</tr>
<tr>
<td>+5</td>
<td>29 / 213 px</td>
<td><strong>7 / 63 px</strong></td>
<td>36 / 186 px</td>
</tr>
<tr>
<td>+6, game resumes</td>
<td>29 / 218 px</td>
<td>35 / 186 px</td>
<td>35 / 189 px</td>
</tr>
</tbody></table>
<p>The 7 particles in the game column are leftovers from the second hit's small chip effect, frozen in place. The shatter itself has zero particles for all six frozen frames.</p>
<p><img src="https://sonaarajyan03.github.io/assets/pixijs-shield-break-hit-stop/shield-break-hitstop-three-clocks.png" alt="Six frames in a 3 by 2 grid. Top row, during hit-stop: with the ticker clock the shards have already spread around the hero; with the game clock the shield is gone and there are no shards; with the game clock plus a 1 ms prime the shards sit on the shield ring with a bright core flash. Bottom row, 7 frames after the hit-stop: all three show shards flying outward, the ticker version furthest out." /></p>
<h2>Why the game clock shows nothing</h2>
<p>Two things in the 0.1.21 Pixi runtime combine here. I read them in <code>dist/pixi.js</code> and the numbers above agree:</p>
<ol>
<li><code>createEffect()</code> resets the instance and draws it, but doesn't simulate. The time-0 bursts haven't fired yet.</li>
<li><code>update(dt)</code> only advances the simulation when <code>dt &gt; 0</code>. With 0, the instance redraws its current state, which is still empty.</li>
</ol>
<p>So the bursts fire on the first frame with real time, which is the first frame after the hit-stop. The good news: the new instance already reports <code>isActive === true</code> before its first update, so a cleanup loop that removes finished effects doesn't throw it away during the freeze. I logged that check in the run.</p>
<h2>The fix: give the new instance one tiny step</h2>
<pre><code class="language-js">let timeScale = 1;
let hitStopFrames = 0;
const live = new Set();

function onShieldBroken() {
  const fx = vfx.createEffect(shatter, { position: [0, 0, 0], seed: nextSeed++ });
  fx.update(0.001);        // fire the time-0 bursts now; update(0) won't
  live.add(fx);
  timeScale = 0;
  hitStopFrames = 6;
}

app.ticker.add((ticker) =&gt; {
  const realDt = ticker.deltaMS / 1000;
  if (hitStopFrames &gt; 0 &amp;&amp; --hitStopFrames === 0) timeScale = 1;
  updateGame(realDt * timeScale);   // may call onShieldBroken()
  vfx.update(realDt * timeScale);   // reads timeScale after the hit set it
  for (const fx of live) {
    if (!fx.isActive) { vfx.removeEffect(fx, true); live.delete(fx); }
  }
});
</code></pre>
<p>The demo steps a fixed 1/60 s instead of <code>ticker.deltaMS</code>; otherwise its loop is this one. The NixieFX <a href="https://nixiefx.com/pixijs-particle-effects/">guide to PixiJS particle effects</a> wires <code>vfx.update(ticker.deltaMS / 1000)</code> straight to the ticker, which is right until your game gets its own clock. Then the effects should usually share it.</p>
<p>A millisecond is too short to see: at the fastest shard speed (2.6 units/s, 100 px per unit) the shards move about 0.26 px. With the prime, the freeze shows the burst exactly as it starts. The shards sit on the ring with the core flash bright behind them, and everything continues from there when time resumes.</p>
<p>Two other fixes work if you'd rather not touch the instance:</p>
<ul>
<li><strong>Start the freeze on the next frame.</strong> I measured this as a fourth run: if effects get the hit frame's normal 1/60 s and the freeze starts after it, the shatter shows 35 particles across 186 px and holds there for the rest of the freeze. It changes frame order for everything else that runs after the damage handler, and the effects freeze for five frames instead of six.</li>
<li><strong>Keep the shield ring drawn until the freeze ends.</strong> Then the screen isn't empty, but the break reads as late.</li>
</ul>
<h2>When the ticker clock is the right answer</h2>
<p>Some games deliberately keep effects playing through hit-stop, so the impact flares while the characters hold still. The ticker clock does that. The cost is in the table: by the time the hero can move again, 0.1 s of a 0.2 s core flash is already gone, and the shards have travelled from a 186 px spread to 213 px. If the effect was tuned to the beat of the animation, it now finishes a tenth of a second early. That's a design decision, not a bug. Just make it on purpose.</p>
<h2>Limits</h2>
<ul>
<li>One effect, one machine, headless Chrome 155 on macOS, fixed 1/60 s steps rather than real <code>requestAnimationFrame</code> timing.</li>
<li>The hit-stop counts frames. On a 120 Hz display, six frames is 50 ms. If refresh rates vary, count milliseconds instead.</li>
<li>I tested the Pixi renderer only. The Three.js renderer wasn't part of this run.</li>
<li>Neither effect uses sub-emitters, so I didn't check how child instances behave with a zero step.</li>
<li>The shards render as flat 2D geometry on Pixi, as the export warning says. That's what this effect is meant to be, but don't expect 3D mesh shading from mesh mode on the Pixi backend.</li>
</ul>
<p><em>This post was drafted with AI assistance and reviewed and edited by me. All numbers and the image come from the demo described above.</em></p>
]]></content:encoded></item><item><title><![CDATA[NixieFX vs tsParticles in a PixiJS game: one win burst, built twice and measured]]></title><description><![CDATA[The same 120-particle burst in NixieFX and tsParticles inside a PixiJS reward screen: layering, seeded replays, frame capture and bundle size.]]></description><link>https://nixiefx-notes.hashnode.dev/nixiefx-vs-tsparticles-pixijs-win-burst</link><guid isPermaLink="true">https://nixiefx-notes.hashnode.dev/nixiefx-vs-tsparticles-pixijs-win-burst</guid><category><![CDATA[pixijs]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[GameDev]]></category><category><![CDATA[webdev]]></category><category><![CDATA[animation]]></category><dc:creator><![CDATA[Steven Volkov]]></dc:creator><pubDate>Thu, 15 Oct 2026 12:48:35 GMT</pubDate><content:encoded><![CDATA[<p>tsParticles is what a lot of people reach for when a web page needs confetti or a particle background, and for good reason: it's one package, the options are JSON, and it looks good with very little setup. The question I had was narrower. If you already have a PixiJS game and you want a "level clear" burst that flies up from behind a reward card, how do tsParticles and NixieFX compare once the effect has to live inside the game?</p>
<p>So I built the same burst in both and measured four things: whether the particles can sit between two layers of the Pixi scene, whether a run can be replayed exactly, whether you can read a frame back for a screenshot test, and what each one adds to a production bundle. Some of the results favour tsParticles.</p>
<h2>Setup</h2>
<ul>
<li><code>nixie-fx</code> 0.1.20, <code>@tsparticles/engine</code> 4.4.0 (plus <code>basic</code>, <code>plugin-interactivity</code>, <code>plugin-emitters</code>, <code>updater-life</code>), <code>pixi.js</code> 8.22.0, Vite 6.4.4</li>
<li>Chromium 152 on macOS, Intel Core i9-9880H. One machine.</li>
<li>A 480 × 300 Pixi stage with three layers: a striped background, an empty effects layer, and a "LEVEL CLEAR" card on top</li>
<li>The burst starts in the middle of the card, so for the first fraction of a second the sparks should be hidden behind it</li>
<li>Fixed 1/60 s steps and seed 42 everywhere</li>
</ul>
<p><strong>The effect:</strong> a one-shot burst of 120 warm sparks in a 30° cone pointing up, 0.6–0.9 s lifetime, gravity pulling them back down, fading out at the end.</p>
<h2>The burst in NixieFX</h2>
<p>NixieFX effects are JSON files you author in the editor (or by hand), validate and export with the CLI. The fields that matter here:</p>
<pre><code class="language-json">{
  "id": "win-burst",
  "targetProfile": "portable",
  "emitters": [{
    "maxParticles": 140,
    "spawn": {
      "bursts": [{ "time": 0, "count": 120 }],
      "shape": "cone", "angle": 30, "radius": 0.1,
      "rotation": [0, 0, 0],
      "sphericalDirectionAmount": 0
    },
    "initializeParticle": {
      "lifetime": { "mode": "random", "min": 0.6, "max": 0.9 },
      "size": { "mode": "random", "min": 0.1, "max": 0.18 },
      "velocity": { "mode": "shapeDirection",
                    "speed": { "mode": "random", "min": 3.5, "max": 5.5 } }
    },
    "forces": { "gravity": 4 }
  }]
}
</code></pre>
<p>One gotcha cost me a few runs. I started from an existing impact effect, and its <code>sphericalDirectionAmount</code> was <code>1</code>. That blends each particle's direction toward "away from the spawn point", which turns a cone into a radial spray. In a 2D Pixi projection it looked like a firework instead of a fountain. With <code>0</code> the cone's local +Y axis is up and the burst goes where you'd expect.</p>
<pre><code class="language-bash">npx nixie-fx validate .
npx nixie-fx export .
</code></pre>
<pre><code class="language-text">Validated 1 effect: 0 errors, 0 warnings.
Exported 1 effect to public/vfx.
</code></pre>
<p>The export's support report for this effect says <code>pixi2d: supported</code> and <code>three3d: supported</code>, so the same file would also load in a Three.js scene. I didn't render the Three.js side for this post.</p>
<p>The game loads the bundle and puts the renderer in the effects layer, between the background and the card:</p>
<pre><code class="language-javascript">import { loadVfxExportBundle } from "nixie-fx/export";
import { PixiVfxRenderer, createPixiVfx2dProjection } from "nixie-fx/pixi";

app.stage.addChild(background, fxLayer, card);

const bundle = loadVfxExportBundle({ manifest, effectsByPath }, { requiredBackend: "pixi2d" });
const vfx = new PixiVfxRenderer({
  parent: fxLayer,
  pixiRenderer: app.renderer,
  projection: createPixiVfx2dProjection({ originX: 240, originY: 205, pixelsPerUnit: 60, yAxis: "up" }),
});
vfx.createEffect(bundle.effectsById.get("win-burst"), { position: [0, 0, 0], seed: 42 });

// in the game loop, in seconds
vfx.update(1 / 60);
</code></pre>
<h2>The burst in tsParticles</h2>
<p>tsParticles 4 is split into plugins. A one-shot burst from a point needs the emitters plugin, which in turn needs the interactivity plugin, plus the life updater:</p>
<pre><code class="language-javascript">import { tsParticles } from "@tsparticles/engine";
import { loadBasic } from "@tsparticles/basic";
import { loadInteractivityPlugin } from "@tsparticles/plugin-interactivity";
import { loadEmittersPlugin } from "@tsparticles/plugin-emitters";
import { loadLifeUpdater } from "@tsparticles/updater-life";

await loadBasic(tsParticles);
await loadInteractivityPlugin(tsParticles);
await loadEmittersPlugin(tsParticles);
await loadLifeUpdater(tsParticles);

const options = {
  fullScreen: { enable: false }, detectRetina: false,
  background: { color: { value: "transparent" } },
  particles: { number: { value: 0 } },
  emitters: {
    position: { x: 50, y: 68.33 },            // percent of the canvas
    size: { width: 0, height: 0 },
    startCount: 120, rate: { quantity: 0, delay: 10 }, life: { count: 1, duration: 0.05 },
    particles: {
      paint: { fill: { enable: true, color: { value: ["#fff08c", "#ffb347", "#ff8c26"] } } },
      size: { value: { min: 3, max: 5.5 } },
      opacity: { value: { min: 0, max: 1 },
                 animation: { enable: true, speed: 1.2, startValue: "max", destroy: "none", sync: true } },
      life: { count: 1, duration: { value: { min: 0.6, max: 0.9 }, sync: true } },
      move: { enable: true, direction: "top", angle: { value: 60, offset: 0 },
              speed: { min: 6.3, max: 9.9 }, outModes: "destroy",
              gravity: { enable: true, acceleration: 7 } },
    },
  },
};
</code></pre>
<p>Two gotchas here too:</p>
<ul>
<li>My first attempt had <code>opacity: { value: 1, animation: { destroy: "min" } }</code>. With min and max both 1, every particle is already at its minimum, so all 120 were destroyed within five frames. The opacity range has to go down to 0.</li>
<li><code>life.duration.sync</code> defaults to <code>false</code>, and in that case each particle's duration is the range value multiplied by a random factor between 0 and 1. A "0.6–0.9 s" lifetime then gives anything from 0 to 0.9 s. <code>sync: true</code> keeps it in the range you wrote.</li>
</ul>
<p>The units differ (tsParticles speeds are pixels per 60 Hz frame, NixieFX uses world units per second), so I didn't try to convert. I tuned the tsParticles speed, angle and gravity until the bounding box of the live particles matched NixieFX's within a few pixels at three times. Offsets from the burst origin, in pixels (up is positive):</p>
<table>
<thead>
<tr>
<th>Time</th>
<th>NixieFX: alive, x range, height range</th>
<th>tsParticles: alive, x range, height range</th>
</tr>
</thead>
<tbody><tr>
<td>0.30 s</td>
<td>120, −43 to +46, 34 to 73</td>
<td>120, −43 to +45, 35 to 74</td>
</tr>
<tr>
<td>0.60 s</td>
<td>120, −84 to +88, 27 to 108</td>
<td>120, −83 to +89, 28 to 105</td>
</tr>
<tr>
<td>0.80 s</td>
<td>42, −92 to +117, 4 to 104</td>
<td>48, −103 to +102, 3 to 100</td>
</tr>
</tbody></table>
<p>Close enough to compare integration. The colours and fade curves are not identical: NixieFX uses a gradient over each particle's life and holds full alpha until 70% of it, while the tsParticles version picks one of three colours per particle and fades linearly.</p>
<h2>Result 1: layering inside the game scene</h2>
<p><img src="https://sonaarajyan03.github.io/assets/nixiefx-vs-tsparticles/win-burst-three-ways.png" alt="Six frames of a PixiJS 'LEVEL CLEAR' card comparing NixieFX and tsParticles layering" /></p>
<p>Top row at 0.20 s, bottom row at 0.45 s.</p>
<ul>
<li><strong>Left, NixieFX:</strong> the renderer is a child of <code>fxLayer</code>, so the sparks are hidden behind the card until they clear its top edge. That's the intended look.</li>
<li><strong>Middle, tsParticles over the Pixi canvas:</strong> tsParticles draws into its own canvas. Stacked over the game canvas, the sparks cover the card and its text. Put it underneath instead and the game canvas, which has an opaque background, hides it completely. Two canvases give you "in front of everything" or "behind everything", not "between the background and the card".</li>
<li><strong>Right, tsParticles inside Pixi:</strong> <code>tsParticles.load()</code> also accepts an <code>OffscreenCanvas</code> as its <code>element</code>. I gave it one and wrapped it in a Pixi <code>CanvasSource</code> and <code>Sprite</code> in <code>fxLayer</code>. That gets the layering right. The cost is that <code>source.update()</code> re-uploads the whole 480 × 300 canvas to the GPU every frame, 576,000 bytes of RGBA, however many particles are alive. At full-screen size on a high-DPI phone that grows fast, so measure it on your target device before shipping it.</li>
</ul>
<h2>Result 2: replaying a run exactly</h2>
<p>For screenshot tests and bug reports you want the same frame twice. I rendered each burst twice with the same settings and compared SHA-256 hashes of the PNGs.</p>
<ul>
<li><strong>NixieFX:</strong> the seed is a <code>createEffect</code> option and the step is whatever you pass to <code>update()</code>. Same seed, same steps: identical pixels at both test times. Seed 43 gave a different frame at 0.45 s. (At 0.20 s every spark is still behind the card, so the frames were identical for both seeds, which is the right answer.)</li>
<li><strong>tsParticles:</strong> there's no per-effect seed, and it drives itself with <code>requestAnimationFrame</code>. But the engine exports two functions that make it testable: <code>setRandom(fn)</code> replaces its random source, and <code>setAnimationFunctions(nextFrame, cancel)</code> replaces its frame scheduler. With a seeded PRNG and a manual clock, two runs gave identical pixels. Without <code>setRandom</code>, two runs differed.</li>
</ul>
<pre><code class="language-javascript">import { setRandom, setAnimationFunctions } from "@tsparticles/engine";

setRandom(mulberry32(42));                       // any () =&gt; number in [0, 1)
const pending = new Map(); let handle = 0, now = 1000;
setAnimationFunctions((cb) =&gt; (pending.set(++handle, cb), handle), (h) =&gt; pending.delete(h));
function tick(ms) { now += ms; const cbs = [...pending.values()]; pending.clear(); cbs.forEach((cb) =&gt; cb(now)); }

const container = await tsParticles.load({ element: canvas, options });
tick(0); for (let i = 0; i &lt; 27; i++) tick(1000 / 60);
</code></pre>
<p>Both overrides are engine-wide, not per container. If two tsParticles effects run on the same page, they share one random sequence, so adding a second effect changes the first one's particles. NixieFX keeps a separate seed per effect instance.</p>
<h2>Result 3: reading a frame back</h2>
<p>This one caught me out. tsParticles 4.4 calls <code>transferControlToOffscreen()</code> on the canvas you pass it. After that, <code>canvas.toDataURL()</code> returns a blank image, and <code>canvas.getContext("2d")</code> throws "Cannot get context from a canvas that has transferred its control to offscreen". The pixels are still reachable through the container:</p>
<pre><code class="language-javascript">const blob = await container.canvas.renderCanvas.convertToBlob({ type: "image/png" });
</code></pre>
<p>If you have visual tests that grab the canvas with <code>toDataURL()</code>, they'll silently compare blank images after upgrading. NixieFX draws into the Pixi renderer, so the usual <code>app.canvas.toDataURL()</code> (with <code>preserveDrawingBuffer: true</code>) or Pixi's extract API works.</p>
<h2>Result 4: bundle size</h2>
<p>I bundled only the imports each version of the demo uses with esbuild 0.25.12 (<code>--bundle --minify --format=esm</code>), with <code>pixi.js</code> marked external because the game already ships it:</p>
<table>
<thead>
<tr>
<th>What's bundled</th>
<th>Minified</th>
<th>Gzip -9</th>
</tr>
</thead>
<tbody><tr>
<td>tsParticles: engine + basic + interactivity + emitters + life (what this demo uses)</td>
<td>112,073 B</td>
<td>34,247 B</td>
</tr>
<tr>
<td>tsParticles: engine + <code>loadSlim</code> + emitters</td>
<td>164,212 B</td>
<td>48,587 B</td>
</tr>
<tr>
<td>NixieFX: <code>nixie-fx/export</code> + <code>nixie-fx/pixi</code></td>
<td>198,680 B</td>
<td>59,859 B</td>
</tr>
</tbody></table>
<p>The minimal tsParticles build is about 25 KB smaller gzipped than the NixieFX Pixi runtime. If bundle size is the deciding factor and the effect can sit on top of everything, that matters.</p>
<h2>Which one to use</h2>
<p><strong>tsParticles fits well when</strong> the particles decorate a web page rather than a game scene: backgrounds, confetti over a DOM layout, hover and click interactions (it has a large set of interaction plugins), presets you can drop in, and no build or export step. It doesn't need a game engine at all. It's also far more widely used: in the week before I wrote this, npm counted about 460,000 downloads of <code>@tsparticles/engine</code> against about 1,300 for <code>nixie-fx</code>, so answers are easier to find.</p>
<p><strong>NixieFX fits better when</strong> the effect is part of the game: it renders inside your PixiJS (or Three.js) scene graph, so it layers with sprites and UI; it steps on your game loop's delta; each effect instance has its own seed; and effects are authored in an editor and shipped as an export bundle with a per-backend support report. The <a href="https://nixiefx.com/vfx-runtime-docs/">NixieFX runtime docs</a> cover the export bundle, the support report and both renderer adapters.</p>
<h2>Limits of this comparison</h2>
<ul>
<li>This NixieFX vs tsParticles comparison covers one effect, one machine and one browser. I didn't measure frame time, because CPU timings from a hidden test tab weren't trustworthy and GPU cost is the interesting part for both.</li>
<li>The two bursts are matched by measured bounding boxes, not by identical physics, colour or fade.</li>
<li>The OffscreenCanvas-as-texture route is something I made work for this test, not a documented tsParticles integration. Its per-frame upload cost is computed from the canvas size, not measured on a device.</li>
<li>I only rendered NixieFX's PixiJS side. The Three.js claim rests on the export's support report.</li>
</ul>
<h2>Reproduce it</h2>
<ol>
<li>Install the versions above in a Vite project, with a 480 × 300 <code>pixi.js</code> app and a stage of background, an empty <code>Container</code> for effects, and a card on top.</li>
<li>Put the NixieFX JSON in a project folder with a <code>vfx-editor.prj</code>, then run <code>npx nixie-fx validate .</code> and <code>npx nixie-fx export .</code>.</li>
<li>Load it with the NixieFX snippet above. Load the tsParticles options with the seeded <code>setRandom</code> and the manual <code>tick()</code> clock.</li>
<li>Step both 12 and 27 times at 1/60 s, capture the Pixi canvas and the tsParticles <code>renderCanvas</code>, and compare.</li>
</ol>
<p><em>This post was drafted with AI assistance and reviewed and edited by me. All numbers and the image come from the demo described above.</em></p>
]]></content:encoded></item><item><title><![CDATA[Campfire embers that drift toward (0, 0): testing wind away from the world origin in Three.js]]></title><description><![CDATA[One seeded NixieFX ember effect, three campfire positions. Why wind drag and vortex pull embers toward (0, 0), and two fixes that held.]]></description><link>https://nixiefx-notes.hashnode.dev/threejs-campfire-embers-wind-away-from-world-origin</link><guid isPermaLink="true">https://nixiefx-notes.hashnode.dev/threejs-campfire-embers-wind-away-from-world-origin</guid><category><![CDATA[ThreeJS]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[GameDev]]></category><category><![CDATA[WebGL]]></category><category><![CDATA[webdev]]></category><dc:creator><![CDATA[Steven Volkov]]></dc:creator><pubDate>Wed, 14 Oct 2026 13:19:31 GMT</pubDate><content:encoded><![CDATA[<p>Most ember demos put the campfire at the world origin, mine included. That's the obvious place to build a test scene, and it's the one place where a whole class of bug can't show up. In a real level the campfire sits at something like (120, 60), next to a tent, and the embers should behave there exactly as they did in the test scene.</p>
<p>So I ran a test that only changes one thing: where the fire stands. Same effect file, same seed, same step size, and a camera that keeps the same offset from the fire. If an ember effect doesn't depend on placement, every number measured relative to the fire should match at every position.</p>
<h2>The scene record</h2>
<p>These values are all you need to reproduce a frame:</p>
<pre><code class="language-js">const SCENE = {
  seed: 3141,
  dt: 1 / 60,
  steps: 180,                     // frame at t = 3.0 s
  viewport: [360, 360],
  cameraOffset: [0, 1.5, 5.2],    // added to the fire position
  cameraTargetOffset: [0, 1.2, 0],
  fov: 45,
};
const FIRES = { origin: [0, 0.15, 0], camp: [14, 0.15, -9], far: [120, 0.15, 60] };
</code></pre>
<p>Versions: nixie-fx 0.1.20, three r185, Chrome on a Mac, with Node used for the same measurement and identical numbers.</p>
<p>The embers are one looping emitter: world simulation space, an upward cone (14°, radius 0.18), 32 per second, 1.6 to 2.4 s lifetime, additive, yellow to deep orange with alpha fading to zero. I exported five variants of that emitter. The wind vector is the same in all of them, <code>[1.1, 0, 0.35]</code> in world units, so the embers should lean toward +X and a little toward +Z.</p>
<table>
<thead>
<tr>
<th>Variant</th>
<th>What's added</th>
<th>three3d export status</th>
</tr>
</thead>
<tbody><tr>
<td>calm</td>
<td>nothing</td>
<td>supported</td>
</tr>
<tr>
<td>forceOverLifetime</td>
<td><code>force: [1.1, 0, 0.35]</code></td>
<td>partial</td>
</tr>
<tr>
<td>externalForces wind</td>
<td><code>wind: [1.1, 0, 0.35]</code></td>
<td>partial</td>
</tr>
<tr>
<td>wind + drag</td>
<td>same wind, <code>drag: 0.6</code></td>
<td>partial</td>
</tr>
<tr>
<td>wind + vortex</td>
<td>same wind, <code>vortexStrength: 1.5</code></td>
<td>partial</td>
</tr>
</tbody></table>
<p>The "partial" entries all carry the same generic export warning: "validate authored subfields before shipping". That's the step this post is about.</p>
<h2>What moved</h2>
<p>Mean ember position relative to the fire at t = 3.0 s (x, z in world units):</p>
<table>
<thead>
<tr>
<th>Variant</th>
<th>Fire at (0, 0)</th>
<th>Fire at (14, −9)</th>
<th>Fire at (120, 60)</th>
</tr>
</thead>
<tbody><tr>
<td>calm</td>
<td>−0.01, 0.03</td>
<td>−0.01, 0.03</td>
<td>−0.01, 0.03</td>
</tr>
<tr>
<td>forceOverLifetime</td>
<td>0.73, 0.29</td>
<td>0.73, 0.29</td>
<td>0.73, 0.29</td>
</tr>
<tr>
<td>externalForces wind</td>
<td>0.82, 0.28</td>
<td>0.82, 0.28</td>
<td>0.82, 0.28</td>
</tr>
<tr>
<td>wind + drag 0.6</td>
<td>0.69, 0.23</td>
<td><strong>−0.66, 1.10</strong></td>
<td><strong>−10.92, −5.57</strong></td>
</tr>
<tr>
<td>wind + vortex 1.5</td>
<td>0.76, 0.27</td>
<td><strong>1.16, 0.91</strong></td>
<td><strong>−1.97, 5.72</strong></td>
</tr>
</tbody></table>
<p>The first three rows hold. Wind on its own, through either module, doesn't care where the fire is.</p>
<p>The drag and vortex rows don't. At (14, −9) the drag variant already leans the wrong way on X. At (120, 60) the embers are pulled about 11 units toward −X and 5.6 toward −Z, which points back at the world origin and away from the wind. From the gameplay camera, 9 of 68 live embers are still on screen. With vortex the embers spread to a 15-unit radius around the fire and 13 of 63 stay in view.</p>
<p><img src="https://cdn.hashnode.com/uploads/covers/6a883193b2d30459c9b20c06/d5e94951-2709-4510-ac50-b6050ae0769e.png" alt="Three frames, same seed and same camera offset. A: fire at the origin, embers lean right with the wind. B: same file with the fire at (120, 60), only 9 of 68 embers on screen and they trail away to the left. C: fire at (120, 60) with the renderer mounted on an anchor at the fire, same picture as A." /></p>
<h2>Why it happens</h2>
<p>The motion is evaluated from each particle's age, so I read the externalForces step in the shipped 0.1.20 runtime. The wind part adds <code>wind × ½t²</code> to the position, which is a pure offset. The drag part then multiplies the particle's x and z by <code>1 − drag × age × 0.15</code>, and the vortex part rotates x and z around x = z = 0.</p>
<p>Those are coordinates in the space the particles are simulated in. For a world-space emitter in a renderer added to the scene, that's the scene, so drag shrinks every ember's position toward the world origin and vortex swirls around it. At the origin, "toward the origin" happens to mean "toward the fire's axis", so a test scene there looks right.</p>
<h2>Fix 1: mount the renderer on an anchor at the fire</h2>
<p><code>ThreeVfxRenderer</code> takes a <code>parent</code> option. Put the renderer in a group that sits at the fire, and spawn the effect at the group's origin:</p>
<pre><code class="language-js">const fireAnchor = new THREE.Group();
fireAnchor.position.set(120, 0.15, 60);
scene.add(fireAnchor);

const vfx = new ThreeVfxRenderer({ parent: fireAnchor, camera });
const embers = vfx.createEffect(effect, { position: [0, 0, 0], seed: 3141 });
</code></pre>
<p>With this setup, wind + drag gave 0.69, 0.23 at all three positions, the same as the origin row. Vortex gave 0.76, 0.27 everywhere. Panel C in the image is this setup at (120, 60).</p>
<p>The cost is one renderer per campfire. A level with eight fires needs eight anchors, and anything you query from the renderer is in anchor coordinates, so convert with <code>fireAnchor.localToWorld()</code> before comparing against the scene. The constructor options are listed in the <a href="https://nixiefx.com/threejs-runtime/">NixieFX Three.js runtime guide</a>.</p>
<h2>Fix 2: simulate the embers in local space</h2>
<p>Setting <code>spawn.simulationSpace</code> to <code>"local"</code> also made the result independent of placement: wind + drag gave 0.64, 0.19 at all three positions, and vortex 0.71, 0.31. That fits a campfire, which doesn't move.</p>
<p>These numbers aren't the same as the world-space version at the origin (0.69, 0.23), so check the look again after switching. I didn't rotate the emitter in this test. In local space, assume the authored wind follows the emitter's axes and check it if your fires are placed with a rotation.</p>
<h2>Fix 3: leave drag and vortex off</h2>
<p>If the embers only need to lean, <code>externalForces.wind</code> or <code>forceOverLifetime</code> on its own is enough, and both rows above held at every position. Shorten the lifetime if you want the embers to stop drifting sooner.</p>
<h2>The repeatability check that makes this test worth anything</h2>
<p>Comparing positions only means something if the same inputs give the same frame. I hashed every ember position after 180 steps: seed 3141 gave <code>64335163</code> twice, and seed 3142 gave <code>67c84f16</code>. Each panel was one draw call.</p>
<p>That's also why the camera follows the fire at a fixed offset instead of a fixed point. Same seed, same step, same offset: any difference between two frames comes from the placement and nothing else.</p>
<h2>Checklist for embers in a real level</h2>
<ul>
<li>Write down the seed, step size, frame time, camera offset and fire positions next to the effect.</li>
<li>Render the effect at the origin and at one real level position, and compare positions relative to the fire.</li>
<li>If <code>externalForces</code> has non-zero <code>drag</code> or <code>vortexStrength</code>, either anchor the renderer at the fire or switch the emitter to local simulation space.</li>
<li>After switching space, retune by eye; the numbers shift.</li>
<li>Rerun the same-seed hash check after every nixie-fx upgrade.</li>
</ul>
<h2>Limits</h2>
<ul>
<li><strong>The export report didn't flag this.</strong> All four wind variants got the same "partial" warning. Nothing in it says drag or vortex depends on placement, so test it rather than relying on the status.</li>
<li><strong>Tested on Three.js only.</strong> The same effect files export for PixiJS too. I didn't run them there.</li>
<li><strong>One frame time.</strong> All numbers are at t = 3.0 s. I didn't check how the gap grows over a longer run.</li>
<li><strong>Static fires only.</strong> I didn't move the anchor or the fire while embers were in flight.</li>
<li><strong>No lights, bloom or textures.</strong> The embers are unlit additive billboards and the image is a raw render.</li>
</ul>
<p>Whether you shape the cone and colours in a visual particle editor for three.js or in JSON by hand, the test scene should include a fire that isn't at (0, 0, 0). Otherwise drag and vortex look correct in testing and break in the level.</p>
<p><em>This post was drafted with AI assistance and reviewed and edited by me. All numbers and the image come from the demo described above.</em></p>
]]></content:encoded></item><item><title><![CDATA[Put a crafting spark above the inventory slot in PixiJS]]></title><description><![CDATA[A NixieFX tutorial for PixiJS: anchor a bounded crafting spark above an inventory slot, ignore duplicate events, and verify cleanup.]]></description><link>https://nixiefx-notes.hashnode.dev/put-a-crafting-spark-above-the-inventory-slot-in-pixijs</link><guid isPermaLink="true">https://nixiefx-notes.hashnode.dev/put-a-crafting-spark-above-the-inventory-slot-in-pixijs</guid><category><![CDATA[pixijs]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[GameDev]]></category><dc:creator><![CDATA[Steven Volkov]]></dc:creator><pubDate>Mon, 12 Oct 2026 10:10:17 GMT</pubDate><content:encoded><![CDATA[<p>An inventory effect needs to identify the slot that changed without hiding its item, quantity, or neighboring labels. A short spark above the slot gives the effect its own space. The harder part is keeping that position correct when the inventory panel moves or scales, and making sure a repeated event cannot restart the animation.</p>
<p>This NixieFX tutorial uses one unlit, additive burst in PixiJS. The supplied source pins pixi.js to 8.22.0, nixie-fx to 0.1.20, and Vite to 6.4.4. Its controls simulate accepted and rejected crafting events. They do not implement a crafting server or update inventory quantities.</p>
<p>Disclosure: Prepared for NixieFX's marketing work with AI assistance. The measurements below come from the supplied local example, not a production game.</p>
<h2>Give the spark a small, finite budget</h2>
<p>The effect uses one point emitter above the iron-ingot slot. Continuous emission is zero, looping is off, and a single burst emits 18 particles. Lifetimes range from 0.28 to 0.45 seconds. Each particle starts with an upward velocity and a small horizontal spread, then shrinks and fades from pale yellow toward amber.</p>
<p>The critical source settings are:</p>
<pre><code class="language-js">emitter.loop = false;
emitter.duration = 0.05;
emitter.maxParticles = 18;
emitter.spawn.rate = 0;
emitter.spawn.rateValue = { mode: 'constant', value: 0 };
emitter.spawn.bursts = [
  { time: 0, count: 18, cycles: 1, interval: 0, probability: 1 }
];
</code></pre>
<p>The <a href="https://github.com/SonaArajyan03/sonaarajyan03.github.io/tree/main/assets/crafting-spark">complete source kit</a> includes the normalized authoring JSON, compiled export, runnable demo, and recorded checks. This excerpt shows the emission boundary; it is not a replacement for the complete effect schema. Keep both the numeric rate and its scalar envelope at zero. Setting only one invites confusing differences between the source and the compiled effect.</p>
<p>The stock circle billboard supplies the particle shape. This example needs no file textures, custom materials, or sub-effects. That removes external asset loading from the crafting event path.</p>
<h2>Convert the slot anchor into the effect layer</h2>
<p>The UI and effect layer are siblings under the stage. The effect layer does not participate in inventory layout. Resolve an anchor 14 pixels above the slot's top edge, convert it through global coordinates, and then convert it into the effect layer's coordinates:</p>
<pre><code class="language-js">const PPU = 80;
const renderer = new PixiVfxRenderer({
  parent: vfxLayer,
  projection: createPixiVfx2dProjection({
    pixelsPerUnit: PPU,
    yAxis: 'up'
  })
});

function anchor() {
  const p = vfxLayer.toLocal(
    slots[1].toGlobal(new Point(56, -14))
  );
  return [p.x / PPU, -p.y / PPU, 0];
}
</code></pre>
<p>The slot is 112 pixels wide, so 56 selects its horizontal center. Pixi's downward screen Y becomes an upward world Y in the projection, which explains the negative sign. Dividing by the same pixels-per-unit value used by the renderer keeps the two coordinate systems consistent.</p>
<p>The anchor is resolved when an accepted event arrives. Moving the panel before the next event therefore places the next burst correctly. Continuous panel movement during an already playing burst needs its own policy; this example does not demonstrate that behavior.</p>
<h2>Trigger from the accepted event</h2>
<p>Create one instance with autoStart disabled, then reset it only after an accepted event for the intended slot:</p>
<pre><code class="language-js">const fx = renderer.createEffect(definition, {
  autoStart: false,
  seed: 379
});
const seen = new Set();

function onCraftCompleted(event) {
  if (closed || event.status !== 'accepted' ||
      event.slotId !== 'iron' || seen.has(event.id)) {
    return false;
  }
  seen.add(event.id);
  if (seen.size &gt; 32) seen.delete(seen.values().next().value);
  fx.reset({ position: anchor(), seed: 379 });
  return true;
}
</code></pre>
<p>The demo keeps only the last 32 IDs. That is a bounded local guard, not durable network idempotency. The game must own that guarantee. A new accepted event restarts the reused instance, so this design also deliberately avoids overlapping bursts from rapid crafts.</p>
<p>Ordinary playback advances the renderer once per frame:</p>
<pre><code class="language-js">app.ticker.add(ticker =&gt; {
  renderer.update(ticker.deltaMS / 1000);
});
</code></pre>
<p>Do not update the instance separately in that callback. The inspection controls stop the ticker and use a fixed 120 Hz clock, so they do not add a second update loop to normal playback.</p>
<h2>Check the export and the rendered result</h2>
<p>The runtime loads the compiled manifest and effects with loadVfxExportBundle, requiring the pixi2d backend and the crafting-spark effect ID. The game does not load the authoring JSON directly. The <a href="https://nixiefx.com/pixijs-particle-effects/">NixieFX PixiJS particle-effects guide</a> covers that authoring-to-runtime path.</p>
<p>The source validated with zero errors and warnings. The export reported Pixi support as supported, with no blockers. In the Chrome-hosted example, the 0.10-second inspection showed 18 active particles. At 0.80 seconds it showed zero. Twenty sequential accepted events each peaked at 18 and drained to zero while retaining one effect instance. Repeating the same event was ignored; a rejected event did not trigger a burst.</p>
<p><img src="https://sonaarajyan03.github.io/assets/crafting-spark/evidence/spark-at-0.10s.png" alt="Small crafting spark above the iron-ingot inventory slot, with the item and quantity label unobstructed." /></p>
<p>The transformed-panel check placed the next burst at the moved slot anchor. Missing texture, material, and sub-emitter references were empty, as were unsupported-module and unsupported-feature diagnostics. These are lifecycle and visual checks, not a mobile-device performance benchmark.</p>
<h2>Keep the rendering limits visible</h2>
<p>No unsupported settings are enabled in this effect. Lit particle shading requires the Three 3D backend; Pixi uses unlit compositing. Pixi depthWrite has draw-order and ink semantics rather than GPU depth-buffer writing. This source disables depth writing and uses no material graph, so it does not establish parity for custom shaders or 3D lighting.</p>
<p>Closing the inventory stops the owning ticker and hides both layers. A new event resets the reused instance after reopening. Final page teardown destroys the renderer and Pixi application, and backgrounding the tab pauses the ticker. Real-editor visual review and mobile-device testing remain pending.</p>
]]></content:encoded></item><item><title><![CDATA[A PixiJS rarity reveal that doesn't move the card: fixed icon slots and the 2,000,000-pixel bounds trap]]></title><description><![CDATA[A PixiJS 8 item reveal with NixieFX particles: why the name jumped 111 px, why the card's bounds hit 2,000,000 px, and the fixes.]]></description><link>https://nixiefx-notes.hashnode.dev/pixijs-item-rarity-reveal-without-layout-shift</link><guid isPermaLink="true">https://nixiefx-notes.hashnode.dev/pixijs-item-rarity-reveal-without-layout-shift</guid><category><![CDATA[pixijs]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[GameDev]]></category><category><![CDATA[webdev]]></category><category><![CDATA[HTML5]]></category><dc:creator><![CDATA[Steven Volkov]]></dc:creator><pubDate>Fri, 09 Oct 2026 10:11:32 GMT</pubDate><content:encoded><![CDATA[<p>An item-rarity reveal is a small screen with a lot going on. A card flips from "?" to an item, the frame changes to the rarity colour, the icon downloads from wherever your item art lives, and a burst of particles goes off to say "this one is good". Players watch it closely, because it's the payoff.</p>
<p>In a PixiJS scene there is no browser layout engine and no Cumulative Layout Shift score. If the item name jumps down the card when the icon arrives, nothing flags it. Your own layout code moved it.</p>
<p>I built the same reveal three times side by side to see which layout decisions actually move things. The setup:</p>
<ul>
<li>PixiJS 8.22.0 and nixie-fx 0.1.20 (the NixieFX runtime), Chrome on a Mac.</li>
<li>Three effects, <code>reveal-rare</code>, <code>reveal-epic</code> and <code>reveal-legendary</code>. Each is a soft glow plus a ring of sparks pushed outward, authored as NixieFX effect JSON and exported with <code>npx nixie-fx export .</code>. The export report marks all three "supported" on the <code>pixi2d</code> backend.</li>
<li>Three item icons with different sizes on purpose: 96×96, 112×128 and 140×112. Real item art rarely arrives at one size.</li>
<li>Every measurement uses a fixed 1/60 s step and the same seed, so the numbers are repeatable.</li>
</ul>
<p>The three columns:</p>
<table>
<thead>
<tr>
<th>Column</th>
<th>Icon sizing</th>
<th>Where the particle renderer lives</th>
</tr>
</thead>
<tbody><tr>
<td>A</td>
<td>icon at its texture size, name stacked under it</td>
<td>inside the card container</td>
</tr>
<tr>
<td>B</td>
<td>fixed 120×120 slot, icon fitted into it</td>
<td>inside the card container</td>
</tr>
<tr>
<td>C</td>
<td>fixed 120×120 slot, icon fitted into it</td>
<td>separate layer above the UI</td>
</tr>
</tbody></table>
<p><img src="https://cdn.hashnode.com/uploads/covers/6a883193b2d30459c9b20c06/415c1451-9c97-4012-b5f2-5345a704f827.png" alt="Loading state for the three columns. In column A the question mark sits at the top of the card because the icon has no height yet." /></p>
<h2>Shift 1: the name moves when the icon arrives</h2>
<p>Column A does what a lot of first versions do. The icon sprite starts with an empty texture, the item name sits 14 px under the icon, and when the texture loads, <code>icon.height</code> changes.</p>
<pre><code class="language-javascript">// Column A: layout follows the texture
icon.anchor.set(0.5, 0);
icon.position.set(CARD_W / 2, 22);
name.position.set(CARD_W / 2, icon.y + icon.height + 14);
</code></pre>
<p><code>Texture.EMPTY</code> is 1×1, so before the load the name sits at the top of the card. After the load it drops by the icon's height:</p>
<table>
<thead>
<tr>
<th>Item</th>
<th>Icon size</th>
<th>Name moved on load</th>
<th>Icon centre moved on load</th>
</tr>
</thead>
<tbody><tr>
<td>Old Compass (rare)</td>
<td>96×96</td>
<td>95 px</td>
<td>47.5 px</td>
</tr>
<tr>
<td>Bound Tome (epic)</td>
<td>112×128</td>
<td>127 px</td>
<td>63.5 px</td>
</tr>
<tr>
<td>Ember Crown (legendary)</td>
<td>140×112</td>
<td>111 px</td>
<td>55.5 px</td>
</tr>
</tbody></table>
<p>The fix is old advice from web layout: reserve the box before the content arrives. Columns B and C give the icon a fixed 120×120 slot and fit whatever texture arrives into it, centred, without upscaling:</p>
<pre><code class="language-javascript">// Columns B and C: the slot decides the layout, not the texture
const SLOT = { x: 30, y: 22, size: 120 };
const t = icon.texture;
const s = t === Texture.EMPTY ? 0 : Math.min(1, SLOT.size / t.width, SLOT.size / t.height);
icon.anchor.set(0.5);
icon.scale.set(s);
icon.position.set(SLOT.x + SLOT.size / 2, SLOT.y + SLOT.size / 2);
name.position.set(CARD_W / 2, SLOT.y + SLOT.size + 14);
</code></pre>
<p>In B and C the name and the icon centre moved 0 px for all three items. The crown is scaled to 120×96; the compass stays at 96×96 because I'd rather leave a gap than blur a small icon.</p>
<p>The other half of this is not loading during the reveal at all. With a simulated 400 ms delay on the icon request (a <code>setTimeout</code> before <code>Assets.load</code>, not a real network test), the reveal waits 400 ms for the art. The interactive version of the demo loads all three icons with <code>Promise.all</code> before the buttons do anything, so the reveal never waits on the network. The fixed slot is what protects you when preloading misses something.</p>
<h2>Shift 2: the card becomes 2,000,000 px wide</h2>
<p>This one surprised me. Under each card there's a "Tap to collect" caption, placed from the card's measured bounds. That's a common habit: centre something under a container using its size.</p>
<pre><code class="language-javascript">const b = card.getLocalBounds();
caption.position.set(card.x + b.x + b.width / 2, card.y + b.y + b.height + 12);
</code></pre>
<p>Before the reveal, every card measures 183×213 px (the 180×210 frame plus its stroke). In columns A and B, the moment the effect is created, <code>card.getBounds()</code> returns this:</p>
<pre><code class="language-plaintext">x: -999730, y: -999948, width: 2000000, height: 2000000
</code></pre>
<p>The caption moved 999,788 px, off the canvas. The bounds jumped on the frame the effect was created, before a single particle had been simulated, and returned to 183×213 on the frame my reap loop removed the finished instance with <code>removeEffect</code>. Column C, with the renderer on its own layer, measured 183×213 the entire time.</p>
<p><img src="https://cdn.hashnode.com/uploads/covers/6a883193b2d30459c9b20c06/73a55e5e-ac71-4000-83f0-41055dafa2ad.png" alt="Same frame, 0.20 s into the legendary reveal. Columns A and B have lost their caption because their card bounds include the particle renderer's bounds area. Green crosshairs mark each slot centre." /></p>
<p>The cause is in the runtime, and it's reasonable once you see it. <code>PixiVfxRenderer</code> sets a <code>boundsArea</code> on its emitter containers, and the default is a rectangle from -1,000,000 to +1,000,000 on both axes. PixiJS uses a container's <code>boundsArea</code> instead of measuring its children. That saves Pixi from walking every particle each time something asks for bounds. But if the renderer is a child of your card, the card's bounds now include a 2,000,000 px square.</p>
<p>You'll only notice this if something measures the card: a layout pass, a hit-area calculation, a scroll container, a tooltip anchor. If nothing measures it, the particles render fine inside the card.</p>
<p>There are two fixes, and I measured both.</p>
<p><strong>Option 1: keep the renderer out of the card (column C).</strong> Put particles on a layer above the UI and convert the slot centre to that layer's coordinates:</p>
<pre><code class="language-javascript">const vfxLayer = new Container();
app.stage.addChild(cardsLayer, vfxLayer);

const vfx = new PixiVfxRenderer({
  parent: vfxLayer,
  projection: createPixiVfx2dProjection({ originX: 0, originY: 0, pixelsPerUnit: 100, yAxis: "down" }),
});

function reveal(card, effect, seed) {
  const p = card.toGlobal({ x: SLOT.x + SLOT.size / 2, y: SLOT.y + SLOT.size / 2 });
  return vfx.createEffect(effect, { position: [p.x / 100, p.y / 100, 0], seed });
}
</code></pre>
<p>This assumes <code>vfxLayer</code> sits at the stage origin with no transform. If it's scaled or offset, use <code>vfxLayer.toLocal(p)</code> instead.</p>
<p><strong>Option 2: keep it in the card, but give it a real <code>boundsArea</code>.</strong> The renderer accepts a <code>boundsArea</code> option:</p>
<pre><code class="language-javascript">const vfx = new PixiVfxRenderer({
  parent: card,
  projection,
  boundsArea: new Rectangle(0, 0, CARD_W, CARD_H),
});
</code></pre>
<p>With that, the caption moved 0 px in columns A and B as well. I prefer option 1 for UI: particles usually fly past the card edge, and a VFX layer keeps "what the UI is" separate from "what's drawn on top of it". Option 2 is the smaller change when an effect has to move with a card, for example one in a scrolling list. One thing I did not test: if you enable culling, a tight <code>boundsArea</code> could cull particles that drift outside it.</p>
<p><img src="https://cdn.hashnode.com/uploads/covers/6a883193b2d30459c9b20c06/151ecfec-2165-4e2e-8c59-83a7189f913b.png" alt="The same frame with boundsArea set to the card rectangle. Captions stay in place in all three columns." /></p>
<h2>Alignment proof</h2>
<p>Moving the particles to another layer raises a fair question: do they still start where the icon is? I checked it with the runtime's own debug output. <code>getParticleDebugQuads()</code> returns a screen quad for every live particle, and the glow emitter has exactly one particle spawned at the effect origin. For every frame in which that glow particle was alive, in every reveal, I compared its quad centre with the slot centre in stage pixels:</p>
<table>
<thead>
<tr>
<th>Column</th>
<th>Max offset, glow centre vs slot centre (all three rarities)</th>
</tr>
</thead>
<tbody><tr>
<td>A</td>
<td>0 px</td>
</tr>
<tr>
<td>B</td>
<td>0 px</td>
</tr>
<tr>
<td>C</td>
<td>0 px</td>
</tr>
</tbody></table>
<p>So the layer move costs nothing in alignment, as long as the position goes through <code>toGlobal</code> (or <code>toLocal</code>) instead of being copied from card-local numbers. The crosshairs in the screenshots are drawn from the same slot-centre calculation.</p>
<h2>What's supported on the Pixi side</h2>
<p>These reveal effects only use colour over life, size over life, velocity and drag, and the export report marks them "supported" on both <code>pixi2d</code> and <code>three3d</code>. If you build your own reveal, check the per-backend report the export writes into <code>manifest.json</code>. When I switched other modules on one at a time in nixie-fx 0.1.20, the export reported these as <strong>partial</strong> on <code>pixi2d</code>: trails, noise, collision, lights, sub-emitters, texture sheet animation, custom data, external forces, force over lifetime, size by speed and colour by speed. Lights and sub-emitters are <strong>blocked</strong> on <code>three3d</code>. Partial means "validate the subfields before shipping", not "won't render", but a reveal is a bad place to find that out.</p>
<p>If you'd rather tune the glow and spark ring by eye than by editing JSON, NixieFX has a visual particle editor for PixiJS that exports the same bundle format. The <a href="https://nixiefx.com/pixijs-particle-effects/">PixiJS particle effects guide</a> walks through loading an exported bundle with <code>loadVfxExportBundle</code> and driving it from <code>app.ticker</code>.</p>
<h2>Limits</h2>
<ul>
<li><strong>One machine, one browser.</strong> Chrome on a Mac, fixed time steps. The layout numbers are deterministic, but I didn't test other browsers or devices.</li>
<li><strong>The network delay was simulated.</strong> 400 ms is a <code>setTimeout</code>, not a measurement of any CDN.</li>
<li><strong>I didn't test resizing or DPR changes.</strong> A slot that's fixed in logical pixels can still move if your layout recomputes on resize.</li>
<li><strong>Culling with a tight <code>boundsArea</code> wasn't tested.</strong> See option 2 above.</li>
<li><strong>This isn't an accessibility pass.</strong> A reveal should also respect reduced-motion settings and announce the item as text. I left both out to keep the comparison to layout.</li>
</ul>
<p>The short version: let a fixed slot decide where the icon goes, and don't let anything you measure contain the particle renderer.</p>
<p><em>This post was drafted with AI assistance and reviewed and edited by me. All numbers and screenshots come from the demo described above.</em></p>
]]></content:encoded></item><item><title><![CDATA[What the NixieFX agent skill actually makes an agent do: a one-shot pickup burst in PixiJS 8]]></title><description><![CDATA[Following the NixieFX authoring skill from an empty folder to a running PixiJS 8 particle burst, with the real CLI output and screenshots.]]></description><link>https://nixiefx-notes.hashnode.dev/what-the-nixiefx-agent-skill-actually-makes-an-agent-do-a-one-shot-pickup-burst-in-pixijs-8</link><guid isPermaLink="true">https://nixiefx-notes.hashnode.dev/what-the-nixiefx-agent-skill-actually-makes-an-agent-do-a-one-shot-pickup-burst-in-pixijs-8</guid><category><![CDATA[pixijs]]></category><category><![CDATA[GameDev]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[AI]]></category><category><![CDATA[webdev]]></category><dc:creator><![CDATA[Steven Volkov]]></dc:creator><pubDate>Fri, 02 Oct 2026 13:30:05 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a883193b2d30459c9b20c06/22e842e9-c7d0-42a6-8616-a7d8e1fec0ef.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>There are now agent skills for both halves of a PixiJS particle workflow: the official PixiJS skills for writing correct v8 code, and two NixieFX skills for creating, validating, exporting, and loading particle effects. I wanted to know what "AI agent skills for PixiJS particles" means in practice, so I took one small effect through the NixieFX authoring skill from an empty folder to a running Pixi scene and kept every command output.</p>
<p>The target: a coin pickup "pop". Click a coin, about two dozen warm sparks fly outward and fade in under a second, and the effect cleans itself up.</p>
<p><em>Note: I'm involved with the NixieFX project. The run below was done in a Claude Code session that followed the nixie-fx-authoring SKILL.md step by step, with the skills installed through the official installer. This post was drafted with AI assistance and edited by hand. All outputs and screenshots are from that run.</em></p>
<h2>Versions</h2>
<ul>
<li>Node 24.3.0</li>
<li><code>pixi.js</code> 8.22.0</li>
<li><code>nixie-fx</code> 0.1.17 (runtime + CLI)</li>
<li>Skills: <code>nixie-fx-authoring</code> + <code>nixie-fx-runtime</code> (azakhary/nixie-fx, main as of 2 Oct 2026) and the 26 PixiJS skills, installed with <code>npx skills add</code></li>
</ul>
<h2>The skills, briefly</h2>
<p>The install commands from the NixieFX docs are below. I added <code>-a claude-code -y</code> to install into the project for Claude Code without prompts:</p>
<pre><code class="language-bash">npx skills add https://github.com/azakhary/nixie-fx -a claude-code -y
npx skills add https://github.com/pixijs/pixijs-skills -a claude-code -y
</code></pre>
<p>Trimmed output from the NixieFX one:</p>
<pre><code class="language-text">◇  Source: https://github.com/azakhary/nixie-fx.git
◇  Repository cloned
◇  Found 2 skills
●  Installing all 2 skills
◇  Security Risk Assessments
   nixie-fx-authoring  Safe   0 alerts   Low Risk
   nixie-fx-runtime    Safe   0 alerts   Low Risk
◇  Installed 2 skills
   ✓ nixie-fx-authoring (copied) → demo/.claude/skills/nixie-fx-authoring
   ✓ nixie-fx-runtime (copied)   → demo/.claude/skills/nixie-fx-runtime
└  Done!  Review skills before use; they run with full agent permissions.
</code></pre>
<p>The PixiJS one reported <code>Found 26 skills</code> and installed all 26 (<code>pixijs</code>, <code>pixijs-application</code>, <code>pixijs-assets</code>, <code>pixijs-scene-particle-container</code>, <code>pixijs-ticker</code> and so on), each as its own folder under <code>.claude/skills/</code>. Its risk table rated all 26 "Safe" with 0 Socket alerts. Snyk rated the top-level <code>pixijs</code> skill Medium Risk and the other 25 Low Risk.</p>
<p>So the NixieFX side is just two skills:</p>
<ul>
<li><strong>nixie-fx-authoring</strong> (6 files): create, edit, validate and export effect projects. Includes the project template and three reference docs.</li>
<li><strong>nixie-fx-runtime</strong> (5 files): load the exported bundle in Pixi or Three.js, with one reference doc per renderer plus one for export bundles.</li>
</ul>
<p>The installer's last line is worth taking seriously: a skill runs with whatever permissions your agent already has. Read the two SKILL.md files before you rely on them. They are short.</p>
<p>A skill is mostly a workflow plus rules. The authoring one is short. Its core is ten steps: find or create <code>vfx-editor.prj</code>, check <code>npx nixie-fx --help</code> before trusting any command, create effects with the CLI instead of writing the schema from memory, edit the generated file, validate after every meaningful change, export, check export status, and then open the folder in the editor for visual review. It also has hard rules, like "never edit generated <code>out/vfx</code> files" and "schema validation is not visual approval." That last one turned out to matter.</p>
<h2>Step 1: a project folder</h2>
<p>The skill ships a template for <code>vfx-editor.prj</code> and says to copy it only when no manifest exists, replacing every <code>REPLACE-*</code> value:</p>
<pre><code class="language-json">{
  "app": "vfx-editor",
  "kind": "project",
  "version": 1,
  "id": "vfx-pickup-pop-&lt;random&gt;",
  "name": "Pickup Pop",
  "settings": {
    "effectDataPath": "effects",
    "outputPath": "out/vfx",
    "assetRootPath": "assets",
    "materialsFolder": "materials",
    "allowExternalOutput": false,
    "lastEffectFile": "pickup-pop.json"
  },
  "createdAt": "&lt;ISO timestamp&gt;",
  "updatedAt": "&lt;ISO timestamp&gt;"
}
</code></pre>
<p>Then check the CLI contract, as the skill asks:</p>
<pre><code class="language-text">$ npx nixie-fx --help
Usage:
  nixie-fx effect create --project &lt;folder&gt; --name &lt;name&gt; [--profile &lt;profile&gt;]
  nixie-fx validate [project-folder]
  nixie-fx export [project-folder]
  nixie-fx export-status [project-folder]

Profiles:
  pixi-ui-2d (default), three-world-3d, portable
</code></pre>
<h2>Step 2: generate the effect, don't hand-write it</h2>
<pre><code class="language-text">$ npx nixie-fx effect create --project . --name "Pickup Pop" --profile pixi-ui-2d
Created .../vfx/effects/pickup-pop.json
</code></pre>
<p>The generated file is about 48 KB of normalized defaults: one emitter, continuous rate of 24 particles per second, point shape, <code>bursts: []</code>. The skill is explicit that the generated file is the schema reference, and it documents the one thing you can't learn from the defaults: the shape of a burst entry.</p>
<h2>Step 3: turn it into a one-shot burst</h2>
<p>These are the fields I changed in <code>effects/pickup-pop.json</code> (everything else stayed as generated):</p>
<pre><code class="language-text">timeline.duration                         2.3   -&gt; 1.0
emitters[0].loop                          true  -&gt; false
emitters[0].duration                      1     -&gt; 0.6
emitters[0].maxParticles                  128   -&gt; 64
spawn.rate / rateValue                    24    -&gt; 0
spawn.bursts                              []    -&gt; [{ time: 0, count: 22, cycles: 1, interval: 0, probability: 1 }]
spawn.shape                               point -&gt; circle, radius 0.06
initializeParticle.lifetime               1     -&gt; random 0.35..0.6 s
initializeParticle.velocity.speed         0     -&gt; random 1.2..2.2 units/s
forces.drag                               0     -&gt; 4
modules.color, modules.size               false -&gt; true
color.gradient                            white -&gt; pale gold -&gt; orange, alpha 1 -&gt; 0
billboard.sizeStart / sizeEnd             0.28  -&gt; 0.16 / 0.03, softness 0.6
render.blend                              alpha -&gt; additive
</code></pre>
<p>Then validate, as the skill says to after each meaningful edit:</p>
<pre><code class="language-text">$ npx nixie-fx validate .
note [unsupported-module] (pixi2d note) pickup-pop.json:emitters.0.render.depthTest: depthTest exports as deterministic Pixi 2.5D draw-order/ink semantics, not GPU depth-buffer rendering.
note [unsupported-module] (pixi2d note) pickup-pop.json:emitters.0.render.depthInk: depthInk exports as deterministic Pixi 2.5D draw-order/ink semantics, not GPU depth-buffer rendering.
Validated 1 effect: 0 errors, 0 warnings.
</code></pre>
<p>The two notes are informational: on the Pixi backend, depth settings become 2.5D draw order rather than a GPU depth buffer. For a flat UI burst that is fine.</p>
<h2>Step 4: valid is not the same as right</h2>
<p>Zero errors, zero warnings. I exported it, loaded it in Pixi, and the sparks did not burst outward. They came out as a small clump drifting straight up from each coin.</p>
<p>With the default orientation, the circle shape emitted along +Y, the same direction the engine's tests document for a point shape. A radial pop in a 2D view needed two more fields:</p>
<pre><code class="language-text">spawn.rotation                  [0, 0, 0] -&gt; [90, 0, 0]   (lay the circle in the view plane)
spawn.sphericalDirectionAmount  0         -&gt; 1            (push particles away from the centre)
</code></pre>
<p>After that change the burst spreads radially. Nothing about the first version was invalid, it just wasn't what I wanted. This is exactly why the skill ends with a visual review step and says validation is not approval. An agent that stops at a green <code>validate</code> would have reported success here.</p>
<h2>Step 5: export and check status</h2>
<pre><code class="language-text">$ npx nixie-fx export .
Exported 1 effect to out/vfx.

$ npx nixie-fx export-status .
pickup-pop.json  exported    pickup-pop
1 up to date, 0 stale, 0 unexported, 0 orphaned.
</code></pre>
<p><code>out/vfx</code> contains <code>manifest.json</code> and <code>effects/pickup-pop.json</code>. No textures, because the effect uses the built-in soft circle billboard. The manifest reports <code>validation.valid: true</code> and Pixi support status <code>supported</code>.</p>
<h2>Step 6: load it in PixiJS 8</h2>
<p>This part follows the runtime skill. The page loads the manifest, hands it to <code>loadVfxExportBundle</code>, and creates a <code>PixiVfxRenderer</code> with a 2D projection of 100 pixels per effect unit:</p>
<pre><code class="language-javascript">import { Application, Graphics } from "pixi.js";
import { loadVfxExportBundle } from "nixie-fx/export";
import { PixiVfxRenderer, createPixiVfx2dProjection } from "nixie-fx/pixi";

const base = "./vfx/out/vfx/";
const getJson = (p) =&gt; fetch(base + p).then((r) =&gt; r.json());
const manifest = await getJson("manifest.json");
const effectsByPath = {};
for (const { path } of manifest.effects) effectsByPath[path] = await getJson(path);

const bundle = loadVfxExportBundle(
  { manifest, effectsByPath },
  { requiredBackend: "pixi2d", requiredEffectIds: ["pickup-pop"] },
);
const pop = bundle.effectsById.get("pickup-pop");

const app = new Application();
await app.init({ width: 600, height: 300, background: "#1d1813", antialias: true });
document.body.appendChild(app.canvas);

// Placeholder coins drawn once; they are the click targets.
const coins = [150, 300, 450].map((x) =&gt; {
  const c = new Graphics().circle(0, 0, 22).fill("#e0a930");
  c.position.set(x, 150);
  c.eventMode = "static";
  c.cursor = "pointer";
  app.stage.addChild(c);
  return c;
});

// 100 px per unit, origin bottom-left, y up.
const vfx = new PixiVfxRenderer({
  parent: app.stage,
  projection: createPixiVfx2dProjection({ originX: 0, originY: 300, pixelsPerUnit: 100, yAxis: "up" }),
});

const live = new Set();
let seed = 1;
for (const coin of coins) {
  coin.on("pointerdown", () =&gt; {
    live.add(vfx.createEffect(pop, {
      position: [coin.x / 100, (300 - coin.y) / 100, 0],
      seed: seed++,
    }));
  });
}

app.ticker.add((ticker) =&gt; {
  vfx.update(ticker.deltaMS / 1000); // seconds, not ms
  for (const fx of live) {
    if (!fx.isActive) {
      vfx.removeEffect(fx, true);
      live.delete(fx);
    }
  }
});
</code></pre>
<p>Two details worth copying: <code>update()</code> takes seconds, and finished one-shots are removed in the ticker so instances don't pile up.</p>
<h2>Result</h2>
<p>To get reproducible screenshots, the demo page has a freeze mode that plays all three coins with fixed seeds, steps the simulation in 1/60 s increments to a chosen time, renders once, and stops.</p>
<p><img src="https://cdn.hashnode.com/uploads/covers/6a883193b2d30459c9b20c06/c1603913-ac3a-4e66-84c4-5289ecb674b2.png" alt="Three coins with sparks just leaving each one, 0.08 s after the burst" />
<em>At 0.08 s: three bursts, 66 particles (22 each).</em></p>
<p><img src="https://cdn.hashnode.com/uploads/covers/6a883193b2d30459c9b20c06/22e842e9-c7d0-42a6-8616-a7d8e1fec0ef.png" alt="Gold sparks spread in a ring around each coin, 0.2 s after the burst" />
<em>At 0.2 s: still 66 particles, spread around each coin.</em></p>
<p><img src="https://cdn.hashnode.com/uploads/covers/6a883193b2d30459c9b20c06/56f15ecf-5497-4007-ade7-ee8a2afe1dd1.png" alt="Fading orange sparks spread wide around the coins, 0.4 s after the burst" />
<em>At 0.4 s: 59 particles left as the shortest-lived ones expire, fading toward orange.</em></p>
<p>In the interactive mode, one click produced 22 live particles; about 1.6 s later the counter read 0 particles and 0 live instances, so the cleanup loop is doing its job.</p>
<h2>What I would tell someone handing this to an agent</h2>
<ul>
<li>The skill keeps the agent on the CLI and the generated schema. That removed the most likely failure, an agent inventing effect JSON from memory.</li>
<li>It does not and cannot judge how an effect looks. My first version passed every check and was wrong. Ask the agent to render a frame and show it to you, or do the editor review yourself.</li>
<li>Know the defaults that don't match a 2D UI burst: emission direction is +Y, and bursts start empty.</li>
<li>I reviewed the result in the Pixi runtime only. The skill's last step, opening the folder in the NixieFX editor for visual review, is the right place to tune timing and colour, and I'd do that before shipping.</li>
</ul>
<p>For the full integration path, including texture providers, a looping coin fountain, and teardown, see the <a href="https://nixiefx.com/pixijs-particle-effects/">NixieFX tutorial on PixiJS particle effects for game UI</a>.</p>
]]></content:encoded></item></channel></rss>