Skip to main content
Back to module
Game Prototyping & Systems · The Game Loop in Code

Drawing with canvas

Reading35mLesson 1 of 3 in this module

Draw a sprite on an HTML canvas, move it by changing its state, and keep it inside the play area.

Why this matters

A prototype answers a design question: is this jump fun, is this enemy readable, does this loop hold attention for five minutes? The fastest way to answer it is often a single HTML file that runs in any browser. The <canvas> element gives you a blank bitmap you can draw on with JavaScript, with no engine, install or build step. Many jam games and pitch prototypes start exactly like this, and the habits you build here (state in plain objects, drawing as a function of that state) carry straight into Godot, Unity or any engine.

The canvas and its context

A canvas is an element with a fixed drawing size, set with the width and height attributes (not CSS, which only stretches the bitmap):

<canvas id="game" width="320" height="200"></canvas>

To draw, ask the canvas for a 2D rendering context. Every drawing call goes through it:

const canvas = document.getElementById("game");
const ctx = canvas.getContext("2d");

ctx.fillStyle = "#1e293b";              // background colour
ctx.fillRect(0, 0, canvas.width, canvas.height);

ctx.fillStyle = "#f59e0b";              // the "sprite"
ctx.fillRect(40, 80, 24, 24);           // x, y, width, height

The coordinate system starts at the top-left corner. x grows to the right and y grows downwards, which surprises people used to maths graphs. A rectangle at (40, 80) has its top-left corner 40 pixels from the left edge and 80 from the top.

Useful calls to know:

callwhat it does
fillRect(x, y, w, h)solid rectangle
clearRect(x, y, w, h)erase to transparent
beginPath(), arc(x, y, r, 0, Math.PI * 2), fill()a circle
drawImage(img, x, y)draw a loaded image (a real sprite)
fillText("Score: 3", x, y)text, styled with ctx.font

State first, pixels second

The canvas has no memory of "objects". Once you draw a rectangle it is just coloured pixels. To move something you change its data and draw everything again. Keep the game's state in a plain object:

const player = { x: 40, y: 80, w: 24, h: 24, color: "#f59e0b" };

function drawFrame() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);  // wipe the last frame
  ctx.fillStyle = player.color;
  ctx.fillRect(player.x, player.y, player.w, player.h);
}

function movePlayer(dx, dy) {
  player.x += dx;
  player.y += dy;
}

movePlayer(10, 0);
drawFrame();   // the square appears 10px further right

This split is the most important idea in the module. movePlayer changes state; drawFrame turns state into pixels. Your game logic never reads pixels back, so you can test it without looking at the screen, and you can swap a coloured square for real art later without touching the rules.

Keeping things on screen

Players find edges fast. Without a guard, the square slides off and never comes back. Clamping keeps a value inside a range:

function clamp(value, min, max) {
  return Math.max(min, Math.min(max, value));
}

function movePlayer(dx, dy) {
  player.x = clamp(player.x + dx, 0, canvas.width - player.w);
  player.y = clamp(player.y + dy, 0, canvas.height - player.h);
}

Note the upper bound: canvas.width - player.w, because x is the left edge. If you clamp to canvas.width, the sprite can sit entirely off the right side.

Exposing state for debugging

During prototyping, put your state where you can poke it from DevTools:

window.game = { player, movePlayer, drawFrame };

Now you can type game.movePlayer(50, 0); game.drawFrame() in the console, or set game.player.color = "red" to test readability. The Do activity uses this same object to check your work.

Sharp pixels and sizing

If you scale the canvas with CSS (say width: 100%), the browser stretches the bitmap and pixel art goes blurry. For a crisp retro look keep the bitmap small and add image-rendering: pixelated; to the canvas's CSS. For non-pixel art, set the width/height attributes to the size you actually display.

Common mistakes

  • Forgetting clearRect. Each frame draws on top of the last, leaving a smear trail.
  • Setting size with CSS only. The default bitmap is 300×150, stretched to your CSS size.
  • Thinking y goes up. Moving "up" means dy is negative.
  • Drawing before setting fillStyle. The colour applies to calls made after you set it.
  • Storing positions in the DOM or reading pixels to find objects. Keep a state object; draw from it.

Quick recap

  • <canvas width height> plus getContext("2d") gives you a drawing surface.
  • The origin is top-left; y grows downwards.
  • Keep the game in a state object; drawFrame clears and redraws from it.
  • Clamp positions with the sprite's size in mind.
  • Exposing window.game makes prototypes easy to debug and test.

Try it

Open the Do tab. You will finish movePlayer (with clamping) and drawFrame for a small canvas prototype.

Also in this lesson: