# A PixiJS shield break that vanished during hit-stop

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.

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.

## The setup

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 `nixie-fx` CLI, then played by its PixiJS renderer. Two emitters, both one-shot bursts at time 0:

```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" } }
```

(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.

Validation passes with one warning, and the export marks the effect `partial` for the Pixi backend because of it:

```text
warning [unsupported-module] shield-shatter.json:emitters.1.mode: Mesh mode exports as Pixi 2D shard geometry semantics, not true 3D mesh rendering.
```

In practice that means flat triangle shards, which is what a 2D game wants here.

The hit-stop works the way many games do it. The damage handler sets `timeScale = 0` 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.

## Three clocks, measured

I ran the same fight three times, changing only what `vfx.update()` receives:

- **ticker**: the real frame time, freeze or not
- **game**: frame time × `timeScale`, so 0 during hit-stop, including the hit frame
- **game + prime**: the same, plus one 1 ms step on the new shatter instance when it's created

Particles on screen and the horizontal spread of all particles, per frame after the hit. The first six rows are the freeze.

| Frame | ticker | game | game + prime |
|---|---|---|---|
| hit | 35 / 186 px | **7 / 63 px** | 36 / 186 px |
| +1 | 33 / 189 px | **7 / 63 px** | 36 / 186 px |
| +2 | 33 / 193 px | **7 / 63 px** | 36 / 186 px |
| +3 | 31 / 200 px | **7 / 63 px** | 36 / 186 px |
| +4 | 29 / 206 px | **7 / 63 px** | 36 / 186 px |
| +5 | 29 / 213 px | **7 / 63 px** | 36 / 186 px |
| +6, game resumes | 29 / 218 px | 35 / 186 px | 35 / 189 px |

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.

![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.](https://sonaarajyan03.github.io/assets/pixijs-shield-break-hit-stop/shield-break-hitstop-three-clocks.png "Last frozen frame (top) and 7 frames after the freeze (bottom). Left: ticker clock. Middle: game clock. Right: game clock with a 1 ms prime.")

## Why the game clock shows nothing

Two things in the 0.1.21 Pixi runtime combine here. I read them in `dist/pixi.js` and the numbers above agree:

1. `createEffect()` resets the instance and draws it, but doesn't simulate. The time-0 bursts haven't fired yet.
2. `update(dt)` only advances the simulation when `dt > 0`. With 0, the instance redraws its current state, which is still empty.

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 `isActive === true` 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.

## The fix: give the new instance one tiny step

```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) => {
  const realDt = ticker.deltaMS / 1000;
  if (hitStopFrames > 0 && --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); }
  }
});
```

The demo steps a fixed 1/60 s instead of `ticker.deltaMS`; otherwise its loop is this one. The NixieFX [guide to PixiJS particle effects](https://nixiefx.com/pixijs-particle-effects/) wires `vfx.update(ticker.deltaMS / 1000)` straight to the ticker, which is right until your game gets its own clock. Then the effects should usually share it.

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.

Two other fixes work if you'd rather not touch the instance:

- **Start the freeze on the next frame.** 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.
- **Keep the shield ring drawn until the freeze ends.** Then the screen isn't empty, but the break reads as late.

## When the ticker clock is the right answer

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.

## Limits

- One effect, one machine, headless Chrome 155 on macOS, fixed 1/60 s steps rather than real `requestAnimationFrame` timing.
- The hit-stop counts frames. On a 120 Hz display, six frames is 50 ms. If refresh rates vary, count milliseconds instead.
- I tested the Pixi renderer only. The Three.js renderer wasn't part of this run.
- Neither effect uses sub-emitters, so I didn't check how child instances behave with a zero step.
- 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.

*This post was drafted with AI assistance and reviewed and edited by me. All numbers and the image come from the demo described above.*

