Chapter 10: Game feel and juice
How to make your game feel good.
Juice?
In game design, juice refers to the visual, audio, and controller feedback added to make a game feel alive and highly satisfying to play. It's not just about making things flashy and loud, though. Done well, juice makes your game more immersive, interesting, and intuitive to the player.
To demonstrate a few of the techniques we'll explore in this chapter, our slightly-juiced Pong includes impact particles, screen shake, bullet time, and scaling up on impact. These effects increase with the speed of the ball so that the intensity of the effects ramps up gradually as the game itself gets more intense.
These are all visual effects, but feedback for the other senses is just as important.
Haptic feedback
If you've added controller support, then you can deliver touch feedback through controller haptics. (And if you haven't yet added controller support, you should! It's easy--see Chapter 8.) Haptics provide a low-effort way to increase the immersiveness of your game through tactile feedback.
Audio feedback
Some basic tips when providing audio feedback in games:
- If you have overlapping sounds, run them through a compressor so the total volume doesn't grow too high.
- Randomize the pitch or speed of repetitive sounds, or loop through several audio samples.
- State changes and collisions, especially those triggered by the player, are good candidates for sound effects.
Visual feedback
Anticipation
(todo: two cannon fire demos. one charges up and animates before firing, the other does not.)
Squash and stretch on impact
(todo: bumping into walls with squash and without)
Screen shake
(todo: demo + example code, based on the WIP cameras chapter)
Freeze on impact
(todo: example code adding simulated pause time to the accumulator)
Particles
Particle effects are a great way to add juice to your games. Impact sparks, smoke, tire dust, muzzle flashes, blood splats, water spray, jump clouds, explosions, fire, vehicle exhaust, and ambient weather like rain or snowfall can all be implemented with particle emitters. So can fire and smoke emissions from a rocket:
Click to focus. WASD or arrow keys to move.
This is the code that got me excited about particle effects. It's adapted from The Transmitter, a submission to the Ludum Dare 59 game jam by a co-author to this guide, Austin @onsclom.
The basic technique is to start with some state similar to our bouncing ball from Chapter 4.
const particle = {
x: 0,
y: 0,
vx: 0,
vy: 0,
age: 0,
life: 0,
// plus any other properties you want...
color: "orange",
fromSize: 10,
toSize: 0,
fromOpacity: 1,
toOpacity: 0,
};The main new concepts here are age and life.
ageticks up withdtonce per updatelifeis how long the particle lasts- when
age > lifewe skip updating and rendering - any
from*andto*values arelerped usingt = age / life
The state from this particle provides settings for size and opacity changes over the particle's life, but you can add anything you want to it. Maybe you want to be able to transition the colors, add rotation speed, use a non-linear easing curve--when you define your own particle system, you decide exactly what goes into it.
Using this state, let's spawn some particles.
A single particle
We'll start by emitting just one particle on an interval from the center of the canvas. This code should look pretty familiar if you followed the earlier example with the ball.
You'll notice we're using the variable frame-rate update function rather than fixedUpdate since particles tend to be used for visual flare rather than for game logic that requires fixed time steps. This means we can avoid updating the particles' positions more frequently than they're rendered.
Lerp size and opacity
Next we'll add a few properties to allow us to transition the size and opacity of the particle over the course of its life. Here we're animating the size and opacity, but you can update any properties you want or even derive them without adding state.
Lots of particles
Because they're small and ubiquitous, it's easy to end up with thousands of particles on screen. Assuming updates run at 120fps, that could involve hundreds of thousands of object allocations and dynamic array resizes per second. That can be a problem when the browser decides it is time to run the garbage collector [Wikipedia]. If it takes too long to collect and free unused objects, you may drop a frame or two periodically.
We'll mitigate this issue by creating our particles up front. The idea is to preallocate one big array for all the particles you could ever need, and generate the objects in it just once. This approach, called a ring buffer or circular buffer [Wikipedia], is an optimization. So far in this guide we've been fairly oblivious to performance concerns, but when you might be spawning thousands of particles 120 times per second, particle emitter code is likely to be the hottest loop in your game code. The ring buffer reduces potential dropped frames that can happen during garbage collection by preventing said "garbage" from being created in the first place. Operations like .filter or .map and building new objects all allocate new memory that needs to be cleaned up by the GC. By making a fixed-size array with all the particle objects in it up front we eliminate that work for the GC entirely.
Is this premature optimization?
Our benchmark aims to answer that question.
In my browser, the benchmark shows that a ring buffer ensures relatively stable update times whereas a push + filter approach occasionally slows down during GC--at least, for very high particle counts. Most small games don't need the number of particles required to really see the difference here, though. That being said, the more memory your game uses, the slower garbage collection gets. So what might be negligible GC time when you're starting out your project could result in many dropped frames as your code grows in complexity later on. Tracing sources of slow GC is arguably way harder than using a ring buffer, so I'd say that this is a worthwhile optimization to include.
I will note two minor inconveniences with the ring buffer approach, though: (1) you need to estimate a reasonable upper bound on the total particles up-front, and (2) you need to clear out stale properties when you initialize a new particle.
If you'd like to learn more about how V8's Orinoco garbage collector works, Playing with Garbage [YouTube] by SimonDev provides a great rundown.
Blend modes
The "lighter" blend mode is often used for fire and sparks since it makes overlapping particles brighter. We'll increase the number of particles on screen to highlight the effect.
const state = {emitter: {x: bounds.width / 2,y: bounds.height / 2,},interval: 1.3, // secondstime: 0,particle: {x: 0,y: 0,vx: 0,vy: 0,age: 0,life: 0,},};
Game feel
- input buffering
- coyote time
References
- Celeste & Forgiveness by Maddy Thorson
https://garden.bradwoods.io/notes/design/juice
https://www.youtube.com/watch?v=Fy0aCDmgnxg
https://www.youtube.com/watch?v=AJdEqssNZ-U
in some other chapter--tile maps: https://developer.mozilla.org/en-US/docs/Games/Techniques/Tilemaps