An objective marker on a game map has one job: help the player find where to go. A pulse helps with that. A pulse that never stops turns into noise, and if every pin on the map pulses, nothing stands out.
This post builds a pulse for one tracked objective in PixiJS 8, with the particle effect from NixieFX, and gives it an attention budget. It pulses three times when the map opens, skips beats while the pin is off-screen, and then stops completely. Everything below comes from a demo I ran with pixi.js 8.22.0 and nixie-fx 0.1.20, stepped on a fixed 1/60 s clock.
One pulse, re-triggered from code
The effect is a single one-shot pulse with two emitters. A soft glow grows and fades over 0.7 s, and a ring of 14 sparks moves outward over about 0.8 s. It doesn't loop. The game decides when to fire it again, which keeps every stop condition in one place in your code instead of split between code and effect settings.
The relevant parts of the effect JSON:
{
"id": "ring",
"loop": false,
"spawn": {
"bursts": [{ "time": 0, "count": 14 }],
"shape": "circle", "radius": 0.1,
"rotation": [90, 0, 0], "sphericalDirectionAmount": 1
},
"forces": { "drag": 0.6 },
"initializeParticle": {
"lifetime": { "mode": "random", "min": 0.75, "max": 0.8 },
"velocity": { "mode": "shapeDirection", "speed": { "mode": "random", "min": 0.95, "max": 1.05 } }
}
}
Two settings in there are easy to get wrong on the Pixi side:
-
rotation: [90, 0, 0]andsphericalDirectionAmount: 1. A circle emitter's default direction is along +Y. Without these two settings the sparks don't spread outward on a 2D screen. -
Drag versus lifetime. In 0.1.20, drag scales the distance travelled by
1 − drag × age. Sparks reach their furthest point atage = 1 / (2 × drag)and come back to where they started atage = 1 / drag. With drag 0.6, that peak is at 0.83 s, so I kept the lifetime under it.
The loop and its exits
Here is the controller. Every way out of the loop is a named line:
const PERIOD = 1.4; // seconds between pulses
const MAX_PULSES = 3; // the attention budget
function fire(p) {
if (!p.fx) p.fx = vfx.createEffect(pulseFx, { position: [p.x / PX, p.y / PX, 0], seed: 100 + p.i });
else p.fx.reset(); // reuse one instance per pin
p.fired++; p.since = 0;
if (p.fired >= MAX_PULSES) p.state = "draining"; // last beat: let it finish
}
function startPulse(p) { // objective assigned or map opened
if (reducedMotion) { showStaticRing(p); return; } // exit 1: no motion at all
p.state = "running"; p.fired = 0;
if (onScreen(p)) fire(p); else p.since = 0;
}
function stopPulse(p) { // exit 2: objective done or map closed
p.state = "idle";
if (p.fx) { vfx.removeEffect(p.fx, true); p.fx = null; }
}
function updatePulse(p, dt) {
if (p.state === "idle") return;
p.since += dt;
if (p.state === "running" && p.since >= PERIOD) {
if (!onScreen(p)) p.since = 0; // exit 3 (temporary): nobody can see it
else fire(p);
}
if (p.state === "draining" && !p.fx.isActive) { // exit 4: budget spent and particles gone
vfx.removeEffect(p.fx, true); p.fx = null; p.state = "idle";
}
}
app.ticker.add((t) => {
const dt = t.deltaMS / 1000;
for (const p of pins) updatePulse(p, dt);
if (pins.some((p) => p.state !== "idle")) vfx.update(dt); // no VFX work once everything is idle
});
onScreen(p) compares the pin's global position with the canvas, plus a 24 px margin. An off-screen beat doesn't count against the budget, so a player who pans to the objective still sees it pulse.
Measured: one tracked pin vs. every pin pulsing
The demo map has 30 pins on an 1100 × 720 map, viewed through a 640 × 400 canvas, so 9 pins are on screen. Each run lasts 8 seconds. I counted real WebGL draw calls by wrapping drawArrays and drawElements. Each scenario ran three times, and the counts were identical every time.
| Scenario | Pulses fired | Peak particles | Draw calls per frame | VFX work |
|---|---|---|---|---|
| Tracked pin, budget 3 | 3 | 15 | 3 while pulsing, 1 after | stops at 3.62 s |
| All 30 pins, no budget, no off-screen skip | 180 | 450 | 61 | never stops |
| All 30 pins, budget 3, off-screen skip | 27 | 135 | up to 19, usually 1 | 21 off-screen pins stay armed, 0 particles |
| Tracked pin, reduced motion | 0 | 0 | 1 | none |
Three things stood out:
- Each live pulse costs two draw calls in Pixi, one per emitter. NixieFX doesn't merge instances across pins, so 30 pulsing pins is 61 draw calls, against 1 for the map and pins themselves.
-
The budget makes the tracked pin free after 3.62 s. That's the third pulse at 2.8 s plus its 0.8 s of particles. After that, the controller skips
vfx.update()entirely. -
CPU numbers moved with machine load, so treat them as rough. With all 30 pins pulsing,
vfx.update()took a median of 2.2 to 8.5 ms per frame across six runs on a busy Mac. With the budget it was 0 to 0.1 ms. The ratio held in every run, but the absolute milliseconds didn't.
Unsupported and partial settings
This effect only uses colour over life, size over life, velocity and drag, and the export marks it "supported" for pixi2d. If you build a fancier pulse, check support.backends.pixi2d in manifest.json first. In 0.1.20, turning on trails, noise, collision, lights, sub-emitters, texture sheet animation, custom data, external forces, force over lifetime, size by speed or colour by speed made the export report the effect as "partial" for Pixi.
I wrote the effect as JSON and exported it with npx nixie-fx export .. If you'd rather tune the glow and ring by eye, NixieFX is a visual particle editor for PixiJS that saves the same format. The PixiJS particle effects guide shows the bundle loading and ticker setup this demo uses.
Limits
- Three beats is a design choice, not research. Pick your own budget. Restart it when the objective changes or the map reopens.
- The off-screen check is a point test. A pin just outside the margin skips its beat even if part of the pulse would have been visible.
- One machine, fixed steps. The draw-call and particle counts are deterministic. The milliseconds are not.
- Reduced motion here swaps in a static ring. I didn't test it with a screen reader. The objective should also be announced as text somewhere outside the canvas.

Top comments (0)