Handmade Web Games

Chapter 4: Animation, pixel art, and sprites

Techniques for web animation

Animations usually aren't built from just one technique, but are made up of a combination of patterns that can be assembled in different ways. So rather than just showing you isolated code examples, we'll take a different approach in this chapter. We'll build four small projects to teach the fundamental animation techniques you'll use frequently in web games.

  1. Bouncing ball. Velocity, acceleration, and basic collisions.
  2. Bouncing DVD logo. Separating simulation from animation.
  3. Animated grid-based movement. Animated vs logical positions, lerp, springs, easing functions.
  4. Sprite animation. Animations with images, plus some ways to upscale pixel art.

The code for all of these examples runs in a game loop (see Chapter 3), which means anything in a draw function will run synchronized with the monitor's refresh rate, and any code in a fixedUpdate function will run at a fixed 8ms interval.

A bouncing ball

Start with a static ball

Before animating anything, we'll set the ball's starting position and draw it on the canvas using the arc technique from Chapter 2.

Add velocity

Every frame, add the y velocity to the current y position.

Since the ball's y position is based on its center, we know it is touching the ground when its y equals bounds.height - ball.r.

Since the ball would rapidly fall out of frame forever if we did nothing when it collides with the ground, we'll temporarily wrap back to y=0 on each collision. The next step will make this less jarring!

Collisions

Ground collision: We're already checking to see whether ball.y exceeds bounds.height - ball.r. Now we need to flip the y velocity's sign to make it go in the other direction when we collide.

Ceiling collision: The y coordinate of the top of the canvas is 0, so the ball is touching the top when its y equals ball.r.

You can't only flip the velocity on collision.

Unlike in real life, there is no perfectly continuous motion in games. The ball is actually teleporting some number of pixels each frame. This means it's pretty unlikely that the ball ever perfectly hits a y position of bounds.height - ball.r. It'll more likely overshoot that value by a little bit, and we'll know to reflect its velocity for all values greater than that position. But if our logic only looked like this...

if (ball.y > bottomY) {
  ball.vy *= -1; // flip velocity
}

...then the ball would still be positioned below the floor after the velocity flip. Since the collision condition would still be true on the next frame, the velocity would keep flipping back and forth every update, causing the ball to be stuck inside the floor. So the solution is to move the ball back to the exact collision boundary before reflecting it:

if (ball.y > bottomY) {
  // move ball to collision boundary
  ball.y = bottomY;
  // then flip velocity
  ball.vy *= -1;
}

Add gravity

Acceleration makes the animation feel a lot more physically grounded. We can define a gravity acceleration value and apply it to the velocity each frame, remembering to incorporate dt into the update since this is a change over time. And that's it! With only a couple of new lines of code, we now have a physics-based animation.

Fix overshoot

When we set ball.y = bottomY in the previous step, we ended up throwing away part of the frame. If gravity pulls the ball 3 pixels past the floor, setting ball.y = bottomY deletes those 3 pixels of downward movement. As a result, each bounce loses a tiny bit of energy, which you can see if you leave the gravity example running for a long time.

So how can we account for this overshoot?

Measure how far the ball crossed the boundary, then reflect it back by that same amount:

const overshoot = ball.y - bottomY;
ball.y = bottomY - overshoot;

This simplifies to a one-liner:

ball.y = 2 * bottomY - ball.y;

Energy loss

To add further realism, the ball should lose some energy on each bounce. When we update the velocity on each impact, in addition to flipping the direction with *= -1 we'll now apply an energy loss factor as well. To lose 20% of the current energy each bounce, try this:

Come to rest

