No Time To Play

Brief guide to the 2D canvas in HTML5

by on May.18, 2017, under Gamedev

I started making web games using the 2D canvas API in 2009, early enough that people still went “I can’t believe it’s not Flash”. A year or two later, everybody and their dog was making canvas-based games, so mine weren’t special for long, but oh well. On the plus side, my skills are still entirely relevant eight years down the road — a lucky break in this world where we all have to run as fast as we can just to stay in place.

The canvas API isn’t exactly huge or obscure, and the Mozilla Developer Network covers it well. It can still be daunting to learn from scratch, especially if you don’t yet know what you’re going to need in actual game development.

As it turns out, I only ever use about two dozen fields and methods of the canvas element’s 2D context; you may be able to make do with even fewer. Of course, that’s just for the graphics — setting up a game loop and accepting input is another story.

Starting out

Speaking of context (both literally and figuratively), let’s start with the basics of setting up a web page to work on:

<!DOCTYPE html>
<meta charset="utf-8">
<title>HTML5 canvas example</title>
<canvas id="viewport" width="800" height="600"></canvas>
<script>
    window.addEventListener("load", function () {
        var viewport = document.getElementById("viewport");
        var context = viewport.getContext("2d");

        // Your drawing code goes here.
    });
</script>

The above is hardly best practice, but it is valid, idiomatic HTML5. First make it work, then make it right. How to have the canvas fill the browser window and resize with it is also another story. For now, let’s focus on how to show players some pretty graphics.

Well, if you’re making a traditional sprite-based game, you may well be able to make do with just one method:

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

where image is either a picture loaded from outside, or else another canvas element:

var image = new Image(); image.src = url;
// or else
var image = document.createElement("canvas");

(You can create one without ever showing it to the player, and use it as a backing store, not that you need double-buffering here. But you might be generating resource-intensive graphics at runtime. Preloading images is a good idea too, but it has nothing to do with the canvas API.)

Either way, drawImage() will paint its first argument at the given coordinates on the canvas (starting from the top-right corner), scaling it as required. You can omit width and height if you’re keeping the original size, and there are extra arguments to only pick a slice of the image, but I never used them.

Working with shapes and text

Sooner or later, however, you’ll want to draw various shapes directly, and the simplest is a rectangle, whether filled or just a contour:

context.fillStyle = color; // Black by default.
context.fillRect(x, y, width, height);

context.strokeStyle = color;
context.lineWidth = thickness; // 1 by default.
context.strokeRect(x, y, width, height);

