Canvas Drawing for Games

1

Canvas Drawing for Games

▶

The HTML5 Canvas API is the cornerstone of browser-based game development. It gives you a blank rectangular drawing surface directly inside a web page, and a rich JavaScript interface to paint pixels, shapes, images, and animations onto that surface in real time. Unlike static HTML elements, the canvas is entirely code-driven — every dot, rectangle, and sprite you see is the result of explicit drawing commands executed by your game loop. Understanding how to set up the canvas, organize your drawing calls, and cycle through clear-and-redraw frames is the foundation upon which every game mechanic you add will rest.

Before writing a single line of JavaScript, you need to place the canvas element in your HTML document. The <canvas> tag is an ordinary inline element, but it requires two critical attributes: width and height. These attributes define the internal resolution of the drawing surface — the actual number of pixels available for drawing — and they are completely separate from any CSS sizing you might apply. Always set them directly on the element rather than relying solely on CSS, because CSS stretches or shrinks the canvas after the fact, which can distort your graphics.

<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>My Canvas Game</title>
  <style>
    body { margin: 0; background: #111; display: flex; justify-content: center; }
    canvas { display: block; border: 2px solid #444; }
  </style>
</head>
<body>
  <canvas id="gameCanvas" width="800" height="400"></canvas>
  <script src="game.js"></script>
</body>
</html>

Here the canvas is 800 pixels wide and 400 pixels tall. The id attribute lets JavaScript locate it easily. Now, inside game.js, you retrieve the element and obtain its 2D rendering context. The context object is your drawing toolkit — it holds every method and property needed to paint onto the canvas.

// game.js

// 1. Grab the canvas element from the DOM
const canvas = document.getElementById('gameCanvas');

// 2. Get the 2D drawing context
const ctx = canvas.getContext('2d');

// ctx is now ready for drawing commands
console.log(canvas.width, canvas.height); // 800 400

getContext('2d') returns a CanvasRenderingContext2D object. Storing it in a variable called ctx is a widely adopted convention — you will see this name throughout tutorials, documentation, and production game code. Every drawing instruction you write from this point forward will be a method call or property assignment on ctx.

With the context in hand, the first visual task is painting a background layer. In a game, the background sits behind every other element — the sky, floor, a dungeon wall, or simply a solid color. The most straightforward approach uses two properties and one method:

  • ctx.fillStyle — sets the current fill color. Accepts any CSS color string: '#1a1a2e', 'skyblue', 'rgb(30, 90, 180)', or even gradients and patterns.
  • ctx.fillRect(x, y, width, height) — draws a filled rectangle. Passing 0, 0, canvas.width, canvas.height fills the entire canvas.
// Paint a solid dark-blue background
ctx.fillStyle = '#1a1a2e';
ctx.fillRect(0, 0, canvas.width, canvas.height);

For a more atmospheric background you can use a linear gradient. A gradient transitions smoothly between two or more colors and can simulate a sky that fades from deep blue at the top to lighter blue at the horizon.

// Sky gradient background
const skyGradient = ctx.createLinearGradient(0, 0, 0, canvas.height);
skyGradient.addColorStop(0, '#0d0d2b');   // dark at top
skyGradient.addColorStop(1, '#1a6ea8');   // lighter blue at bottom

ctx.fillStyle = skyGradient;
ctx.fillRect(0, 0, canvas.width, canvas.height);

For tiling textures — like a repeating brick or grass pattern — ctx.createPattern(image, 'repeat') builds a fill that tiles the source image across the rectangle. You would load the image first, wait for it to finish loading, then create and apply the pattern:

const tileImg = new Image();
tileImg.src = 'grass.png';
tileImg.onload = () => {
  const pattern = ctx.createPattern(tileImg, 'repeat');
  ctx.fillStyle = pattern;
  ctx.fillRect(0, canvas.height - 80, canvas.width, 80); // ground strip
};

A crucial rule: always draw background elements before everything else. The canvas paints layers in the order you call drawing commands — later calls paint over earlier ones. Your background must come first so that characters, obstacles, and UI elements appear on top of it.

Next come characters and sprites. For a quick prototype, simple geometric shapes work perfectly. Rectangles are drawn with ctx.fillRect(), and circles (or partial arcs) are drawn using the path system: ctx.beginPath(), ctx.arc(), then ctx.fill().

// --- Simple rectangle player ---
let player = { x: 100, y: 300, width: 40, height: 60, color: '#e94560' };

function drawPlayer() {
  ctx.fillStyle = player.color;
  ctx.fillRect(player.x, player.y, player.width, player.height);
}

// --- Circular alternative ---
function drawCirclePlayer() {
  ctx.beginPath();
  ctx.arc(player.x, player.y, 20, 0, Math.PI * 2); // cx, cy, radius, startAngle, endAngle
  ctx.fillStyle = '#e94560';
  ctx.fill();
  ctx.closePath();
}

Storing the character's position in x and y variables is essential. When the player presses a key, you increment or decrement those variables, and on the next frame the character is drawn at the new position — creating movement. Never hard-code coordinates directly into drawing calls if you want the object to move.

For production-quality visuals, load a sprite image using the built-in Image constructor, then render it with ctx.drawImage():

const playerSprite = new Image();
playerSprite.src = 'hero.png';

function drawPlayer() {
  // ctx.drawImage(image, destX, destY, destWidth, destHeight)
  ctx.drawImage(playerSprite, player.x, player.y, player.width, player.height);
}

// For sprite sheet animation, use the extended 9-argument form:
// ctx.drawImage(image, srcX, srcY, srcWidth, srcHeight, destX, destY, destWidth, destHeight)
// This crops a specific frame from a sprite sheet before drawing it.

Always wait for images to fully load before drawing them. Wrapping your game initialisation inside an onload callback, or using a loading-state counter for multiple assets, prevents blank or broken sprites on the first frame.

Obstacles and other game objects follow exactly the same drawing principles. The key difference is that you typically manage them in an array, giving each object its own property bundle, and iterate over that array every frame:

// Array of obstacle objects
let obstacles = [
  { x: 400, y: 340, width: 50, height: 60, color: '#f0a500' },
  { x: 600, y: 320, width: 70, height: 80, color: '#f0a500' },
  { x: 750, y: 350, width: 40, height: 50, color: '#f05a00' }
];

function drawObstacles() {
  obstacles.forEach(obs => {
    ctx.fillStyle = obs.color;
    ctx.fillRect(obs.x, obs.y, obs.width, obs.height);

    // Optional: add a dark border to distinguish obstacles from the background
    ctx.strokeStyle = '#333';
    ctx.lineWidth = 2;
    ctx.strokeRect(obs.x, obs.y, obs.width, obs.height);
  });
}

Iterating over an array is the correct pattern — not twenty separate hard-coded fillRect calls. It scales from two obstacles to two hundred without changing the drawing function at all. Each obstacle object's x, y, width, and height properties serve double duty: they drive the drawing commands and they are the same values used for collision detection later on.

The heartbeat of any canvas game is the clear-then-draw cycle. The canvas is not a scene graph — it does not remember what objects exist. It is just a grid of pixels. If you draw a rectangle at x=100 and then on the next frame draw it at x=105, both rectangles will be visible unless you erase the old frame first. The solution is to clear the entire canvas at the very start of every frame update:

ctx.clearRect(0, 0, canvas.width, canvas.height);

clearRect resets every pixel in the specified rectangle to fully transparent black, effectively wiping the slate clean. After clearing, you redraw everything from scratch in the correct order for that frame. Here is a complete game loop skeleton illustrating the pattern:

function gameLoop() {
  // 1. Clear the canvas
  ctx.clearRect(0, 0, canvas.width, canvas.height);

  // 2. Draw background (bottommost layer)
  ctx.fillStyle = '#1a1a2e';
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  // 3. Draw ground
  ctx.fillStyle = '#4a7c59';
  ctx.fillRect(0, canvas.height - 60, canvas.width, 60);

  // 4. Draw obstacles (behind player)
  drawObstacles();

  // 5. Draw player (on top of obstacles visually, or swap if needed)
  drawPlayer();

  // 6. Draw HUD / score (topmost layer)
  drawScore();

  // 7. Schedule the next frame (~60 fps)
  requestAnimationFrame(gameLoop);
}

// Kick off the loop
requestAnimationFrame(gameLoop);

requestAnimationFrame asks the browser to call your function before the next screen repaint, typically 60 times per second. This produces smooth animation and is far preferable to setInterval for games. The loop runs recursively: each call schedules the next one, creating a continuous cycle of clear → update state → redraw.

Understanding the canvas coordinate system is non-negotiable for placing objects correctly. The canvas grid works like this:

  • The origin (0, 0) is the top-left corner.
  • The x-axis increases going left to right.
  • The y-axis increases going top to bottom — the opposite of a standard mathematical graph.
  • A point at (canvas.width, canvas.height) is the bottom-right corner.

This coordinate system is visualised in the table below for an 800×400 canvas:

Canvas Location x value y value
Top-left corner 0 0
Top-right corner 800 0
Bottom-left corner 0 400
Bottom-right corner 800 400
Dead centre 400 200

When you position an element with ctx.fillRect(x, y, width, height), the x and y refer to its top-left corner. So a 40×60 player rectangle at (100, 300) occupies x from 100 to 140, and y from 300 to 360. Knowing this, you can compute the right edge as x + width and the bottom edge as y + height — exactly the values needed for boundary checks and collision detection.

To keep a character within the canvas, compare its position against canvas.width and canvas.height:

// Prevent the player from leaving the canvas
function clampPlayer() {
  // Left boundary
  if (player.x < 0) player.x = 0;

  // Right boundary (right edge of player must not exceed canvas width)
  if (player.x + player.width > canvas.width) {
    player.x = canvas.width - player.width;
  }

  // Top boundary
  if (player.y < 0) player.y = 0;

  // Bottom boundary
  if (player.y + player.height > canvas.height) {
    player.y = canvas.height - player.height;
  }
}

Putting all these concepts together, here is a minimal but complete working example — a coloured rectangle that moves right each frame, bounces off both walls, and is redrawn smoothly at 60 fps:

const canvas = document.getElementById('gameCanvas');
const ctx    = canvas.getContext('2d');

const player = { x: 50, y: 170, width: 40, height: 60, speed: 3, dir: 1 };

function update() {
  player.x += player.speed * player.dir;
  if (player.x + player.width >= canvas.width || player.x <= 0) {
    player.dir *= -1; // reverse direction at both walls
  }
}

function draw() {
  // Clear
  ctx.clearRect(0, 0, canvas.width, canvas.height);

  // Background
  ctx.fillStyle = '#1a1a2e';
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  // Ground
  ctx.fillStyle = '#4a7c59';
  ctx.fillRect(0, canvas.height - 40, canvas.width, 40);

  // Player
  ctx.fillStyle = '#e94560';
  ctx.fillRect(player.x, player.y, player.width, player.height);
}

function gameLoop() {
  update();
  draw();
  requestAnimationFrame(gameLoop);
}

requestAnimationFrame(gameLoop);

This small example encapsulates every principle covered: canvas setup, context retrieval, layered drawing (background → ground → player), coordinate-based positioning, boundary detection using canvas.width, and the clear-then-redraw animation loop. Every game you build on the canvas — no matter how complex — extends and elaborates on exactly these fundamentals.

NotesStudents should test their canvas setup in the browser immediately after writing it, using the browser's developer tools to confirm the canvas dimensions and that `ctx` is not null. Common early mistakes include forgetting `width`/`height` attributes (giving a 300×150 default canvas), calling drawing methods before the DOM is ready (wrap in `DOMContentLoaded` or place the script before `</body>`), and drawing images before they have loaded. Emphasise that `requestAnimationFrame` is always preferred over `setInterval` for game loops due to its synchronisation with the display refresh rate and automatic pausing when the browser tab is hidden.