Learn to Code via Tutorials on Repl.it

← Back to all posts
4
JavaScript Games Tutorial #3: the Canvas
MarkKreitler (33)

JavaScript Game Tutorial #3: the Canvas

Introduction

In this tutorial, we cover the basics of 2D graphics programming using the JavaScript Canvas object.

We start with creating a canvas and displaying text, then move on to drawing lines and shapes, and finally show how to draw "pixies" -- 2D pixel arrays that form the basis of almost every 2D game.

But First...

Before we get into the good stuff, we'll make a small foray into the topic of "code cleanliness". If you read our earlier tutorials, you might recall we discused "scope". Scope is a complex topic, but it boils down to a simple idea: every program running on your computer reserves memory for its data. Some of that memory is visible to other programs. Other bits of memory are visible only to the programs themselves, and even within a program, some of the memory is visible only to certain parts of the program at certain times. The rules defining what memory is visible to which program(s) is called "scope".

We don't need to worry about scope too much, but there is one thing we'd like to manage: we don't want the details of our programs to pollute the "global scope," which is the memory visible to all programs.

When you are running in a web browser, the global scope is the window in which you are running. Windows are large objects with many variables and functions. Here's a peek at the just the first bits of a Window object:

Every time we execute the following commands:

var myVariable;
var myFunction = function() {
	
};

we create a new object in the global scope. For instance, suppose we execute the command

var aardvark = 3;

and once again look at the Window object:

If you look carefully, you can see our 'aardvark' variable in the list of Window fields. Insert sad emoji here.

To minimize our footprint in the global scope, we are going to put all our tutorial code inside an object. We'll call it the 'ct' object, short for "CanvasTutorial":

ct = {
	// Our code goes here.
};

As we cover more topics, we will continue to add on to this object as follows:

ct.newVariable = 7;
ct.newFunction = myFunction() {
	// Code goes here...
};

Also note that, when you look in the tutorial code, you will see commands like:

this.ctxt = this.canvas.getContext('2d');

The this keyword means "whatever object I am currently in", which, for us, usually means the ct object.

Otherwise, this tutorial should look, walk, and smell just like the others we have done.

Let's get to the good stuff!

Creating a Canvas

In JavaScript, the Canvas is an object that manages a region of memory that can be rendered to the screen as an image. The Canvas is an object with its own variables and functions to call. In order to draw to it, you need to obtain a "context" -- an object whose helper functions we'll use to draw 2D primitives. In order to see it, you need to add it to the document in view.

Summarizing:

1) create a Canvas:

ct.myCanvas = document.createElement("canvas");
ct.myCanvas.width = 1024; // width in pixels
ct.myCanvas.height = 768; // height in pixels

2) obtain a context object:

ct.myContext = ct.myCanvas.getContext("2d");

3) add the new canvas to the document:

document.body.appendChild(ct.myCanvas);

Now, to draw to the canvas, you just call a method inside the context object. For example, to fill the canvas with a solid color:

ct.myContext.fillStyle = "black";
ct.myContext.fillRect(0, 0, ct.myCanvas.width, ct.myCanvas.height);

where 'fillStyle' is the color the canvas will use on all subsequent 'fill' commands, and 'fillRect' draws a rectangle with the top corner at x = 0 (the left side of the canvas) and y = 0 (the top of the canvas) with a width equal to the width of the canvas and the height equal to the height of the canvas.

Important Point: the left edge of the canvas has an x coordinate of 0, and increasing 'x' values move across the canvas to the right. The top edge of the canvas has a y coordinate of 0, and increasing 'y' values move down the canvas toward the bottom.

If you check out the first half of createCanvas.js, you can see all these steps in action.

Displaying Text

Since this is a graphics tutorial, naturally, the first thing we will display is...text???

Well, yes, but the good news is we'll be displaying the text in a canvas-y way, rather than a writeln-y way as we did previously.

To draw text to the canvas, we need to specify the font and color, then actually draw the text:

ct.myContext.font = "20px Arial";
ct.myContext.fillStyle = "green";
ct.myContext.fillText("My Message", 10, 50); // 10 = x-position, 50 = y-position