The ball in the previous demo never actually comes to a complete stop. It's doing tons of microscopic bounces as it gets ever closer to being at rest. To address that, if the ball's velocity ever dips below a threshold value during a collision (we've gone with 30), we now snap it to zero.

Squash and stretch

The first of Disney's twelve principles of animation [Wikipedia], squash and stretch adds interest to the animation as well as some realism, since a perfectly rigid ball would not be so bouncy. A key part of the principle is to keep the ball's volume constant. So if we are squashing by some factor vertically, then we need to stretch by that same factor horizontally. Otherwise it will look like the ball is changing size when it hits the ground.

The new squash value deforms the ball proportionally to its impact speed and decays back to zero. In real physics, this would be an exponential function, but I like the more animated look of the less-realistic linear function, so that's what I've gone with for this demo. Reducing the squash factor will also make it less cartoony.

const ball = {
x: 100,
y: 50,
r: 16, // radius
};

What we built in the bouncing ball example is a simulation. Each frame reads the previous frame's state, then uses that to compute where it's going next. You could easily use those same techniques to build the bouncing DVD logo animation [YouTube] but in this section we're going to do something totally different.

You can probably already picture what the state would look like for an animation like this. Maybe:

const logo = { x, y, dx, dy, numberOfBounces };

But what if I told you that the only state you need for that animation is this:

const state = { time }; // elapsed seconds since animation start

All of the other properties--the location, movement direction, and number of bounces--can be derived from time.

Every now and then you read something that permanently changes the way you approach certain problems. One post that did that for me was "Your Simulation Might Not Need State", which introduced me to the concept of animation states derived from time. This upcoming section is a canvas extension to that post, so go give it a read, too!

There's an analogy to this concept that you sometimes see in other coding problems: using an iterative solution vs a direct formula or "closed-form solution".

function (: Date, : Date) {
  let  = 0;
  let  = new ();
  while ( < ) {
    .(.() + 1);
    ++;
  }
  return ;
}

You could count the number of days between two dates one-by-one, like the above code. But we know we're just looking at two timestamps, and we know the duration of a day, so we have all the information we need to do this without the loop:

function (: Date, : Date) {
  const  = 24 * 60 * 60 * 1000;
  const  = .((.() - .()) / );
  return ;
}

Besides the fact that it feels elegant to use formulas to do in O(1) time what previously was done in O(N), there's a lot more to this "iterative vs closed-form" or "simulation vs animation" mental model than microoptimizations. If you can derive the current state without having to look back to the previous state, it becomes possible to massively parallelize these computations and run them on the GPU, which is how games are able to animate things like the movement of foliage so efficiently. But even if you're not planning to have millions of blades of grass in your browser game or use shaders or do any GPU optimization, if you can avoid storing state for every blinking light or rotating coin or idle breathing animation, you'll be doing future you a favor by reducing the complexity of your game code.

Let's look at a real example.

We'll start with the naïve, stateful solution where we need to store and update the logo's position, direction, and number of bounces. This should look pretty familiar if you made it to Step 3 (Collisions) in the bouncing ball code--only now, we're working in two axes.

Animations vs simulations

Now for the magic. We'll strip out almost everything from fixedUpdate and just use it to increment the elapsed seconds value (time). Then we'll use a formula to derive the current x and y positions from the time--no direction or velocity needed!

Now that we've removed all the physics simulation code, there's no longer any need to run this in a fixedUpdate loop. The variable rate update function that runs less frequently is well suited to this type of task.

If you want the formula explained...

Understanding the math behind the bounce position formula isn't essential, but it is an interesting puzzle regardless. In practice, time-based animations tend to use simpler formulas than this one, such as a plain sine wave or just time % value.

To derive the logo's x position from state, we'll first build a sawtooth pattern where x increases over the available width before wrapping back to zero:

const speed = 80; // px/s
const width = 300;
const x = (time * speed) % width;

We also need to subtract the logo's own width from the horizontal distance we're allowing it to travel:

const speed = 80; // px/s
const canvasWidth = 300;
const logoWidth = 100;
const width = canvasWidth - logoWidth;
const x = (time * speed) % width;

But we don't want the logo to teleport back to the origin after it reaches the far edge, so to make it ping pong we need to add some logic to reflect the shape. To mirror the whole animation we would do this:

const speed = 80; // px/s
const canvasWidth = 300;
const logoWidth = 100;
const width = canvasWidth - logoWidth;
const x = (time * speed) % width;
const invertedX = width - x;

So to make it reflect halfway, we'll need conditional logic. If we're in the first half, use x directly. If we're in the second half, use the mirrored x.

const speed = 80; // px/s
const canvasWidth = 300;
const logoWidth = 100;
const width = canvasWidth - logoWidth;
const x = (time * speed) % width;
const invertedX = width - x;
const displayX =
  x < width / 2
    ? x 
    : invertedX;

But this is bouncing after it only hits the halfway point in the x direction. Let's double the width to fix it.

const speed = 80; // px/s
const canvasWidth = 300;
const logoWidth = 100;
const width = canvasWidth - logoWidth;
const x = (time * speed) % (width * 2);
const invertedX = width * 2 - x;
const displayX =
  x < width // double the half-width
    ? x 
    : invertedX;

Now x bounces back and forth across the width of the canvas at a configurable speed. The exact same approach will work for the y and height values. And that is how we're able to bounce the DVD logo in both axes without needing to save the previous state. As a bonus, this approach is already framerate-independent and automatically scales to any canvas size responsively, too!

Count the bounces

Surely we need state to count each bounce, right? Wrong! The number of bounces is easily derived given time and the dimensions.

xBounces = floor((time * speed) / (canvas.w - logo.w));
yBounces = floor((time * speed) / (canvas.h - logo.h));
bounces = xBounces + yBounces;
color = colors[bounces % colors.length];
const logo = {
x: 40,
y: 40,
w: 96,
h: 42,
dx: 80, // horizontal velocity (px/s)
dy: 55, // vertical velocity (px/s)
bounces: 0, // we'll count # of walls hit
};

Just remember, relying on state is not bad! And actually, our "stateless" animation still relies on some state: time is state, too. Sometimes it's just more pragmatic to build a simple stateful solution than it is to use a clever formula. So our aim is not to eliminate statefulness everywhere we can. Instead, identify where it is practical to derive it rather than simulate it, and apply these techniques there. Because the fewer states your game can be in at any given time, the fewer branches you have to go down when debugging or reasoning about your code. And if that sounds appealing to you, there are a few more examples where we derive positions and other typically-stateful properties using only the current time in the section on sine waves in Chapter 5 →.

Animated grid-based movement

In grid-based games you want your logical game state to be in discrete, integer steps. So if the player is at x=1 y=2 and moves right, then they snap to x=2 y=2 on the next update. There are no in-between states, so your game rules will never have to deal with a player at x=1.333 y=2.5.

While snapping to a grid is great for game logic, it's rarely how you want to display movement to players. In this section we'll show how to decouple the logical grid position from the visual position.

Lerp

Lerp is a strange portmanteau for "Linear Interpolation", which blends between two numbers a and b. How far between? Some amount t--a value between 0 and 1.

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

// lerp(0, 1, 0.3) => 0.3
// lerp(0, 100, 0.3) => 30
// lerp(50, 100, 0.5) => 75
// lerp(123, 456, 0.789) => 385.737

When the t value updates over time, you get an animation.

const t = (time % 2) / 2;
const x = lerp(startX, endX, t);

And the same blend factor t can update several properties at once.

const t = (time % 2) / 2;

const x = lerp(startX, endX, t);
const opacity = lerp(0, 1, t);
const size = lerp(0, 40, t);
const rotation = lerp(-0.45, 0.45, t);

Easing functions

You mostly see unaccelerated motion in machinery. An automated factory is chock-full of linear tweens—robotic arms, conveyor belts, assembly lines. It is extremely difficult for humans to move in a linear fashion. Breakdancers who do “robot”-style moves have achieved their linearity with much practice. In this sense, you could say Michael Jackson is the master of the linear tween.
- Robert Penner on the Aesthetics of Linear Motion

The L in "lerp" stands for "linear". But in games and in life, movement tends not to be linear. So if you apply an easing function to the t parameter in your lerp, it will still take the same amount of time to get from a to b, but you can shape the acceleration and deceleration as you move between those fixed points for more natural-looking motion.

const eased = easeOut(t);
const x = lerp(startX, endX, eased);
function easeOut(t: number) {
  return 1 - (1 - t) ** 2;
}

There are a bunch of other easing curves, with examples and TypeScript functions you can copy at easings.net.

Fixed tweens vs follow behaviors

The lerp we've used so far is a fixed tween, measuring progress between a known start and end point. What do you do when you have a target that can move again before the previous move has finished?

Follows, unlike tweens, do not need to know the original starting position in order to smoothly close the gap between the current value and the latest target each frame.

Exponential smoothing

Exponential smoothing is a follow technique that brings a value asymptotically close to a target point, but never actually reaches it. No matter how fast you make the speed factor here, blue will always win.

The formula looks like this. You may notice something funny in it:

 = 1 - .(- * );
 = (, , );

That's right, follows can still use lerp! We just need to reframe the thinking a bit: let a in the lerp(a, b, t) function be the current position rather than the original starting position.

Be careful with lerp smoothing!

Seeing that formula, you might be tempted to update a position by simply lerping towards it each frame using the current value as the starting value in the lerp function, like this:

 = (, , );
avoid doing this

This is often referred to as lerp smoothing, and the main problem with it is how its behavior changes at different frame rates. Unfortunately, it's not as simple as just multiplying t by dt to correct the frame rate issue. To see why, check out the talk by Freya Holmer at the 2024 Guadalindie conference called Lerp smoothing is broken [YouTube].

Our "exponential smoothing" function is basically the exponential decay that Freya uses at the end. You may prefer her non-lerp rewrite of the same formula:

 =  + ( - ) * .(- * );

Springs

Springs are a physics-based follow function with configurable stiffness (how much the target pulls the value towards it) and damping (how much resistance the velocity faces). Like exponential follows, springs can also follow a moving target. But unlike the exponential follow, springs also carry a velocity value and can overshoot the target.

function stepSpring(
  value: number,
  velocity: number,
  target: number,
  stiffness: number,
  damping: number,
  dt: number,
) {
  const acceleration = -stiffness * (value - target) - damping * velocity;
  velocity += acceleration * dt;
  value += velocity * dt;
  return { value, velocity };
}

For more on springs, see Josh Comeau's A Friendly Introduction to Spring Physics and Glenn Fieldler's Spring Physics.

Mini animated grid movement project

The goal in this section will be to code a grid movement system with an exponential follow. An integer row and col grid position will always be available in the game state, but an animated visual position will also be available for drawing.

Grid setup

Our starting code will draw the player at row=0 col=0 in a small grid.

Instantaneous movement

We'll advance the logical cell on a timer without any animations yet.

Split logical and visual position

Keep col and row for game logic, but now we will draw from pixel x and y to prep for animating those values.

Smooth the visual position toward the logical cell

Implement exponential smoothing to make x and y chase the row and col.

const gridSize = 40;
const playerSize = 28;
const state = {
col: 0,
row: 0,
};

Sprites

People have been producing the illusion of motion through still images for hundreds of years. Analog mechanical animation media, like film, flip books, and zoëtropes all rely on the fact that if you flip through still frames fast enough, viewers will perceive motion.

Sprite animations, such as this one, also rely on that principle.

This sprite came from the free Pixel Planet Generator on itch.io.

A sprite (sometimes called a texture atlas or sprite sheet) is simply a single image with other images in it. Here, for example, we have a sprite sheet with all of the frames of our planet animation in it.

You can play through the animation by cropping to a frame, then adjusting the crop window on an interval.

Sprite sheets are used for much more than animation--they're often used for background tiles and textures as well since you can easily pack dozens of textures into a single file. The exact same techniques for indexing into a small window within a larger image can be used both for sprite animations and for static uses of sprites.

This tileset is from the Brackeys Platformer Bundle on itch.io.

Sprite sheets are especially useful in web games because they can significantly reduce the number of roundtrips your browser needs to make to fetch your game's assets.

Let's start with how to draw a single frame in a sprite. We'll need to use the 9-argument form of drawImage (see Chapter 2 →). The code below pulls from the source rectangle (our "crop window") within the image, then draws it to the destination rectangle on the canvas.

function ({ , ,  }: { : number; : number; : number }) {
  const  = 50;
  const  = 50;
  const  =  * ;
  const  = 0;
  .(
    ,
    // source rectangle
    ,
    ,
    ,
    ,
    // destination rectangle
    ,
    ,
    ,
    ,
  );
}

({ : 5, : 0, : 0 });
Scaling up for pixel art

Pixel art is often displayed larger than its original size. You have two ways to do this scaling:

1.Scale up your entire canvas

Scaling up the entire canvas means all positions, gradients, and lighting effects will be aligned to your larger pixel grid. There are no in-between pixels to blend between.

If you scale your entire canvas up, you'll also need to disable antialiasing on the canvas itself, by setting image-rendering: pixelated; in CSS.

2.Only scale up the artwork when drawing to the canvas

Scaling up only your pixel art assets allows for smooth movement between positions, smooth gradients and lighting effects, and even partially rotated pixels (which do not exist in a fully scaled canvas).

If you're not scaling the entire canvas up, you'll need to update drawSprite to handle in-canvas scaling:

function ({
  ,
  ,
  ,
  ,
}: {
  : number;
  : number;
  : number;
  : number;
}) {
  const  = 50;
  const  = 50;
  const  =  * ;
  const  = 0;
  . = false;
  .(
    ,
    ,
    ,
    ,
    ,
    ,
    ,
     * ,
     * ,
  );
}

({ : 5, : 0, : 0, : 4 });

To turn that into an animation, increment the index on an interval in the update cycle of your game loop.

const state = {
  frameTime: 0,
  index: 0,
};

function update(dt: number) {
  const totalFrames = 40;
  const frameDuration = 0.1;
  state.frameTime += dt;
  if (state.frameTime >= frameDuration) {
    state.index = (state.index + 1) % totalFrames;
    state.frameTime -= frameDuration;
  }
}
What about sprites with several rows?

Lining everything up in one row is convenient for animations, but makes less sense for background tiles or when using the same spritesheet for several animations. Luckily, the 9-argument form of drawImage is already close to perfect for the job.

function (: number, : number, : number, : number) {
  const  = 32;
  const  = 32;

  .(
    // your image asset (usually an HTMLImageElement)
    ,

    // the source rectangle (x, y, w, h)
     * , // startX
     * , // startY
    , // source width
    , // source height

    // the destination rectangle (x, y, w, h)
    ,
    ,
    ,
    ,
  );
}

If you want to create your own sprite sheets, Aseprite and Tilesetter are good (paid) options.

Performance improvements for upscaled pixel art

Scaling images every frame is rather expensive. We can instead pre-scale them in an OffscreenCanvas, which is just like a <canvas> element except that it can't interact with the DOM, so you'll only use it to compute bitmaps and then directly transfer those onto your main canvas.

const  = 4;
const  = 50;
const  = 50;
const  = 40;

// create an offscreen canvas with the exact scaled dimensions for our sprite
const  = new (
   *  * , // canvas width
   * , // canvas height
);

// pre-scale the entire sprite sheet one time up front
function () {
  const  = .("2d")!;
  . = false;
  .(, 0, 0, ., .);
}

// only run the one-time scaling once the sprite is fully downloaded
if (.) ();
else . = ;

function (: number, : number, : number) {
  .(
    ,
     *  * , // previously `index * frameWidth`
    0,
     * , // previously just `frameWidth`
     * , // previously just `frameHeight`
    ,
    ,
     * ,
     * ,
  );
}

OffscreenCanvases can even be used in Web Workers, allowing you to run expensive computations in a separate thread.

See also

Tools

  • Aseprite — animated sprite editor and pixel art tool
  • easings.net — interactive easing curve reference
  • Paint of Persia — like tracing paper for pixel art, with animation support
  • Tilesetter — can export a png + json file with coordinates for sprite sheets

On this page