Skip to content

Game feel ​

Make jumps, hits and pickups feel good: squash and stretch, tweens, particles and trails from @debuffjp/juice, plus camera shake and hit-stop from the game itself. Press space or click in the example.

ts
const tweens = new Tweens();
const particles = new Particles(() => game.random.next()); // seeded, so replays match
const player = { x: 160, y: 140, scaleX: 1, scaleY: 1, hop: 0 };

if (input.pressed("pop")) {
  squash(tweens, player, 0.35);
  tweens.to(player, { hop: 14 }, {
    duration: 0.12,
    onComplete: () => tweens.to(player, { hop: 0 }, { duration: 0.3, ease: "outBounce" }),
  });
  particles.emit({ x: player.x, y: player.y - 14, count: 16, colors: ["#cffb2d", "#38bdf8", "#f472b6"], shape: "circle", gravity: 300 });
  game.camera.shake(0.3);
  game.freeze(0.05);
}
tweens.update(dt);
particles.update(dt);
▶ Run in playground

Create one Tweens and one Particles (or one per scene), and update both from your scene's update. Pass Particles the game's seeded random numbers, as above, so replays and scenarios produce the same sparks every run. Draw particles inside draw.withCamera, since they live in world space.

Squash and stretch ​

squash(tweens, target, amount, duration) sets the target's scaleX and scaleY to a squashed shape, then springs them back to 1. A positive amount squashes (wide and short, for landing); a negative one stretches (tall and thin, for a jump). The defaults are 0.3 and 0.25 seconds.

ts
squash(tweens, player, -0.25); // take-off
squash(tweens, player, Math.min(0.35, impactSpeed / 1200)); // a harder landing squashes more

The target is any object with scaleX and scaleY. Apply them when you draw, with the origin at the feet so the character squashes into the floor instead of shrinking around its middle:

ts
draw.push();
draw.translate(player.x, player.y); // y is the bottom of the sprite
draw.scale(player.scaleX, player.scaleY);
draw.sprite(frame, 0, 0, { origin: [0.5, 1] });
draw.pop();

squash replaces only the scale tweens on the target, so a move or fade that is already running keeps going. Before 0.8 it stopped every tween on the target.

Tweens ​

tweens.to(target, props, options) moves any numeric properties of any object to new values over time.

ts
tweens.to(coin, { y: coin.y - 20, alpha: 0 }, { duration: 0.4, ease: "outCubic" });

const t = tweens.to(door, { x: 64 }, { duration: 1, ease: "inOutQuad" });
await t.finished; // resolves when it completes, not when cancelled
OptionDoes
durationSeconds. Required.
easeA name below, or your own (t) => number. Default outQuad.
delaySeconds to wait first.
yoyoPlay forward, then back.
repeatExtra repetitions; -1 repeats forever.
onCompleteCalled when it finishes.

Eases: linear, inQuad, outQuad, inOutQuad, inCubic, outCubic, inOutCubic, outBack (overshoots, then settles), outElastic (wobbles), outBounce (bounces to a stop).

tweens.kill(target) stops every tween on an object, and tweens.kill(target, ["x"]) only those on x. tweens.has(target, "scaleX") tells you whether one is running, which is how the templates let an idle wobble play only while nothing else is squashing the character.

Particles ​

particles.emit(options) releases a burst at a point. Every option except x and y has a default:

OptionDefaultDoes
count10Particles in the burst.
speed[40, 120]Speed range in px/s.
angle, spread0, full circleDirection in radians, and the arc around it. angle: -Math.PI / 2, spread: 0.8 sprays upwards.
life[0.3, 0.6]Lifetime range in seconds. Particles fade over the second half of their life.
size, sizeEnd[2, 4], 0Size range in px, and the fraction of it left at the end of life.
colors["#fff"]Each particle picks one.
gravity0Downward acceleration in px/s².
drag1Velocity kept per second; 0.1 slows them hard, 1 not at all.
shape"rect"Or "circle".
additivefalseAdditive blending, for glows and sparks.

One Particles holds up to 2,000 particles at once (new Particles(random, max) changes that) and reuses them, so bursts don't allocate. For tens of thousands of particles or bullets, draw them yourself with draw.sprites (Drawing).

Trails ​

A Trail draws a fading line behind something fast, such as a dash, a projectile or a sword swipe.

ts
import { Trail } from "@debuffjp/juice";

const trail = new Trail(12, "#cffb2d", 3); // points kept, colour, width
trail.push(bullet.x, bullet.y); // every update
trail.draw(draw); // inside draw.withCamera

Call trail.clear() when the object respawns, or the trail draws a line across the screen.

Camera shake ​

game.camera.shake(amount) adds trauma from 0 to 1. The shake grows with the square of the trauma, so small hits stay small and big ones stack up, and it fades by itself.

ts
game.camera.shake(0.3); // a hit
game.camera.shake(0.6); // an explosion

At trauma 1 the camera moves up to game.camera.shakeAmplitude pixels (8 by default), and loses game.camera.shakeDecay trauma a second (1.5 by default). Shake only shows on what you draw inside draw.withCamera, so the HUD stays still.

Hit-stop and slow motion ​

game.freeze(seconds) pauses the simulation for a moment while drawing carries on. A few hundredths of a second on a hit makes it land harder.

ts
game.freeze(0.06); // a hit
game.freeze(0.12); // the player dies

Freezing again while frozen keeps the longer of the two, so many hits in one frame don't add up to a long pause. game.frozen is true during one. game.timeScale = 0.5 runs the simulation at half speed until you set it back to 1.

Reduced motion ​

Some players turn on reduced motion in their system settings. game.reducedMotion follows that setting live, and camera shake is turned down to a quarter while it's on, so it's still felt. Tone down your own effects too:

ts
if (!game.reducedMotion) squash(tweens, player, 0.3);

Flashes, screen-wide particles and fast parallax are the usual candidates. new Game({ reducedMotion: true }) forces it on (or false off), and a scenario can set "reducedMotion": true to capture how the game looks with it (Scenarios).

Tuning ​

How much to squash, shake and freeze is a matter of taste. Put the amounts in params and tune them with the game running:

ts
const feel = game.params.define("feel", {
  shake: { type: "number", default: 0.3, min: 0, max: 1 },
  hitStop: { type: "number", default: 0.06, min: 0, max: 0.3, units: "s" },
});

game.camera.shake(feel.values.shake);
game.freeze(feel.values.hitStop);

API reference