In the tutorial, we use fillText to display a collection of menu items as follows:

// One text size to rule them all...
textSize: 20,

// An array that contains all our menu elements.
menu: ["Draw a grid",
       "Draw rectangles",
       "Draw circles",
       "Draw to image",
       "Draw Pixie Forest",
       "Animate an image"],

displayMenu: function(textColor) {
  // Set the text size and color (color is passed in to the function).
  this.ctxt.font = this.textSize + "px Arial";
  this.ctxt.fillStyle = textColor;

  // Loop through all menu items, auto-generating each entrie's text and position.
  for (var i=0; i<this.menu.length; ++i) {
    var entryText = (i + 1) + ") " + this.menu[i];
    var textPosY = (i + 1) * this.textSize * 1.25;
    this.ctxt.fillText(entryText, 10, textPosY);
  }
}

Drawing Lines

Let's draw lines. The 2D context provides 2 methods to make this easy:

ct.myContext.moveTo(50, 10);  // 50 = x-position, 10 = y-position
ct.myContext.lineTo(500, 10); // Draws a line to x=500, y=10

The moveTo method puts the 2D graphics cursor at the specified (x, y) position without drawing anything.
The lineTo method moves the 2D graphics cursor to a new (x, y) position, drawing a line between the previous position and the new one.

Unfortunately, if you just execute these two commands, you won't see anything. For many commands like moveTo and lineTo, the Canvas expects you to move the graphics cursor several times, then draw all the lines in one go. In other words, it wants to process a whole batch of commands in one shot, which is often more efficient that drawing one line at a time.

In order to process batches of commands (often called "batch commands"), the canvas provides two methods:

ct.myContext.beginPath();	// Tells the canvas that a series of commands is coming.
ct.myContext.closePath();	// Tells the canvas that no more commands belong in this batch.

Then, to execute all the commands in the batch, the context provides:

ct.myContext.stroke();	// Draw all lines in the current batch.
ct.myContext.fill();	// Fill all shapes in the current batch.

Note that shapes like circles and rectangles can be drawn with either 'stoke' or 'fill' commands, or both -- depending on whether you want just the border, just the interior, or an interior with a border. We'll see how this works in the next section.

For now, we just want to draw grid lines across the screen. To do this, we use two loops: one to draw horizontal lines, and one to draw vertical lines. Here is the code (see grid.js):

ct.drawGrid = function() {
  this.fillWith("black");	// Fills the entire canvas with black.

  this.ctxt.strokeStyle = "red"; // Sets the color of strokes to red.

  // Start a new batch of lines.
  this.ctxt.beginPath();

  // Create horizontal lines.
  for (var iRow=0; iRow <= this.canvas.height; iRow += 20) {
    this.ctxt.moveTo(0, iRow);
    this.ctxt.lineTo(this.canvas.width, iRow);
  }

  for (var iCol=0; iCol <= this.canvas.width; iCol += 20) {
  	// Create vertical lines.
    this.ctxt.moveTo(iCol, 0);
    this.ctxt.lineTo(iCol, this.canvas.height);
  }

  // End the new batch of lines.
  this.ctxt.closePath();

  // Render the lines.
  this.ctxt.stroke();

  // Wait for the user to press a key, then return to the main menu.
  input.waitForAnyKey();
};

Important Point: if you omit the beginPath and closePath calls, you may see strange artifacts in your graphics. For instance, you might see an errant diagonal line connecting the end of one grid line to the start of the next. beginPath and closePath help the 2D context to track where the graphics cursor is and when it should be moved without drawing versus moved while drawing. Make sure you wrap your batch commands correctly, or you may get unexpected results.

Drawing Shapes

It's easy to draw rectangles and arcs with the 2D canvas.

Rectangles:

ct.myContext.fillRect(5, 25, 100, 40);	// Creates and fills a rectangle with top left corner at (5, 25).
										// Width = 100, heigh = 40.
ct.myContext.beginPath();
ct.myContext.rect(5, 25, 100, 40);		// Defines, but doesn't draw, the same rectangle.
ct.myContext.closePath();
ct.myContext.stroke();					// Draws the border of the rectangle (doesn't fill).
										

