Skip to main content

Command Palette

Search for a command to run...

Fire events, not effects

Updated
3 min readView as Markdown
P
Pseudo-Engineer, Full-Time Menace

The first time I wired particle effects to a game UI, I put the effect call inside the button handler. It worked, and it was the wrong shape, and it took two more features before I understood why.

// the version that works and then hurts
claimButton.on('pointerdown', () => {
  claimReward();
  vfx.play('button-spark', claimButton.position);
});

The problem is not the coupling to vfx. It is that the button now knows about presentation. When the reward can also be claimed from a daily-bonus popup, from a push notification deep link, and automatically on level-up, you have four call sites and three of them will forget the spark.

Fire events, not effects

The shape that survives is to emit a domain event and let a presentation layer decide what it looks like.

// UI: says what happened, not what it looks like
claimButton.on('pointerdown', () => events.emit('reward:claimed', { source: 'claim-button', at: claimButton.position }));

// presentation: decides how it reads
events.on('reward:claimed', ({ at }) => {
  vfx.play('button-spark', at);
  vfx.play('coin-fountain', rewardTray.position);
  audio.play('chime');
  shake(camera, 0.2);
});

Three things get better immediately.

One place to tune juice. When someone says the reward "doesn't feel good enough", you open one file. Not four handlers scattered across three screens.

The designer can work without you. Adding a second effect to an existing event is a change in the presentation layer, not in gameplay code. That is the difference between a tuning pass and a code review.

Turning it off is trivial. Low-end device profile, accessibility setting, or a "reduce motion" preference becomes one subscription you don't register. Try doing that when the effect call is welded into a click handler.

The trap on the other side

Do not let the event bus become the whole architecture. I have also seen the version where every function call is an event, nothing can be traced, and a stack trace tells you nothing about why a coin fountain fired. Two rules keep it honest:

  • Events describe what happened in the game, never what should be drawn. reward:claimed, not play-coin-fountain.
  • Events carry the data presentation needs — a position, an amount — so the listener never has to reach back into game state to find out where to draw.

If a listener needs to query three systems to know what to render, the event is underspecified. Add the field.

Where the effects themselves live

The other half of this is that effects should be data, not code. If a "coin fountain" is a function someone wrote, tuning it is an engineering task forever. If it is an authored file the presentation layer loads by name, anyone can open it.

This walkthrough builds exactly that for PixiJS v8 — a button-press burst and a reward coin fountain authored in an editor, exported to a bundle, and played from the ticker. What I took from it was less the particles and more the boundary: the game says a reward was claimed, and a separate layer owns how loud that is.

The test I use now

Ask whether you could ship a build with all VFX disabled by deleting one file. If yes, the boundary is in the right place. If you would have to edit twelve call sites, the effects have grown into your gameplay code, and the next artist who wants to tune the feel will have to ask a programmer for permission.