Note how you set the color; the value can be either a named web color such as “red”, a hex string (“#ff0000”) or the RGB equivalent: “rgb(255, 0, 0)”. To make it partially transparent, you can either use “rgba(255, 0, 0, 0.5)”, or else the globalAlpha field:

context.globalAlpha = 0.5; // 1 (opaque) by default.

The latter applies to all subsequent operations, including image drawing, in addition to any other transparency these may have.

A little more involved is working with text, because in addition to colors you need to also specify the font, font size and alignment before painting:

context.font = "20px sans-serif";
context.textAlign = "center"; // "left"/"right"
context.textBaseline = "middle"; // "top"/"bottom"

context.fillText(text, x, y);
// and/or
context.strokeText(text, x, y);

The font field takes the same values as the CSS property of the same name. As it expects a precise order of modifiers, you’re better off sticking to the basic size+family format. Speaking of CSS, you can of course use any web font declared in your page styles. As for size, what would be a big no-no on a regular web page — giving it in pixels — is necessary for a game rendered on a canvas of known width and height. You don’t want your text being either too big or too small depending on the pixel density of your screen (or, all too often, whatever the operating system thinks it is).

Last but not least, you can also draw things more complicated than rectangles, but you need a bit of preparation. You see, the 2D canvas works with something called paths — a notion you’re also going to find in vector drawing apps. You set up a path, add shapes to it, then stroke and/or fill it at will:

context.beginPath();

context.rect(x, y, width, height);
context.arc(x, y, radius, angle1, angle2); // Usually 0 and Math.PI*2

context.fill();
// and/or
context.stroke();

You can probably guess by now that fillRect() and strokeRect() are shortcuts for the full syntax presented above; the latter is faster if you’re drawing a bunch of shapes at once, all with the same settings. As for arc(), it does what the name implies; as most of the time you’ll likely want a full circle, give the start and end angles as 0 (zero) and Math.PI*2, respectively. 2π is how you say “360 degrees” in radians — a notion from trigonometry.

(You probably knew that, and if not, you should brush up on trig 101: it’s very useful for game development.)

Last but not least, you can also use paths to draw arbitrary lines and polygons:

context.beginPath();

context.moveTo(0, -size);
context.lineTo(size, size * 0.5);
context.lineTo(-size, size * 0.5);
context.lineTo(0, -size);

context.moveTo(0, -size * 0.5);
context.lineTo(size, size);
context.lineTo(-size, size);
context.lineTo(0, -size * 0.5);

context.fill();

That’s a real example from my game Buzz Grid. I close each shape manually so they’ll be filled correctly; there’s a way to auto-close the last one, but it’s better to always be explicit.

Beyond flat colors

Before we move on, let me point something out. Isn’t it weird that it’s called “fillStyle” if it can only be a color anyway? The answer is contained in the question: you can fill shapes with more complex patterns as well. The one kind I actually needed in a couple of places was linear gradients. Here’s how to set one up:

var gradient = context.createLinearGradient(x1, y1, x2, y2);

gradient.addColorStop(0, "#140C1C"); // #597DCE
gradient.addColorStop(0.5, "#597DCE"); // #6DC2CA
gradient.addColorStop(0.5, "#597DCE"); // #6DC2CA
gradient.addColorStop(1, "#346524");

context.fillStyle = gradient;

The two pairs of coordinates in the first line give the direction and length of the gradient (relative to the canvas as a whole). E.g. for a vertical gradient going straight down you wantx1 equal to x2 (zero is fine), y1 set to the top and y2 to the bottom of the shape you plan to fill. Or play around with the values to create all kinds of funky effects. Either way, the color stops go from 0 at the start of the gradient to 1 at the end, much like the alpha, or opacity; my example yields a neat twilight effect.

Transformations and states

The 2D canvas is pretty damn fast. In Firefox it’s even hardware-accelerated (not sure about other browsers). But that’s not much help if you have to keep calculating offsets in Javascript code, e.g. in order to draw a minimap. For this purpose, the canvas supports transforming the coordinate system in various ways:

context.translate(x, y);
context.rotate(angle); // In radians.
context.scale(x, y);

As you might suspect, translate() moves the origins of the coordinate system from its default position in the upper left corner to wherever you want. Don’t worry, you can paint just fine at negative coordinates. Then there’s rotate(), which turns the coordinate system around (just like you might a piece of paper on a desk), allowing you to draw sideways, or upside-down. The angle is in radians, like for arcs, so setting it to Math.PI/2 makes the former up point to the right (rotating clockwise). Finally, scale() changes the size at which you draw things relative to the canvas element. For instance, context.scale(1, 1/2) will make all subsequent shapes appear squished to half height. That, by the way, is how you make an ellipse, in case you were wondering.

When you work with transforms, there are some quirks to keep in mind:

  • The 2D canvas may support sub-pixel rendering, but it’s still painting on a bitmap, with pixels. You can’t do context.scale(viewport.width, viewport.height) and expect coordinates to go smoothly from 0 to 1. Rather, you’ve just restricted yourself to a single (virtual) pixel!
  • Transforms only apply to the coordinate system. In other words, they only affect subsequent operations — what you’ve already drawn stays put.
  • Transforms are cumulative, so going back to the previous state would normally require some tricky calculations. Luckily, there’s a better way.

See, the 2D canvas works with a single global state. That means for instance you don’t have to give a color explicitly to each shape you’re drawing. But it also means you have to keep track of the current settings at all times. Which can be a bother if you want to change those settings just for a while — like for drawing a minimap, to repeat an earlier example.

For this purpose, the 2D canvas allows you to save the state of the current context, make some changes, then restore the previous state:

context.fillText("This shows in black.", 50, 50);
context.save();
context.fillStyle = "red";
context.fillText("This shows in red.", 100, 100);
context.restore();
context.fillText("This is black again.", 150, 150");

You can save several times in a row, too — all the states go on a stack. Just remember to restore as many times:

context.fillText("This goes in the top left.", 0, 10);
context.save();
context.translate(50, 50);
context.fillText("This shows 50 pixels down and across.", 0, 10);
context.save();
context.translate(50, 50);
context.fillText("Now a hundred pixels down and across.", 0, 10);
context.save();
context.translate(50, 50);
context.fillText("Make that a hundred and fifty!", 0, 10);
context.restore();
context.restore();
context.restore();
context.fillText("Sarting from the top left again.", 25, 35);

We’re nearing the end of this guide. There’s just one more trick I want to mention. You know how if you try to draw shapes bigger than the canvas element they’ll be partially hidden? That’s called clipping, and it prevents your graphics from spilling all over the place like graffiti. Well, sometimes — just sometimes — you need to apply the same principle to only part of your canvas. Here’s how to set it up:

context.beginPath();
context.rect(0, 50, 800, 500);
context.clip();

So, you begin with an ordinary path, add shapes — it can contain anything, not just a box — then instead of filling it you call clip(). Subsequent drawing operations will only have an effect inside this path. Beware that there’s no way to undo it short of restoring an earlier state, so be sure to save your context first. No worries, that’s a fast operation too.

There’s much more to the HTML5 canvas. I haven’t even covered compositing, for instance. But you can also do a lot with just the basics, which means you’ll spend more time coding your game than fiddling with details. And of the rest, you can always pick and choose. Mine is just one of many ways to do things.

:, ,

1 Trackback or Pingback for this entry

Leave a Reply

Turn on pictures to see the captcha *

Posts by date

May 2017
M T W T F S S
« Apr   Jun »
1234567
891011121314
15161718192021
22232425262728
293031  

Posts by month