Arcs (partial circles):

ct.myContext.beginPath();
ct.myContext.arc(25, 40, 100, 0, 2.0 * Math.PI); 	// Defines, but doesn't draw, a full circle
													// centered at (25, 40) with radius 100
													// (the last two arguments, 0 & 2.0 * Math.PI,
													// define the amount of arc to draw. Since 2Pi
													// == 360 degrees, this command defines the
													// whole circle).
ct.myContext.closePath();
ct.myContext.fill();								// Fills the circle's interior.
ct.myContext.stroke();								// Draws the circle's border.

For our tutorial, we randomly-generate a collection of rectangles and a collection of circles. The only thing we haven't seen in that code is the use of the round, random, and min Math functions:

round rounds the input to the nearest whole number,
random generates a random decimal number between 0 and 1, and
min selects the smaller of two input numbers.

Combining these functions allows us to create shapes of random sizes and random locations on the screen.

Rectangles:

ct.drawRectangles = function() {
  this.fillWith("black");

  for (var i=0; i<10; ++i) {
    var width = Math.round(this.canvas.width * Math.random());
    var height = Math.round(this.canvas.height * Math.random());

    var left = Math.floor((this.canvas.width - width) * Math.random());
    var top = Math.floor((this.canvas.height - height) * Math.random());

    var color = this.colors[Math.floor(this.colors.length * Math.random())];

    this.ctxt.beginPath();
    this.ctxt.fillStyle = color;
    this.ctxt.fillRect(left, top, width, height);

    this.ctxt.strokeStyle = "white";
    this.ctxt.rect(left, top, width, height);
    this.ctxt.closePath();
    this.ctxt.stroke();
  }

  input.waitForAnyKey();
};

Circles (Arcs):

ct.drawCircles = function() {
  this.fillWith("black");

  for (var i=0; i<10; ++i) {
  	var maxRadius = Math.min(this.canvas.width / 2, this.canvas.height / 2);
    var radius = Math.round(maxRadius * Math.random());

    var x = radius + Math.floor((this.canvas.width - 2 * radius) * Math.random());
    var y = radius + Math.floor((this.canvas.height - 2 * radius) * Math.random());

    var color = this.colors[Math.floor(this.colors.length * Math.random())];

    this.ctxt.beginPath();
    this.ctxt.fillStyle = color;
    this.ctxt.arc(x, y, radius, 0, 2.0 * Math.PI);
    this.ctxt.fill();

    this.ctxt.strokeStyle = "white";
    this.ctxt.arc(x, y, radius, 0, 2.0 * Math.PI);
    this.ctxt.closePath();
    this.ctxt.stroke();
  }

  input.waitForAnyKey();
};

Drawing Images

The canvas also allows you do draw arbitrary images (as opposed to just shapes) to the screen. To do this, you create an offscreen canvas, draw into it, and then draw that canvas to the screen with the drawImage context method:

// Create a canvas to hold a 100 pixel by 100 pixel image.
ct.offscreenCanvas = document.createElement("canvas");
ct.offscreenCanvas.width = 100;
ct.offscreenCanvas.height = 100;

// Draw this image onto the visible canvas at position x=50, y=200.
ct.myContext.drawImage(ct.offscreenCanvas, 50, 200);

That's all there is to it!

In our tutorial, we'll draw a red box with a white cross into the offscreen canvas, then draw that canvas at a random position on the screen:

ct.drawToImage = function() {
    this.imageCanvas = document.createElement("canvas");
    this.imageCanvas.width = 100;
    this.imageCanvas.height = 100;

    var imgCtxt = this.imageCanvas.getContext('2d');

    imgCtxt.beginPath();
    imgCtxt.fillStyle = "red";
    imgCtxt.fillRect(0, 0, this.imageCanvas.width, this.imageCanvas.height);

    var stripeSize = Math.round(this.imageCanvas.width / 8);
    var x = this.imageCanvas.width / 2 - stripeSize / 2;
    var y = this.imageCanvas.height / 2 - stripeSize / 2;

    imgCtxt.fillStyle = "white";
    imgCtxt.fillRect(x, 0, stripeSize, this.imageCanvas.height);
    imgCtxt.fillRect(0, y, this.imageCanvas.width, stripeSize);

    imgCtxt.closePath();
};

