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.
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);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.
squash(tweens, player, -0.25); // take-off
squash(tweens, player, Math.min(0.35, impactSpeed / 1200)); // a harder landing squashes moreThe 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:
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.
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| Option | Does |
|---|---|
duration | Seconds. Required. |
ease | A name below, or your own (t) => number. Default outQuad. |
delay | Seconds to wait first. |
yoyo | Play forward, then back. |
repeat | Extra repetitions; -1 repeats forever. |
onComplete | Called 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:
| Option | Default | Does |
|---|---|---|
count | 10 | Particles in the burst. |
speed | [40, 120] | Speed range in px/s. |
angle, spread | 0, full circle | Direction 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], 0 | Size range in px, and the fraction of it left at the end of life. |
colors | ["#fff"] | Each particle picks one. |
gravity | 0 | Downward acceleration in px/s². |
drag | 1 | Velocity kept per second; 0.1 slows them hard, 1 not at all. |
shape | "rect" | Or "circle". |
additive | false | Additive 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.
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.withCameraCall 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.
game.camera.shake(0.3); // a hit
game.camera.shake(0.6); // an explosionAt 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.
game.freeze(0.06); // a hit
game.freeze(0.12); // the player diesFreezing 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:
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:
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);