---
title: "Game feel on the web: squash, shake, and the art of juice"
description: "A button that just works is dead. I spent an evening teaching a side-project to punch back, and these are the six techniques that did it. Each one playable, dependency-free, and respectful of your settings."
date: 2026-06-03
url: https://valdemird.com/blog/game-feel-on-the-web/
lang: en
tags: ["game-feel", "animation", "web-audio", "frontend", "interaction-design"]
---

# Game feel on the web: squash, shake, and the art of juice

> A button that just works is dead. I spent an evening teaching a side-project to punch back, and these are the six techniques that did it. Each one playable, dependency-free, and respectful of your settings.

Last month I was building a small multiplayer party game at `games.valdemird.com`. One of the modes is a number hunt: a grid of cells, you tap the right ones before your rival does. It worked on the first try. Tapping a cell flipped it on, the score went up, the round ended. Correct, and completely dead.

I sat there clicking the same cell over and over, and it felt like submitting a form. So I spent an evening doing nothing but making that one tap feel good. No new features. The cell now squashes when you hit it, throws a few particles, the pitch of the click rises as your streak climbs, and at a streak of fifty the whole board shakes. Same logic underneath. The difference is the interface finally acknowledges that you did something.

That gap has a name.

The trap is using a single `scale()`. That's a balloon inflating, not an object with weight. The trick is animating `scaleX` and `scaleY` in opposition, so the element conserves area. In the game the board uses this when it appears, planting itself rather than dropping in:

That's the surprising part. Nothing about the motion changed. The strike speed, the recoil, the particles are identical. The only difference is a `setTimeout` between contact and reaction, and your brain reads the longer pause as a heavier hit. It's the cheapest weight you will ever add to an interface.

The medium and large variants add a fraction of a degree of rotation. That rotation is the part most web implementations miss. Pure translation reads as a glitch. A few tenths of a degree of rotation reads as force. Here's the shipped code, including the one line everyone forgets:

The implementation detail that matters here is where the particles live. In a React app, spawning forty elements per click through state would trigger forty re-renders and tank the frame rate the moment a streak gets going. So the burst function reaches around React and touches the DOM directly:

A sound is just a frequency, a waveform, and a volume envelope over time. A short triangle wave that ramps up fast and decays is a click. A sine wave that glides downward is a thud. Here's the core, about fifteen lines:

The escalation isn't magic, it's a step function. The streak maps to a tier, and every effect reads off that tier. More particles, wider spread, a bigger shake, a redder tint, a higher pitch:

```ts
function comboTier(streak: number): 0 | 1 | 2 | 3 | 4 {
  if (streak >= 50) return 4;
  if (streak >= 30) return 3;
  if (streak >= 20) return 2;
  if (streak >= 10) return 1;
  return 0;
}

// on each successful mark:
sfx.pop(streak);                                              // pitch tracks streak
juice.burst(x, y, { count: 8 + tier * 5, spread: 80 + tier * 35 }); // more debris per tier
juice.flash(flashColor, 0.07 + tier * 0.025);                // brighter flash per tier
juice.shake(tier >= 3 ? 'md' : 'sm');                        // the board only shakes when it's earned
```

Two decisions make this feel good instead of exhausting. The board only shakes at tier three and above, so the strongest effect stays rare and keeps meaning something. And the streak decays after 1.6 seconds of inactivity, which turns a counter into tension. You're not just racking up points, you're keeping something alive. The decay is what makes the celebration feel earned, because it can be lost.

```ts
function onMark(cell: Cell) {
  setMarked((m) => [...m, cell]);
  setScore((s) => s + 1);
}
```

Correct. Ships. Tells you nothing happened beyond a number changing. This is what I had on day one, and it's why I spent the evening.

```ts
function onMark(cell: Cell, e: PointerEvent) {
  const next = streak + 1;
  setStreak(next);
  setMarked((m) => [...m, cell]);
  sfx.pop(next);
  juice.burst(e.clientX, e.clientY, { count: 6, spread: 64, size: 7 });
  clearTimeout(decay.current);
  decay.current = setTimeout(() => setStreak(0), 1600);
}
```

Eight more lines. Same correctness, plus weight, sound, and a reason to keep going.

The part that isn't optional

Everything here is designed to grab attention, which means everything here can become an attack on someone who didn't ask for it. Vestibular disorders make screen shake nauseating. Some people work in silence on purpose. The web has a real signal for the first, the [`prefers-reduced-motion`](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion) media query, and a basic courtesy for the second, and both are one line.

Look back at every function above. They all open the same way:

```ts
function reduced(): boolean {
  return matchMedia('(prefers-reduced-motion: reduce)').matches;
}
// shake, flash, burst all start with: if (reduced()) return;
```

If the operating system asks for reduced motion, the shake, the flash, and the particles simply don't happen. The game stays fully playable. This is also why I scoped the blog demos to honor the same flag. If your OS has reduced motion on, the motion demos on this page suppress themselves too, and you're reading a post that mostly demonstrates its own absence. Sound gets the same treatment through a persisted mute flag, defaulted to audible but one click from silent and remembered across sessions.

Every time I've told myself I'd polish feel after the logic was done, the logic calcified around having no feel. Click handlers got written assuming a synchronous, instant response, and retrofitting a 400ms reaction window meant untangling them. The evening I described at the top worked because the game was small. On anything larger, the feedback layer wants to be a sibling of the logic from the start: a `juice.burst()` call sitting next to the `setState`, not bolted on in a later pass.

Almost everything here stands on a handful of well-known talks and one book. If you want the originals:

- **Steve Swink, *Game Feel: A Game Designer's Guide to Virtual Sensation*** (2008) — the book that named the field. [game-feel.com](http://www.game-feel.com/), or the longer [*Game Feel: The Secret Ingredient*](https://www.gamedeveloper.com/game-platforms/feature-game-feel---the-secret-ingredient-) essay.
- **Disney's twelve principles of animation** — squash & stretch is principle one, from Frank Thomas and Ollie Johnston's *The Illusion of Life*. [Overview](https://en.wikipedia.org/wiki/Twelve_basic_principles_of_animation).
- **Martin Jonasson & Petri Purho, *Juice it or lose it*** (2012) — [the talk](https://www.youtube.com/watch?v=Fy0aCDmgnxg), and the [playable Breakout](http://grapefrukt.com/f/games/juicy-breakout/) they juice live on stage.
- **Jan Willem Nijman / Vlambeer, *The art of screenshake*** (2013) — [the talk](https://www.youtube.com/watch?v=AJdEqssNZ-U). Around 30 tricks in 25 minutes.
- **Mark Brown, *Secrets of Game Feel and Juice*** — [the Game Maker's Toolkit video essay](https://www.youtube.com/watch?v=216_5nu4aVQ), if you'd rather watch than read.
- **Hitstop / hitlag** — defined for fighting games in [Infil's Fighting Game Glossary](https://glossary.infil.net/?t=Hitstop).

The number hunt still plays identically with every effect stripped out. Nobody loses the game because they muted it or turned off motion. That's the line. Juice is something you add on top of a thing that already works, never a load-bearing part of whether it works at all.

Go back up and mash the coin one more time. That pull you feel, the small reluctance to stop, is the entire reason any of this is worth writing. It's eight lines of code standing between a form submission and something you don't want to put down.