// ...copy the image to the canvas.
ct.drawImageToCanvas = function() {
  this.fillWith("black");
  
  // Draw the image at a random location on the screen.
  var x = Math.round((this.canvas.width - this.imageCanvas.width) * Math.random());
  var y = Math.round((this.canvas.height - this.imageCanvas.height) * Math.random());

  this.ctxt.drawImage(this.imageCanvas, x, y);

  input.waitForAnyKey();
};

Drawing "Pixies"

Way back in the 80's, the Commodore 64 and Atari home computers introduced the idea of the "sprite" -- a 2D grid of pixels rendered at high speed in the graphics hardware. Sprites made it fast and easy to render thigns like animated characters, and they often supported collision detection in the hardware, too.

In the spirit of these early sprites, we introduce the "Pixie" -- a 2D array of pixels drawn via the 2D context. We accomplish this by creating a 2D array that represents the pixels, then looping through this array and drawing the pixels to an offscreen canvas. When we want to display the Pixie, we just draw its offscreen canvas to the visible canvas.

Before we look at that code, we need to cover a few new concepts.

First, when you define a JavaScript object like this:

var myObj = {a: 1, b: "cat", c: 3.14};

we can access the fields like this:

console.log("The value of field 'a' is:");
console.log(myObj['a']);

This would print out:

The value of field 'a' is:
1

Second, if you have defined a string like this:

var myString = "ABCD1234";

You can access each character like this:

console.log("The 3rd character is:");
console.log(charAt(2)); // Remember: first character is at position 0

which would produce the following output:

The 3rd character is:
C

With these concepts in our pocket, let's take a look at our "Pixie" code:

ct.pixieCanvas = null;

// Create an object that will map abbreviated colors to the full color name.
ct.pixiePalette = {
    r: "red",
    g: "green",
    b: "blue",
    y: "yellow",
    o: "orange",
    p: "purple",
    w: "white",
    l: "black",
    n: "brown",
    a: "gray",
  };

// Create the array representing the pixel data.
ct.pixieTreeData = [
  ". . . . . . . . . . . . . . . . ",
  ". . . . . . . g g . . . . . . . ",
  ". . . . . . . g g . . . . . . . ",
  ". . . . . . g g g g . . . . . . ",
  ". . . . . . g g g g . . . . . . ",
  ". . . . . g g g g g g . . . . . ",
  ". . . . . g g g g g g . . . . . ",
  ". . . . g g g g g g g g . . . . ",
  ". . . . g g g g g g g g . . . . ",
  ". . . g g g g g g g g g g . . . ",
  ". . . g g g g g g g g g g . . . ",
  ". . g g g g g g g g g g g g . . ",
  ". . g g g g g n n g g g g g . . ",
  ". . . . . . . n n . . . . . . . ",
  ". . . . . . . n n . . . . . . . ",
  ". . . . . . . n n . . . . . . . ",
];

ct.initTreePixie = function() {
  this.pixieCanvas = this.makePixie(this.pixieTreeData, 2);
};

// Draw the Pixie into its offscreen canvas by scanning
// through the associated 2D array.
ct.makePixie = function(data, scale) {
  var canvas = document.createElement("canvas");
  canvas.width = data[0].length * scale;
  canvas.height = data.length * scale;

  var pixieCtxt = canvas.getContext('2d');

  for (var iRow=0; iRow<data.length; ++iRow) {
    for (var iCol=0; iCol<data[iRow].length; iCol += 2) {

      // Get the character at the current point in the string.
      var colorCode = data[iRow].charAt(iCol);
      var color = null;

      // Check for the current character in the pallette object.
      if (this.pixiePalette.hasOwnProperty(colorCode)) {
        // If the pallette has this character, get the associated color.
        color = this.pixiePalette[colorCode];
      }

      // If we found a valid color, draw a rectangle into the off-screen pallette.
      if (color) {
        pixieCtxt.beginPath();
        pixieCtxt.fillStyle = color;
        pixieCtxt.fillRect(iCol / 2 * scale, iRow * scale, scale, scale);
        pixieCtxt.closePath();
      }
    }
  }

  return canvas;
};

