Master

Juice: Making It Feel Good

The difference between a demo and a game is juice, particles, screen shake, and squash-and-stretch. Learn the particle pool and camera systems that add feedback to every action.

What you will see

coin -> shake 3, 12 #ffcf40 sparkles hit -> shake 18, 40 #ef476f sparkles

About 7 min of reading

Try It

Juice is feedback: particles, screen shake, and a camera that eases toward the player. I build a particle pool that reuses dead slots, watch particles move and die, record and decay shake, reuse the pool across two actions, scale juice to the action, then ease a camera forward with a linear interpolation so it trails the player.

1

A particle pool pre-allocates slots and reuses the first inactive one on spawn instead of making a new object. I seed a PRNG so the random velocity and life are reproducible, spawn two particles, then run one 0.5s frame to see each particle move by vx*dt and lose life.

function makeRng(seed) { let s = seed >>> 0; return function () { s |= 0; s = (s + 0x6D2B79F5) | 0; let t = Math.imul(s ^ (s >>> 15), 1 | s); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } const rand = (lo, hi, rng) => lo + rng() * (hi - lo); class ParticlePool { constructor(max = 200) { this.pool = Array.from({ length: max }, () => ({ x: 0, y: 0, vx: 0, vy: 0, life: 0, maxLife: 1, size: 3, color: '#fff', active: false, })); } spawn(x, y, color, rng) { for (const p of this.pool) { if (!p.active) { p.active = true; p.x = x; p.y = y; p.vx = rand(-120, 120, rng); p.vy = rand(-120, 120, rng); p.maxLife = p.life = rand(0.3, 0.7, rng); p.color = color; return p; } } } update(dt) { for (const p of this.pool) { if (!p.active) continue; p.x += p.vx * dt; p.y += p.vy * dt; p.life -= dt; if (p.life <= 0) p.active = false; } } } const rng = makeRng(42); const pool = new ParticlePool(200); const p1 = pool.spawn(300, 200, '#ffcf40', rng); console.log('spawned: active=' + p1.active + ' x=' + Math.round(p1.x) + ' y=' + Math.round(p1.y) + ' vx=' + Math.round(p1.vx) + ' vy=' + Math.round(p1.vy) + ' life=' + p1.life.toFixed(2)); const p2 = pool.spawn(305, 202, '#ffcf40', rng); console.log('second spawn reuses inactive slot: active=' + p2.active); pool.update(0.5); console.log('after 0.5s: x=' + Math.round(p1.x) + ' y=' + Math.round(p1.y) + ' life=' + p1.life.toFixed(2));

Before you run it, say out loud what it will print. Then open the answer.

What it prints
spawned: active=true x=300 y=200 vx=24 vy=-12 life=0.64 second spawn reuses inactive slot: active=true after 0.5s: x=312 y=194 life=0.14
spawn() returns the SAME pool object every time, so after the first free slot is taken the second call reuses it, that is the point of a pool: no new objects, just reused slots. Position moves by vx*dt, so at 0.5s the x changed by 24*0.5 = 12.
2

Screen shake records impact energy and fades it with a multiplier under 1, so a hit recoils and then naturally dies out. I add a small shake twice, then a hard hit that should cap, and print the decay each frame until it is mostly gone.

function makeRng(seed) { let s = seed >>> 0; return function () { s |= 0; s = (s + 0x6D2B79F5) | 0; let t = Math.imul(s ^ (s >>> 15), 1 | s); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } const rng = makeRng(1); const rand = (lo, hi) => lo + rng() * (hi - lo); class Camera { constructor(width, height) { this.x = 0; this.shake = 0; } addShake(amount) { this.shake = Math.min(this.shake + amount, 24); } update(dt) { if (this.shake > 0) { this.shake *= 0.9; this.x += rand(-this.shake, this.shake); } } } const cam = new Camera(960, 540); cam.addShake(3); cam.addShake(3); console.log('addShake(3) twice -> shake=' + cam.shake); cam.addShake(18); console.log('addShake(18) -> shake=' + cam.shake); let frames = cam.shake; for (let i = 0; i < 4; i++) { frames = Math.round(frames * 0.9); console.log('shake decays: ' + frames); }

Before you run it, say out loud what it will print. Then open the answer.

What it prints
addShake(3) twice -> shake=6 addShake(18) -> shake=24 shake decays: 22 shake decays: 20 shake decays: 18 shake decays: 16
addShake adds up to a hard cap of 24 px (a coin is a small nudge, a hit is a big one), and each frame the shake is multiplied by 0.9, so it shrinks smoothly until it is gone. That exponential decay is what makes a recoil feel physical.
3

A camera should ease toward the player, not snap. The easing is a lerp a toward b by a fraction t; using dt as t keeps the ease the same speed on any frame rate. I run lerp over a few fractions, then drive the camera forward at 60 fps for three frames to see it trail the moving look target.

function lerp(a, b, t) { return a + (b - a) * t; } console.log(lerp(100, 300, 0)); console.log(lerp(100, 300, 1)); console.log(lerp(100, 300, 0.5)); const RUN_SPEED = 340; let cam = 0; let look = 0; const dt = 1 / 60; for (let f = 0; f < 3; f++) { look += dt * RUN_SPEED * 1.2; cam = lerp(cam, look, Math.min(1, dt * 8)); console.log('frame ' + f + ': cam=' + Math.round(cam) + ' look=' + Math.round(look)); }

Before you run it, say out loud what it will print. Then open the answer.

What it prints
100 300 200 frame 0: cam=1 look=7 frame 1: cam=3 look=14 frame 2: cam=5 look=20
lerp(a, b, t) moves a toward b by the fraction t: t = 0 stays, t = 1 snaps. Using Math.min(1, dt * 8) as t means the ease speed depends on dt, so it feels the same at 30 or 60 fps. The camera x trails the look target, then catches up smoothly.
4

The pool is shared, so every action borrows from the same slots. I spawn a small coin burst that fills the free slots, let them all die, then spawn a hazard burst and confirm it revives the very first freed slot (the same object) instead of making a new particle.

class ParticlePool { constructor(max) { this.pool = Array.from({ length: max }, () => ({ active: false, x: 0, y: 0, vx: 0, vy: 0, life: 0, color: '#fff' })); } spawn(x, y, color, life, vx, vy) { for (const p of this.pool) { if (!p.active) { p.active = true; p.x = x; p.y = y; p.vx = vx; p.vy = vy; p.life = life; p.color = color; return p; } } return null; } update(dt) { for (const p of this.pool) { if (p.active) { p.x += p.vx * dt; p.y += p.vy * dt; p.life -= dt; if (p.life <= 0) p.active = false; } } } } const pool = new ParticlePool(3); const c1 = pool.spawn(300, 200, '#ffcf40', 0.3, 20, 10); const c2 = pool.spawn(302, 201, '#ffcf40', 0.3, -15, 25); console.log('coin burst fills slots: ' + [c1, c2].filter(Boolean).length + ' of ' + pool.pool.length); pool.update(0.4); console.log('after 0.4s both dead: ' + (!c1.active && !c2.active)); const hit = pool.spawn(500, 250, '#ef476f', 0.5, -30, 0); console.log('hit reuses first free slot (same object as c1): ' + (hit === c1));

Before you run it, say out loud what it will print. Then open the answer.

What it prints
coin burst fills slots: 2 of 3 after 0.4s both dead: true hit reuses first free slot (same object as c1): true
spawn always reuses the earliest free slot, so a second burst revives the same particle objects instead of allocating new ones. That is what lets the game fire 40 sparkles per hit without creating garbage for the browser to collect.
5

Juice should scale with the action, a coin is a small bright nudge, a hit is a big red one. I write one feedback() function that returns the shake amount, particle count, and color for each action type and prints both, so the rule is visible in the numbers rather than guessed.

function feedback(action) { if (action === 'coin') return { shake: 3, particles: 12, color: '#ffcf40' }; return { shake: 18, particles: 40, color: '#ef476f' }; } const coin = feedback('coin'); const hit = feedback('hit'); console.log('coin -> shake ' + coin.shake + ', ' + coin.particles + ' ' + coin.color + ' sparkles'); console.log('hit -> shake ' + hit.shake + ', ' + hit.particles + ' ' + hit.color + ' sparkles');

Before you run it, say out loud what it will print. Then open the answer.

What it prints
coin -> shake 3, 12 #ffcf40 sparkles hit -> shake 18, 40 #ef476f sparkles
If every action used the same shake and particle count, the feedback would mean nothing, a coin pickup would feel like a hit. Making juice scale with the action is what lets the player read the outcome at a glance.

Put it in order

Before you write anything, put these lines back in the right order. Click a line to move it up one step. This support step is easier than writing code from scratch, and it is here to make the last example stick.

Data Flow

Each action spawns pooled particles and sets camera shake; the loop updates particles and decays shake while drawing them with fading alpha.
1 A pickup or hit calls particles.spawn(x, y, color)
↓
2 spawn reuses the first inactive particle with a random velocity
↓
3 addShake records impact energy on the camera
↓
4 update moves particles and decays shake toward zero
↓
5 draw renders particles with fading globalAlpha, then the shake jitters the frame
Why this works
The particle object pool

Creating and deleting objects constantly is slow. A pool pre-allocates a fixed number of particles and reuses them, when one dies, the next spawn revives it instead of allocating memory.

// Reuse the same array slot.
if (!p.active) {  // free slot, reuse it
  Object.assign(p, { active: true, x, y, vx: rand(-120,120), ... });
  return p;
}

Benefits:

  1. No garbage collection stalls. No churn of new/dead objects for the browser to clean up.
  2. Bounded cost. The spawn loop checks at most max particles, predictable performance.
  3. Reuse is cheap. spawn just rewrites a few fields.

The pool is the reason the game can show 100+ sparkles without dropping frames. It's the same idea as reusing a single <canvas>: keep the expensive resource (memory, context) stable, and only change the data inside it.

Without a pool, a 200-particle burst per coin pickup, times dozens of coins, would thrash memory. The pool keeps it smooth.
When to add juice

Juice is not decoration, it's feedback the player reads as information:

  • Coin pickup → burst of yellow sparkles + small shake. "You got a coin."
  • Hazard hit → big shake + red burst. "You died."
  • Camera ease → smooth follow. "The world keeps moving."

The rule: feedback should scale with the action. A coin is a small shake; a hit is a big one. If every action shakes the same, the feedback means nothing.

// Coin: small, bright.
this.camera.addShake(3);
for (let i = 0; i < 12; i++) particles.spawn(coin.x, coin.y, COLORS.coin);

// Hit: large, red.
this.camera.addShake(18);
for (let i = 0; i < 40; i++) particles.spawn(player.x, player.y, COLORS.hazard);

Without this feedback, a coin pickup is silent and invisible, the player won't know they succeeded. Juice closes the loop between action and response.

Examples

Particles for feedback

When you grab a coin or a hazard hits, the game explodes a handful of sparkles. Particles are tiny, short-lived sprites that make actions feel impactful. The game stores them in a pool (reuse objects instead of creating garbage):

class ParticlePool {
  constructor(max = 200) {
    this.pool = Array.from({ length: max }, () => ({
      x: 0, y: 0, vx: 0, vy: 0, life: 0, maxLife: 1, size: 3, color: '#fff', active: false,
    }));
  }

  spawn(x, y, color) {
    for (const p of this.pool) {
      if (!p.active) {
        p.active = true;
        p.x = x; p.y = y;
        p.vx = rand(-120, 120);
        p.vy = rand(-120, 120);
        p.maxLife = p.life = rand(0.3, 0.7);
        p.color = color;
        return p;
      }
    }
  }

  update(dt) {
    for (const p of this.pool) {
      if (!p.active) continue;
      p.x += p.vx * dt;
      p.y += p.vy * dt;
      p.life -= dt;
      if (p.life <= 0) p.active = false;
    }
  }

  draw(ctx) {
    ctx.fillStyle = '#fff';
    for (const p of this.pool) {
      if (p.active) {
        ctx.globalAlpha = Math.max(0, p.life / p.maxLife);
        ctx.fillRect(p.x, p.y, p.size, p.size);
      }
    }
    ctx.globalAlpha = 1;
  }
}

  • spawn finds the first inactive particle and revives it with new coordinates.
  • update moves each alive particle by vx * dt, shrinks its life, and kills it when life <= 0.
  • draw paints each particle, fading by its remaining life via globalAlpha.

The globalAlpha = 0.6 in draw makes each sparkle semi-transparent, so overlapping ones blend into a burst instead of looking like solid squares. That's a small call that does most of the "juice".

Screen shake

The camera can shake to sell impacts, a coin pickup gives a small nudge, a hazard hit shakes hard. The game stores the shake in the Camera class:

class Camera {
  constructor(width, height) {
    this.width = width;
    this.height = height;
    this.x = 0;
    this.y = 0;
    this.shake = 0;
    this._lookX = 0;   // where the camera is easing toward (follows the player)
  }

  addShake(amount) {
    this.shake = Math.min(this.shake + amount, 24);  // cap the shake at 24 px
  }

  addX(pxPerFrame) { this._lookX += pxPerFrame; }

  update(dt) {
    // ease the look target toward the player
    this.x += (this._lookX - this.x) * Math.min(1, dt * 8);
    // shake decays; add jitter based on remaining energy
    if (this.shake > 0) {
      this.shake *= 0.9;                                  // decay
      this.x += rand(-this.shake, this.shake);            // jitter x
    }
  }
}

  • addShake(amount): record how much to shake.
  • update(dt): each frame, the shake *= decay (here 0.9), so it shrinks until it's gone. While any shake remains, a random offset in [-shake, shake] jitters the camera.

So a big hit creates a shake that fades naturally rather than stopping instantly. That fade is the "juice", it reads as a physical recoil.

Notice this.shake *= 0.9: a multiplier under 1 always produces a smooth exponential decay. That one line is the trick behind nearly every "juice" animation.

Quadratic easing on the camera follow

The camera should ease toward the player, not snap. Snapping feels jarring; easing feels cinematic. The math is a simple lerping toward a target:

function lerp(a, b, t) {
  return a + (b - a) * t;
}

// in Camera.update(dt):
this.x += (this._lookX - this.x) * Math.min(1, dt * 8);
// which is the same as:
this.x = lerp(this.x, this._lookX, Math.min(1, dt * 8));

lerp(a, b, t) moves a toward b by the fraction t. When t = 0, no move; when t = 1, you're at the target. Using dt (not a fixed fraction) means the ease has the **same speed regardless of frame rate*: the same idea as delta time in the Game Loop lesson.

The game applies this to follow the player horizontally:

this.camera.addX(dt * CONFIG.RUN_SPEED * 1.2);  // move look target with the player
this.camera.update(dt);                          // ease toward it

The camera's x trails the player, then catches up smoothly, like watching a runner through a moving window. That single dt * 8 factor (the t in lerp) controls how "floaty" vs. "tight" the follow feels.

Words this lesson uses

Each one is defined here on this site. Open the entry for the full definition and the other words it points at.

← Scoring, Persistence, and the HUD
Previous Lesson
Scoring, Persistence, and the HUD
Next Lesson
Testing a Game with Node
Testing a Game with Node →