Handmade Web Games

Chapter 2: Intro to CanvasRenderingContext2D

Learn to paint on a <canvas>

Picking up from Chapter 1, you now have a canvas on the page and a 2D context to draw into. If you're looking at the size of the scrollbar on this page and wondering what you've gotten yourself into--don't worry. This chapter is a reference as much as a tutorial, so skip to whatever you need.

With that said, there are a few essentials you should know first.


Canvas intro, basic concepts, and setup

Canvas setup

Chapter 1 left off with starter code and a simple fillRect draw call. Remove the blue rectangle and what is left is the necessary code that must precede all of the other examples in this chapter.

What does each of these lines do?

The first line creates a canvas element, exactly like writing <canvas> in index.html would have done.

This canvas element hasn't actually been mounted anywhere yet, though. So immediately after that, we append the canvas to the <body> element of our html document.

The next line determines which drawing API we'll be using. Options other than "2d" include:

  • "webgl" (often used for 3D games and animations, or anything that requires shaders)
  • "webgpu" (many of the same use cases as webgl, but with a lower-level and newer API, so browser support [caniuse] is limited)
  • "imagebitmaprenderer" (a very specialized tool to get frames rendered by a worker thread efficiently into the main thread's canvas)

We'll be sticking with "2d", which gives us all of the functionality you see in the examples throughout this chapter, including fillRect which you have already used.

You'll also notice the ! non-null assertion at the end of this line. canvas.getContext("2d") can return null ONLY IF the canvas has already been set up for one of the other contexts above. So you can't, for example, change a webgl canvas into a webgpu canvas on the fly. The TypeScript compiler isn't able to statically determine that we won't be doing that, but we know that, so the null case is not a concern.

The final line with getBoundingClientRect() [MDN] provides the current dimensions of our canvas. bounds will be an object with the properties top, right, bottom, left, x, y, width, and height. We'll only need those last two properties in this chapter.

src/main.ts
const canvas = document.createElement("canvas");
document.body.appendChild(canvas);
const ctx = canvas.getContext("2d")!;
const bounds = canvas.getBoundingClientRect();

Hello, world!

To begin, draw text with fillText.

Notice that fillText takes only a string and position as arguments, yet the text renders with a font and color. Those come from state that is set on ctx at the moment it runs--here, "32px sans-serif" and "deepskyblue".

Context2D relies on shared mutable state

Yes, shared mutable state 😱

Any property you set on the context (fillStyle, lineWidth, font, for example) stays set until something changes it. Here, fillRect inherits the same fillStyle that colored the text blue.

The coordinate system

The two numbers after "Hello, world!" are the x and y coordinates, measured in pixels from the left edge and pixels from the top. y increases downward, unlike the coordinates you learned in school.

The z axis

Paint operations run in order, so whatever was drawn last sits on top.

High-DPI displays

If you're following along and your canvas looks blurry, your screen can handle more pixels than the canvas has at its default pixel density. A display with a devicePixelRatio of 2 has four physical pixels for every CSS pixel. But by default the canvas doesn't automatically scale itself for high-density displays, resulting in a stretched, blurry image instead.

The fix is the following standard boilerplate, which we'll cover in depth at the end of the chapter.

src/main.ts
ctx.font = "32px sans-serif";
ctx.fillStyle = "deepskyblue";
ctx.fillText("Hello, world!", 20, 100);
// x y

CanvasRenderingContext2D API

Finally, the fun part. Here's a curated cheatsheet with the Context2D functionality you should know about for games.

Paths and shapes

fillRect

As you saw above, use it to draw rectangles.

strokeRect

Draw borders around rectangles. The stroke is centered along the edge of the rectangle, so adjust sizing according to lineWidth as needed.

Dashed lines

Use ctx.setLineDash([length, gap]). Change the dash phase offset like ctx.lineDashOffset = 4 or animate it for a marching ants effect. To reset, pass an empty array: ctx.setLineDash([]). More examples on MDN.

Circles

A circle is just an arc that extends 2π radians. So if you arc from 0 to Math.PI * 2 with some radius, you'll get a circle.

ctx.arc(x, y, radius, startAngle, endAngle)

If you find that you're drawing a lot of circles, ctx.arc is a clunky way to build them each time. A tiny helper function can go a long way for readability.

function circle(ctx, x, y, radius) {
  ctx.beginPath();
  ctx.arc(x, y, radius, 0, Math.PI * 2);
  ctx.fill();
}

Paths

The circle example above introduced a new concept: paths!

You can draw your own shapes with the following methods:

.(); // start drawing a path
.(, ); // jumps to a new position
.(, ); // draw a line to a coordinate
.(); // line straight back to path origin
.(); // outline the path
.(); // fill the path
// shapes
.(, , , ); // add rectangle to path
.(, , , , ); // rounded rectangle
.(, , , , , , );
// curved lines
.(, , , , );
.(, , , , );
.(, , , );
.(, , , , , );

Line joins for rounded corners

By default corners are sharp (lineJoin="miter") but you can round them with lineJoin="round" or bevel them with lineJoin="bevel".

Line caps

Line caps are used for the ends of a line that are visible when you don't close a path.

Arcs

Can be useful for progress indicators.

ctx.arc(x, y, radius, startAngle, endAngle)

ctx.fillStyle = "deepskyblue";
ctx.fillRect(20, 40, 200, 100);
// x y w h

Text

Text rendering deep dive

Let's return to our hello world example.

Stroke text

Like many of the other fill* APIs, there's a stroke equivalent for text.

Measuring text

Typography enthusiasts should be excited to learn that you can read many font metrics directly with ctx.measureText("..."). Unfortunately, some metrics such as emHeightAscent cannot currently be used due to poor browser support [caniuse].

measureText returns...
  • fontBoundingBoxAscent - how many px above the baseline the tallest letter (or highest ascender) can possibly go font-wide (for any text)
  • fontBoundingBoxDescent - how many px below the baseline the lowest descender goes font-wide
  • actualBoundingBoxAscent - for the specific text being measured, how many px above the baseline does it go
  • actualBoundingBoxDescent - for the specific text being measured, how many px below the baseline do descenders go
  • actualBoundingBoxLeft and actualBoundingBoxRight - when you draw text starting at some x origin, the actual characters can be drawn some distance to the left or the right of it depending on the textAlign and whether they're italicized. actualBoundingBoxLeft gives you the distance from x to the true left edge and actualBoundingBoxRight gives you the distance from the x origin to the true right edge.
  • width - confusingly, is not the sum of actualBoundingBoxLeft + actualBoundingBoxRight. width refers to the number of pixels that a cursor would advance when typing the measured text. Each glyph has an "advance" value, and width is their sum which also takes into account kerning. Another way to think about this is that it is the right property to use for a cursor if you're implementing a text editor because it's a layout value not a precise measurement of the pixels of the text.
  • some additional properties: emHeightAscent/emHeightDescent that aren't widely supported but measure the distance to the top and bottom of the letter M, and ideographicBaseline/hangingBaseline typically for internationalization.

Text alignment

By default text is expected to be aligned to the bottom-left. However, you can see that the descenders of the comma and letter g are clipped. Using the right vertical alignment can fix this.

Vertical alignment

Use textBaseline for vertical alignment.

Horizontal alignment

Use textAlign for horizontal alignment. Possible values are left/right/center/start/end. The default is start, which maps to left in left-to-right locales and maps to right in right-to-left locales.

Font weights, italics, families

You can specify these properties (and others [MDN]) in the ctx.font as well.

Custom fonts

You have a few options:

  1. Download a font and use @font-face in CSS [MDN], allowing the browser to decide when it loads and whether to show a fallback during loading.
Option 1 deep-dive

Option 1 requires that we return to the index.html from Chapter 1. For this example we'll use the variable font Doto.

One benefit to option 1 over option 3 is that the font can start downloading even before your JavaScript bundle is parsed by the browser, as long as it's also applied to some text outside of your canvas. (You have to trick the browser by creating a hidden text element that uses it.) On the other hand, the main trade-off with this approach is that you'll likely see a brief flash of text in a fallback font before the font has loaded.

Even if you change font-display: swap to font-display: block, browsers will often still briefly display a fallback font, especially to users with slower internet. font-display is a hint to the browser, not an instruction. And because fonts can have a large impact in games and the swapping behavior looks unpolished, this trade-off might be a nonstarter for you. Hence we typically opt for option 3 for web games.

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Your Game</title>
    <style>
      @font-face {
        font-family: "Doto";
        font-style: normal;
        font-weight: 100 900; /* range of weights */
        font-display: swap;
        src: url(/fonts/doto.ttf) format("truetype");
      }
      p {
        color: transparent;
        font-family: "Doto";
      }
      canvas {
        position: fixed;
        inset: 0;
      }
    </style>
  </head>
  <body>
    <p>Hidden text to trick the browser into downloading the font asap</p>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>
  1. Use a service that does (1) for you, like Google Fonts. (Don't do this.)
Why not?

Creating a dependency on a third party service can be tempting when it offers convenience, but this has downsides. First of all, you now require the service to always host your font at this exact URL. Suppose Google is no longer licensed to serve your game's font. Or perhaps they decide to charge for this service at some point. Or maybe someday their servers have downtime. Because the font is not a part of your game's bundle, in any of these scenarios you're simply out of luck. We want to guarantee that your game is playable with all of its fonts 20 or even 100 years from now.

The cross-site network request also complicates things when we work on preparing your game to work offline. Self-hosting your fonts and other assets makes offline mode dead simple.

For these and many other reasons, option 2 is a poor choice.

  1. For precise control over the timing of the loading of your font, download it and load with JavaScript. This is typically the best choice for games, so it's our pick in this guide.

To load a custom font in JavaScript, you'll first need to download a font file (typically .ttf, .otf, or .woff2) from a source like Google Fonts or itch.io. If you have a choice, woff2 is usually preferred for its smaller file sizes.

You'll then asynchronously load the font. See MDN for explanations of the global FontFace constructor and document.fonts.add.

Async font loading

Although the previous code works, you'll probably see a flash of unstyled text (FOUT) in a fallback font while the custom font initially loads.

One option is to simply render nothing until the font has loaded. You can decide for your project whether a fallback font or no text at all while it loads feels better.

ctx.font = "50px sans-serif";
ctx.fillStyle = "deepskyblue";
ctx.fillText("Hello, world!", 50, 100);

Opacity and blending

Opacity using colors with alpha

fillStyle and strokeStyle accept a wide variety of color formats. Many of these support an Alpha channel, which you can use to control the opacity of whatever you are painting.

Opacity using globalAlpha

Often, simply using colors with an alpha channel for opacity won't be sufficient.

Fading in and out an image asset is one case where that approach breaks down.

Coordinating translucency changes across many different shapes, especially during a fade animation, is another example where the previous technique would be a pain.

For these and many other uses, we have globalAlpha.

Save and restore stack

You can save the full state of the Context2D at any time with ctx.save(), then restore it with ctx.restore().

Why do this?

Subtle bugs can arise when you mutate the shared state in the canvas Context2D. Imagine that you have ctx.lineWidth = 5 deep within some nested function call. Then you call that function in another place and suddenly a bunch of unrelated shapes which had expected to inherit a different lineWidth are now incorrectly using 5.

A clunky solution to this might be to collect the "before" values for every property that is updated. Then change those properties, execute the paint, and restore each one back to its original value. When you're doing that potentially thousands of times across your code, this approach becomes a burden. Thankfully, we have a much more elegant option.

Enter save() and restore().

ctx.save() takes a snapshot of the entire current state of the context and pushes it onto a stack. ctx.restore() pops the top item off of this state stack and restores the values back to what they were at the time of the snapshot being saved.

You can save and restore multiple times and in any order. Just remember--like closing your parentheses in code--the number of ctx.save() calls MUST equal the number of ctx.restore() calls by the end of each animation frame or you'll run into problems.

ctx.fillStyle = "#0ea5e980";
// rrggbbaa
ctx.fillRect(20, 15, 100, 100);
ctx.fillRect(70, 65, 100, 100);
ctx.fillStyle = "rgba(255, 0, 0, 0.5)";
// r g b a
ctx.fillRect(0, 50, 100, 40);
ctx.fillStyle = "oklch(83% 0.19 84 / 0.3)";
// L C hue alpha
ctx.fillRect(70, 50, 150, 40);
// wow, art

Images

drawImage

ctx.drawImage(image, x, y) [MDN] paints an image with its top-left corner at (x, y) drawn at its intrinsic size.

Just like fonts, images load asynchronously as soon as src is set. If you want to show a spinner while waiting until the image can be shown, you can await its decode promise, which resolves not just after the image has loaded, but after the bitmap is in memory and ready to be rendered.

async function (: string) {
  const  = new ();
  . = ;
  await .();
  return ;
}

Pixel art image credit: ansimuz

drawImage with destination rectangle

The same drawImage method has another form that accepts a destination width and height. The entire image is mapped onto the rectangle defined by (x, y, width, height), so the original aspect ratio is ignored.

ctx.drawImage(image, x, y, width, height);

drawImage with source crop and scale

The third, final form that drawImage supports uses nine arguments to crop according to a source rectangle then scale and draw according to a destination rectangle. This is super useful for spritesheets.

Nine-argument function calls are a lot to look at, so the key is to think of the two rectangles separately:

  • The source rectangle (sx, sy, sw, sh) (in image pixels) defines which parts of the source image to use.
  • The destination rectangle (x, y, w, h) (on the canvas) defines where to paint the source image.

Image scaling option 1: contain

To contain the full image within the canvas (like object-fit: contain [MDN]), figure out whether the height or width will be the smaller factor, then scale uniformly according to that factor and center the image. Since we're drawing the entire source image, we only need the five-argument form of drawImage.

Image scaling option 2: cover

For object-fit: cover behavior, figure out which dimension needs the larger scale factor, then derive the crop dimensions. The source rect starts at (0, 0), so extra pixels are chopped off the right or bottom edges.

This requires the nine-argument drawImage function signature.

Center-based origin for object-fit: cover

Instead of lopping off pixels from just the bottom/right, you can anchor based on the center of the image with the following code.

import imageUrl from "./assets/example.png";
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d")!;
const image = new Image();
image.src = imageUrl;

const bounds = canvas.getBoundingClientRect();
const { width, height } = bounds;
const nw = image.naturalWidth;
const nh = image.naturalHeight;
const scale = Math.max(width / nw, height / nh);
const sw = width / scale;
const sh = height / scale;
const sx = (nw - sw) / 2;
const sy = (nh - sh) / 2;
const source = { x: sx, y: sy, w: sw, h: sh };
const dest = { x: 0, y: 0, w: width, h: height };
ctx.drawImage(
  image, // source bitmap
  source.x,
  source.y,
  source.w,
  source.h,
  dest.x,
  dest.y,
  dest.w,
  dest.h,
);

Crisp pixels for pixel art

The pixel art in the previous demo looks bad! To scale images up sharply, you'll also need to set ctx.imageSmoothingEnabled = false to disable antialiasing.

// import imgUrl from './assets/example.png';
// const image = new Image();
// image.src = imgUrl;
ctx.drawImage(image, 0, 0);

Filters

The filter [MDN] property allows you to apply post-processing effects such as blur, contrast adjustment, and hue rotation. Filters still don't work in Safari [caniuse] (including iOS Safari), so you may want to hold off on applying them for now.

ctx.filter is expensive!!

Use sparingly, and ideally not inside of your game loop.

Before you reach for ctx.filter, ask yourself: can you achieve this effect in another way? Pre-process your assets when you can rather than applying post-processing effects at runtime. With that out of the way, these effects are quite fun to look at--here they are:

Blur

Applies a Gaussian blur to all following paints. This will get your fans humming if done within a game loop!

Brightness

Contrast

Grayscale

Hue-rotate

Invert

Fun fact: invert(50%) always creates middle gray.

Opacity

Included here for completeness, but please never do this. Use globalAlpha or semitransparent colors instead.

Saturate

Sepia

Multiple filters

You can combine as many filters as you'd like.

ctx.filter = `blur(10px)`;
ctx.drawImage(image, 0, 40);

Gradients

Intro to gradients

fillStyle and strokeStyle can be used for more than just colors. Gradients also work!

We'll start with linear gradients. You'll pick a starting coordinate and ending coordinate, then add color stops some % along that line.

It's important to note that the gradient exists in global canvas pixels. This demo shows how it's not scaled to the shape you're applying it to.

Horizontal gradients

Same deal as vertical gradients, just change the coordinates of the line for the gradient. You can follow the same approach for angled linear gradients.

Radial gradients

For radial gradients, instead of defining a line you define two circles: createRadialGradient(x1, y1, r1, x2, y2, r2)

The first circle is usually a point in the middle and the second circle is the full size of the glow you want to create.

Radial gradient lighting effect

If you misalign the inner and outer circles, you can create a downlight effect.

Conic gradients

Useful for color wheels.

const { height } = bounds;
// a vertical linear gradient
const gradient = ctx.createLinearGradient(
0, // x1
0, // y1
0, // x2
height, // y2
);
gradient.addColorStop(0, "white");
gradient.addColorStop(0.5, "blue");
gradient.addColorStop(1, "black");
ctx.fillStyle = gradient;
ctx.fillRect(20, 0, 100, 180);
ctx.fillRect(140, 40, 100, 60);

Blend modes

If you've worked with image editors like Photoshop you will be familiar with the concept of Blend Modes.

These determine how the next painted layer interacts with what is beneath it. Common blend modes used in games include:

  • lighter which picks the lighter of the two values (often in particles/explosions)
  • multiply (darkening shadows, ink staining effects)
  • screen similar to lighter but preserves highlights better (fog, light rays)
  • overlay / soft-light / hard-light boost contrast (often for applying textures or color grading)
  • hue / color for color grading

Multiply

Hue

Color

Similar to hue but also applies saturation.

Overlay

Overlay with texture

Texture credit: Becca Lavin

ctx.imageSmoothingEnabled = false;
ctx.drawImage(image, 0, 40);
ctx.globalCompositeOperation = "multiply";
const x = 250;
const y = 90;
const g = ctx.createRadialGradient(
x,
y,
0,
x,
y,
80, // radius
);
g.addColorStop(0, "white");
g.addColorStop(1, "black");
ctx.fillStyle = g;
ctx.fillRect(0, 0, 376, 180);

Composite operations

Composite operations [MDN] use the same globalCompositeOperation property as blend modes. Instead of color mixing, these determine which pixels from the source and destination survive.

In image editors you may have seen "intersect" or "mask", for example. Those same techniques can be applied in a canvas with globalCompositeOperation="destination-in" and globalCompositeOperation="destination-out".

destination-out (text subtraction)

destination-out subtracts everything from the background that overlaps with the drawn shape.

destination-in (text mask)

destination-in subtracts everything from the background outside of the drawn shape.

destination-in (gradient mask)

Useful for weapon scopes, flashlights, and fog-of-war effects when you need feathered or complex edges. Otherwise, clip() is often simpler.

destination-in (decals)

Decal credit: pngkey. Brick texture credit: Kenny Eliason

ctx.imageSmoothingEnabled = false;
ctx.drawImage(image, 0, 40);
ctx.globalCompositeOperation = "destination-out";
ctx.font = "bold 100px sans-serif";
ctx.fillText("helloooooooo", 0, 125);

Clipping

clip() turns the current path into a mask, so pixels only change when they fall inside the clipped region.

A common game use is drawing a large image once while only revealing part of it (eg. minimaps).

Circular clip on an image

Build a circle on the path, call clip(), then drawImage. Remember to save() and restore() to release the clipping mask.

Multiple Path2D clips (intersection)

You can also pass a Path2D [MDN] to clip(path) without touching the context's current path. Calling clip() again with another path intersects the new region with the previous clip.

Bonus: Clipped blur

Combined with a filter you can create a glassmorphic effect.

ctx.save();
ctx.beginPath();
ctx.arc(120, 90, 75, 0, Math.PI * 2);
ctx.clip();
ctx.drawImage(bricks, 0, 0);
ctx.restore();

Transforms

Transforms modify the coordinate system itself rather than individual draw calls. Instead of calculating where to draw each shape, you shift, stretch, or rotate the entire canvas and then draw at simple coordinates.

translate

ctx.translate(x, y) shifts the origin of the coordinate system. After translating, all drawing operations are offset by that amount. Here we draw the character at (0, 0) but it appears at (80, 30) because the origin has moved.

Character asset credit: craftpix

Transforms are cumulative

Each translate shifts the origin from its current position, not from (0, 0). Two translate(100, 0) calls are the same as one translate(200, 0).

The same is true of rotate and scale.

scale

ctx.scale(x, y) multiplies all coordinates and sizes. This also affects positions, not just dimensions!

Negative scale to flip

ctx.scale(-1, 1) mirrors the x-axis, flipping your drawing horizontally. Because the axis reverses, you need to translate first so the result stays in view. Similarly, ctx.scale(1, -1) mirrors the y-axis.

rotate

ctx.rotate(angle) rotates the coordinate system clockwise by angle radians. Rotation happens around the current origin (0, 0) by default--which is usually not what you want.

Rotate around a point

To rotate around a specific point, translate to that point first, then rotate. When you draw, you'll also need to offset the x and y by half the object's width and height.

setTransform

ctx.setTransform(a, b, c, d, e, f) and ctx.resetTransform() replace the current transform matrix entirely rather than multiplying onto it like translate/scale/rotate do.

You'll mainly use it to:

  1. Perform advanced transformations
  2. Handle DPI scaling

As a general tool for transforms

setTransform can apply any of the above transformations without accumulating its effect with repeat calls. The arguments a–f map cleanly to each of the other transforms.

Translate, scale, rotate, and skew using setTransform
  • translate sets e and f
  • scale sets a and d
  • rotate sets a through d, where a = cosθ b = sinθ c = -sinθ and d = cosθ
  • skew, lacking a built-in method, sets b and c, where b = tan(skewY) and c = tan(skewX)

DPI scaling in-depth

The key insight is that we're dealing with three distinct "pixel" concepts:

  1. Physical pixels -- the actual hardware pixels on the screen. A 2019 MacBook Pro has 2560×1600 of these on its "retina" display.
  2. CSS pixels -- the abstract unit browsers use for layout. That same MacBook runs its browser at 1280×800 CSS pixels. These are device-independent and can be affected by browser zoom. When you write width: 100px in CSS, you mean 100 CSS pixels.
  3. Canvas buffer pixels -- the internal resolution of the canvas element, set by canvas.width and canvas.height. This is completely independent of the canvas's size on the page.

Suppose we're working with a canvas 800px wide on that 2019 MacBook Pro. Its devicePixelRatio is 2, so there are 1,600 physical pixels spanning the width of the canvas. Our internal canvas buffer still only has 800 slots on the x axis for pixels, though. So our first task is to remedy that by setting canvas.width = width * dpr.

This creates two new problems:

  1. Within the canvas, we're operating on a much larger area. If you call fillText("Hello", 20, 100) you meant 20 CSS pixels from the left, but now 20 buffer pixels is a tiny fraction of the way across. Everything would draw in the wrong place at the wrong size.
  2. The canvas itself now occupies twice as much space in the layout.

ctx.setTransform(dpr, 0, 0, dpr, 0, 0) fixes the first problem. It scales the x and y axes within the canvas so that fillText("Hello", 20, 100) actually lands at (40, 200) but visually appears 20 CSS pixels from the left. It's just like ctx.scale(dpr, dpr) but without the cumulative effects. scale compounds with previous transforms, setTransform does not.

canvas.style.width = width + "px" addresses the second problem. Explicitly setting the number of CSS pixels occupied in the layout by the canvas ensures the canvas only takes up its original CSS size.

ctx.imageSmoothingEnabled = false;
ctx.translate(80, 30);
ctx.drawImage(guy, 0, 0, 96, 128);

Up next

In the next chapter, we'll lay the groundwork for how to animate the canvas. Chapter 3 deals with game loops--updating the canvas at a high, variable frame rate, while handling physics at a reliable fixed interval.

On this page