ct.drawPixies = function() {
  this.fillWith("black");

  for (var i=0; i<50; ++i) {
    var x = Math.round((this.canvas.width - this.pixieCanvas.width) * Math.random());
    var y = Math.round((this.canvas.height - this.pixieCanvas.height) * Math.random());

    this.ctxt.drawImage(this.pixieCanvas, x, y);
  }

  input.waitForAnyKey();
};

Animation

Now that we have working Pixies, we can animate them. The simplest way to do this is with the setInterval method, which causes JavaScript to repeatedly call a target method at a time interval specified in milliseconds:

var myRepeatedFn = function() {
	console.log("Doin' it!");
}

var myCallback = setInterval(myRepeatedFn, 100);

In the above example, JavaScript will print "Doin' it!" to the console every 100 milliseconds (or so), forever. To stop JavaScript from calling the function, use the clearInterval command:

clearInterval(myCallback);

We will use setInterval to call a method that switches back and forth between two frames of an animated helicopter Pixie. We store the frames in an array. Each time we call the 'animate' function, we change to the other frame, clear the screen, and draw the new Pixie:

// Animate pixies on the canvas //////////////////////////////////////////////
ct.copterCanvas = [null, null];
ct.animFrame = 0;

ct.pixieCopterData01 = [
  ". . . . . . . . w w w w w w w w ",
  ". . . . . . . . w w . . . . . . ",
  ". . . . . . . . w w w w w . . . ",
  ". . w . . . w w w w w w . w w . ",
  ". w w w w w w w w w w w . . w w ",
  "w . w w w w w w w w w w w w w w ",
  ". . . . . w w w w w w w w w w . ",
  ". . . . . . w w w w w w . . . . ",
  ". . . . . . . w . . w . w w . . ",
  ". . . . . w w w w w w w w . . . ",
];

ct.pixieCopterData02 = [
  ". . w w w w w w w w . . . . . . ",
  ". . . . . . . . w w . . . . . . ",
  ". . . . . . . . w w w w w . . . ",
  "w . . . . . w w w w w w . w w . ",
  ". w w w w w w w w w w w . . w w ",
  ". . w w w w w w w w w w w w w w ",
  ". . . . . w w w w w w w w w w . ",
  ". . . . . . w w w w w w . . . . ",
  ". . . . . . . w . . w . w w . . ",
  ". . . . . w w w w w w w w . . . ",
];

ct.initCopterPixie = function() {
  this.copterCanvas[0] = this.makePixie(this.pixieCopterData01, 3);
  this.copterCanvas[1] = this.makePixie(this.pixieCopterData02, 3);
};

ct.startAnimation = function() {
  this.animFrame = 0;
  this.doAnimation();
  this.animCallback = setInterval(ct.doAnimation.bind(this), 67);
  input.waitForAnimStopKey();
};

ct.doAnimation = function() {
  var x = Math.round(this.canvas.width / 2 - this.copterCanvas[this.animFrame].width / 2);
  var y = Math.round(this.canvas.height / 2 - this.copterCanvas[this.animFrame].height / 2);

  this.fillWith("black");
  this.ctxt.drawImage(this.copterCanvas[this.animFrame], x, y);
  this.animFrame += 1;
  this.animFrame %= this.copterCanvas.length;
};

Notice that we use the '%=' operator, which performs the modulus operation on the frame number. Check out the 2nd tutorial in the series (Awari) for a discussion of that operation.

Conclusion

That's if for this tutorial! Hopefully, you now have a good feel for the basics of JavaScript 2D canvas programming. The Canvas can do much more, but even these basics give you the power to make good games.

Go out there and see what you can do!

Commentshotnewtop
1
timmy_i_chen (935)

Awesome! Thanks for making this. :)

2
MarkKreitler (33)

@timmy_i_chen Thanks for including it in the Game Jam "Help" section. I don't think anyone would have seen it, otherwise. :)

1
timmy_i_chen (935)

@MarkKreitler Oh that wasn't me, I'm pretty sure that was @KatyaDelaney :)