So far we’ve been drawing everything from scratch using shapes, paths and arcs. That works fine for simple games. But Pac-Man has a detailed maze, animated ghosts and sprite-based characters. Drawing all that by hand every frame is going to get tedious fast.

This part is about working with images – how to load them, how to draw them, how to cut pieces out of a sprite sheet, and how to reach down into the actual pixel data when you need to.


drawImage – three ways to use it

The drawImage function is overloaded – it accepts different argument combinations:

// 1. just draw the image at position (x, y)
ctx.drawImage(image, x, y);

// 2. draw with explicit width and height (scales the image)
ctx.drawImage(image, x, y, width, height);

// 3. the full version -- crop a section from the image and draw it
ctx.drawImage(image, sx, sy, sw, sh, dx, dy, dw, dh);

The third version is the one that matters for sprite sheets. The s params are the source rectangle (which part of the image to take), and the d params are the destination rectangle (where and how big to draw it on the canvas).

Loading an image before drawing it:

const img = new Image();
img.src = "spritesheet.png";

img.onload = () => {
  // safe to draw now
  ctx.drawImage(img, 0, 0);
};

Always draw inside onload. If you call drawImage before the image has loaded, nothing appears – canvas doesn’t throw an error, it just silently does nothing. This is a frustrating bug when you first hit it.


Sprite sheets

A sprite sheet is a single image file that contains multiple frames or characters laid out in a grid. Instead of loading 20 separate images, you load one and cut out the piece you need each time.

Say you have a ghost sprite sheet with 4 animation frames, each frame being 32x32 pixels, laid out horizontally:

[ frame0 ][ frame1 ][ frame2 ][ frame3 ]
 0,0       32,0      64,0      96,0

To draw frame 2:

const FRAME_W = 32;
const FRAME_H = 32;
const frameIndex = 2;

ctx.drawImage(
  spriteSheet,
  frameIndex * FRAME_W,
  0, // source x, y
  FRAME_W,
  FRAME_H, // source width, height
  drawX,
  drawY, // destination x, y
  FRAME_W,
  FRAME_H, // destination width, height
);

For animation, you cycle frameIndex over time:

let animTimer = 0;
let frameIndex = 0;
const FRAME_COUNT = 4;
const FRAME_DURATION = 0.15; // seconds per frame

function updateAnimation(dt) {
  animTimer += dt;
  if (animTimer >= FRAME_DURATION) {
    animTimer = 0;
    frameIndex = (frameIndex + 1) % FRAME_COUNT;
  }
}

Simple, and it works for any sprite animation.


getImageData – reading raw pixels

This is where things get genuinely interesting. Canvas gives you access to the raw pixel data of anything drawn on it:

const imageData = ctx.getImageData(x, y, width, height);
const pixels = imageData.data; // Uint8ClampedArray

pixels is a flat array where every 4 consecutive values represent one pixel: [R, G, B, A, R, G, B, A, ...]. To get the pixel at position (px, py):

function getPixel(imageData, px, py) {
  const index = (py * imageData.width + px) * 4;
  return {
    r: imageData.data[index],
    g: imageData.data[index + 1],
    b: imageData.data[index + 2],
    a: imageData.data[index + 3],
  };
}

And to set a pixel:

function setPixel(imageData, px, py, r, g, b, a) {
  const index = (py * imageData.width + px) * 4;
  imageData.data[index] = r;
  imageData.data[index + 1] = g;
  imageData.data[index + 2] = b;
  imageData.data[index + 3] = a;
}

After modifying the data, you push it back to the canvas:

ctx.putImageData(imageData, x, y);

This is how you do real image processing on canvas. Invert colors, convert to grayscale, apply blur, do color replacement – all of it is just math on those four numbers per pixel.

// puzzle 04 => pixel math

Load any image onto the canvas with drawImage, then use getImageData to get the pixel data. Now write code to:

  1. Invert all colors: r = 255 - r, same for g and b
  2. Convert to grayscale: replace r, g, b all with 0.299*r + 0.587*g + 0.114*b (those weights match human visual perception)

These are the two most common pixel operations and they teach you the pattern for everything else. Try both before reading the Pac-Man section.


Tile maps – the foundation of 2D game worlds

Pac-Man’s maze isn’t drawn as a bunch of rectangles. It’s defined as a 2D grid where each cell has a value: 0 for empty, 1 for wall, 2 for dot, 3 for power pellet, etc. That grid is called a tile map.

const TILE = {
  EMPTY: 0,
  WALL: 1,
  DOT: 2,
  PELLET: 3,
  GHOST_H: 4, // ghost house
};

const map = [
  [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
  [1, 2, 2, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 2, 2, 1],
  [1, 3, 1, 1, 2, 1, 1, 1, 2, 1, 2, 1, 1, 1, 2, 1, 1, 3, 1],
  // ... and so on
];

To draw the tile map:

const TILE_SIZE = 28;

function drawMap() {
  map.forEach((row, r) => {
    row.forEach((cell, c) => {
      const x = c * TILE_SIZE;
      const y = r * TILE_SIZE;

      if (cell === TILE.WALL) {
        ctx.fillStyle = "#1a6fa8";
        ctx.fillRect(x, y, TILE_SIZE, TILE_SIZE);
      } else if (cell === TILE.DOT) {
        ctx.beginPath();
        ctx.arc(x + TILE_SIZE / 2, y + TILE_SIZE / 2, 3, 0, Math.PI * 2);
        ctx.fillStyle = "#e8d5a3";
        ctx.fill();
      } else if (cell === TILE.PELLET) {
        ctx.beginPath();
        ctx.arc(x + TILE_SIZE / 2, y + TILE_SIZE / 2, 8, 0, Math.PI * 2);
        ctx.fillStyle = "#e8d5a3";
        ctx.fill();
      }
    });
  });
}

For collision, instead of checking against every wall rectangle, you just look up the tile at the position you’re moving to:

function getTile(px, py) {
  const col = Math.floor(px / TILE_SIZE);
  const row = Math.floor(py / TILE_SIZE);
  if (row < 0 || row >= map.length || col < 0 || col >= map[0].length)
    return TILE.WALL;
  return map[row][col];
}

function isWall(px, py) {
  return getTile(px, py) === TILE.WALL;
}

This is much faster than any distance calculation, and it’s how nearly every 2D game does it.


Building full Pac-Man

// project 04 of 05 — full pac-man

This is the biggest project in the series. We're not cutting corners -- proper tile map, moving Pac-Man, four ghosts with different personalities, power pellet logic, lives, score and a proper game loop. It's a lot, but every concept comes from something we've already covered.

The complete tile map

This is a 19x21 cell maze (simplified from the original but recognizable):

const MAP_COLS = 19;
const MAP_ROWS = 21;
const T = (TILE_SIZE = 28);

const INITIAL_MAP = [
  [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
  [1, 2, 2, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 2, 2, 1],
  [1, 3, 1, 1, 2, 1, 1, 1, 2, 1, 2, 1, 1, 1, 2, 1, 1, 3, 1],
  [1, 2, 1, 1, 2, 1, 1, 1, 2, 1, 2, 1, 1, 1, 2, 1, 1, 2, 1],
  [1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1],
  [1, 2, 1, 1, 2, 1, 2, 1, 1, 1, 1, 1, 2, 1, 2, 1, 1, 2, 1],
  [1, 2, 2, 2, 2, 1, 2, 2, 2, 1, 2, 2, 2, 1, 2, 2, 2, 2, 1],
  [1, 1, 1, 1, 2, 1, 1, 1, 0, 1, 0, 1, 1, 1, 2, 1, 1, 1, 1],
  [1, 1, 1, 1, 2, 1, 0, 0, 0, 0, 0, 0, 0, 1, 2, 1, 1, 1, 1],
  [1, 1, 1, 1, 2, 1, 0, 4, 4, 4, 4, 4, 0, 1, 2, 1, 1, 1, 1],
  [0, 0, 0, 0, 2, 0, 0, 4, 4, 4, 4, 4, 0, 0, 2, 0, 0, 0, 0],
  [1, 1, 1, 1, 2, 1, 0, 4, 4, 4, 4, 4, 0, 1, 2, 1, 1, 1, 1],
  [1, 1, 1, 1, 2, 1, 0, 0, 0, 0, 0, 0, 0, 1, 2, 1, 1, 1, 1],
  [1, 1, 1, 1, 2, 1, 0, 1, 1, 1, 1, 1, 0, 1, 2, 1, 1, 1, 1],
  [1, 2, 2, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 2, 2, 1],
  [1, 2, 1, 1, 2, 1, 1, 1, 2, 1, 2, 1, 1, 1, 2, 1, 1, 2, 1],
  [1, 3, 2, 1, 2, 2, 2, 2, 2, 0, 2, 2, 2, 2, 2, 1, 2, 3, 1],
  [1, 1, 2, 1, 2, 1, 2, 1, 1, 1, 1, 1, 2, 1, 2, 1, 2, 1, 1],
  [1, 2, 2, 2, 2, 1, 2, 2, 2, 1, 2, 2, 2, 1, 2, 2, 2, 2, 1],
  [1, 2, 1, 1, 1, 1, 1, 1, 2, 1, 2, 1, 1, 1, 1, 1, 1, 2, 1],
  [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
];

We clone this at the start so we can reset without the original data being mutated:

let map = INITIAL_MAP.map((row) => [...row]);

Pac-Man movement

Pac-Man moves tile by tile but feels smooth because we animate between tiles:

const pacman = {
  // tile position
  tileX: 9,
  tileY: 15,
  // pixel position (interpolated)
  x: 9 * T + T / 2,
  y: 15 * T + T / 2,
  dir: { x: 0, y: 0 },
  nextDir: { x: 0, y: 0 },
  speed: 150, // px/s
  mouthAngle: 0,
  mouthOpen: true,
};

function updatePacman(dt) {
  // animate mouth
  pacman.mouthAngle += dt * 5;
  const mouth = Math.abs(Math.sin(pacman.mouthAngle)) * 0.35;

  // try to turn in requested direction
  const nx = pacman.tileX + pacman.nextDir.x;
  const ny = pacman.tileY + pacman.nextDir.y;
  if (getTile(nx * T + T / 2, ny * T + T / 2) !== TILE.WALL) {
    pacman.dir = { ...pacman.nextDir };
  }

  // move in current direction
  pacman.x += pacman.dir.x * pacman.speed * dt;
  pacman.y += pacman.dir.y * pacman.speed * dt;

  // snap to grid when crossing a tile center
  const targetX = Math.round(pacman.x / T) * T;
  const targetY = Math.round(pacman.y / T) * T;
  if (Math.abs(pacman.x - targetX) < 2 && Math.abs(pacman.y - targetY) < 2) {
    pacman.tileX = Math.floor(pacman.x / T);
    pacman.tileY = Math.floor(pacman.y / T);

    // check for wall ahead
    const aheadX = (pacman.tileX + pacman.dir.x) * T + T / 2;
    const aheadY = (pacman.tileY + pacman.dir.y) * T + T / 2;
    if (getTile(aheadX, aheadY) === TILE.WALL) {
      pacman.dir = { x: 0, y: 0 };
    }

    // eat dot
    const tile = map[pacman.tileY]?.[pacman.tileX];
    if (tile === TILE.DOT) {
      map[pacman.tileY][pacman.tileX] = TILE.EMPTY;
      score += 10;
    }
    if (tile === TILE.PELLET) {
      map[pacman.tileY][pacman.tileX] = TILE.EMPTY;
      score += 50;
      frightenGhosts();
    }
  }
}

Ghost AI

The real Pac-Man ghosts have distinct personalities based on their scatter/chase targets. We’ll implement simplified but functional versions:

  • Blinky (red): always chases Pac-Man directly
  • Pinky (pink): targets 4 tiles ahead of Pac-Man
  • Inky (cyan): semi-random (good for chaos)
  • Clyde (orange): chases when far, scatters when close
const ghosts = [
  {
    name: "blinky",
    tileX: 9,
    tileY: 9,
    x: 9 * T + T / 2,
    y: 9 * T + T / 2,
    color: "#fca5a5",
    dir: { x: 1, y: 0 },
    frightened: false,
    speed: 130,
  },
  {
    name: "pinky",
    tileX: 9,
    tileY: 10,
    x: 9 * T + T / 2,
    y: 10 * T + T / 2,
    color: "#f9a8d4",
    dir: { x: 0, y: -1 },
    frightened: false,
    speed: 130,
  },
  {
    name: "inky",
    tileX: 8,
    tileY: 10,
    x: 8 * T + T / 2,
    y: 10 * T + T / 2,
    color: "#5eead4",
    dir: { x: 0, y: 1 },
    frightened: false,
    speed: 130,
  },
  {
    name: "clyde",
    tileX: 10,
    tileY: 10,
    x: 10 * T + T / 2,
    y: 10 * T + T / 2,
    color: "#fdba74",
    dir: { x: 0, y: -1 },
    frightened: false,
    speed: 130,
  },
];

function getGhostTarget(ghost) {
  if (ghost.frightened) {
    // move semi-randomly when frightened
    return {
      x: Math.floor(Math.random() * MAP_COLS),
      y: Math.floor(Math.random() * MAP_ROWS),
    };
  }
  switch (ghost.name) {
    case "blinky":
      return { x: pacman.tileX, y: pacman.tileY };
    case "pinky":
      return {
        x: pacman.tileX + pacman.dir.x * 4,
        y: pacman.tileY + pacman.dir.y * 4,
      };
    case "inky":
      return {
        x: pacman.tileX + (Math.random() > 0.5 ? 2 : -2),
        y: pacman.tileY + (Math.random() > 0.5 ? 2 : -2),
      };
    case "clyde": {
      const dx = ghost.tileX - pacman.tileX;
      const dy = ghost.tileY - pacman.tileY;
      const dist = Math.sqrt(dx * dx + dy * dy);
      return dist > 8
        ? { x: pacman.tileX, y: pacman.tileY }
        : { x: 0, y: MAP_ROWS - 1 };
    }
  }
}

function updateGhost(ghost, dt) {
  // simple direction choosing at intersections
  const atCenter =
    Math.abs(ghost.x - (ghost.tileX * T + T / 2)) < 2 &&
    Math.abs(ghost.y - (ghost.tileY * T + T / 2)) < 2;

  if (atCenter) {
    const target = getGhostTarget(ghost);
    const dirs = [
      { x: 1, y: 0 },
      { x: -1, y: 0 },
      { x: 0, y: 1 },
      { x: 0, y: -1 },
    ];
    const validDirs = dirs.filter((d) => {
      // can't reverse
      if (d.x === -ghost.dir.x && d.y === -ghost.dir.y) return false;
      const nx = ghost.tileX + d.x;
      const ny = ghost.tileY + d.y;
      return getTile(nx * T + T / 2, ny * T + T / 2) !== TILE.WALL;
    });

    if (validDirs.length > 0) {
      // pick direction closest to target
      ghost.dir = validDirs.reduce((best, d) => {
        const nx = ghost.tileX + d.x;
        const ny = ghost.tileY + d.y;
        const dist = Math.hypot(nx - target.x, ny - target.y);
        const bestDist = Math.hypot(
          ghost.tileX + best.x - target.x,
          ghost.tileY + best.y - target.y,
        );
        return dist < bestDist ? d : best;
      });
    }

    ghost.tileX += ghost.dir.x;
    ghost.tileY += ghost.dir.y;
  }

  const speed = ghost.frightened ? ghost.speed * 0.5 : ghost.speed;
  ghost.x += ghost.dir.x * speed * dt;
  ghost.y += ghost.dir.y * speed * dt;
}

let frightenTimer = 0;
function frightenGhosts() {
  ghosts.forEach((g) => (g.frightened = true));
  frightenTimer = 8; // 8 seconds of fright
}

function updateFrighten(dt) {
  if (frightenTimer > 0) {
    frightenTimer -= dt;
    if (frightenTimer <= 0) ghosts.forEach((g) => (g.frightened = false));
  }
}

Ghost-Pac-Man collision

function checkGhostCollisions() {
  ghosts.forEach((ghost) => {
    const dx = ghost.x - pacman.x;
    const dy = ghost.y - pacman.y;
    if (Math.hypot(dx, dy) < T * 0.8) {
      if (ghost.frightened) {
        // eat the ghost
        ghost.frightened = false;
        ghost.tileX = 9;
        ghost.tileY = 9;
        ghost.x = 9 * T + T / 2;
        ghost.y = 9 * T + T / 2;
        score += 200;
      } else {
        // Pac-Man dies
        lives--;
        if (lives <= 0) gameState = STATE.GAME_OVER;
        else resetPositions();
      }
    }
  });
}

Drawing everything

function drawPacman() {
  const mouth = Math.abs(Math.sin(pacman.mouthAngle)) * 0.35;
  const facingAngle = Math.atan2(pacman.dir.y, pacman.dir.x);

  ctx.save();
  ctx.translate(pacman.x, pacman.y);
  ctx.rotate(facingAngle);

  ctx.beginPath();
  ctx.moveTo(0, 0);
  ctx.arc(0, 0, T / 2 - 3, mouth, Math.PI * 2 - mouth);
  ctx.closePath();
  ctx.fillStyle = "#fbbf24";
  ctx.fill();

  ctx.restore();
}

function drawGhost(ghost) {
  const x = ghost.x;
  const y = ghost.y;
  const r = T / 2 - 3;
  const color = ghost.frightened
    ? frightenTimer < 2 && Math.floor(Date.now() / 300) % 2
      ? "#fff"
      : "#1a3fa8"
    : ghost.color;

  ctx.beginPath();
  ctx.arc(x, y - r * 0.2, r, Math.PI, 0);
  ctx.lineTo(x + r, y + r * 1.1);
  ctx.quadraticCurveTo(x + r * 0.6, y + r * 0.7, x + r * 0.3, y + r * 1.1);
  ctx.quadraticCurveTo(x, y + r * 0.7, x - r * 0.3, y + r * 1.1);
  ctx.quadraticCurveTo(x - r * 0.6, y + r * 0.7, x - r, y + r * 1.1);
  ctx.lineTo(x - r, y - r * 0.2);
  ctx.closePath();
  ctx.fillStyle = color;
  ctx.fill();

  if (!ghost.frightened) {
    // eyes
    [
      [-r * 0.35, -r * 0.2],
      [r * 0.35, -r * 0.2],
    ].forEach(([ex, ey]) => {
      ctx.beginPath();
      ctx.arc(x + ex, y + ey, r * 0.28, 0, Math.PI * 2);
      ctx.fillStyle = "#fff";
      ctx.fill();
      ctx.beginPath();
      ctx.arc(
        x + ex + ghost.dir.x * 2,
        y + ey + ghost.dir.y * 2,
        r * 0.14,
        0,
        Math.PI * 2,
      );
      ctx.fillStyle = "#1a3fa8";
      ctx.fill();
    });
  }
}

The frightened ghost flashing when frightenTimer < 2 is a classic Pac-Man mechanic – warning the player that the ghosts are about to return to normal.

Win condition

function checkWin() {
  const dotsLeft = map
    .flat()
    .filter((t) => t === TILE.DOT || t === TILE.PELLET).length;
  if (dotsLeft === 0) {
    gameState = STATE.WIN;
  }
}

That’s the whole game. The full loop ties everything together:

function loop(ts) {
  const dt = Math.min((ts - lastTime) / 1000, 0.05);
  lastTime = ts;

  if (gameState === STATE.PLAYING) {
    updatePacman(dt);
    ghosts.forEach((g) => updateGhost(g, dt));
    updateFrighten(dt);
    checkGhostCollisions();
    checkWin();
  }

  ctx.fillStyle = "#000";
  ctx.fillRect(0, 0, W, H);
  drawMap();
  drawPacman();
  ghosts.forEach(drawGhost);
  drawHUD();

  requestAnimationFrame(loop);
}
// on performance: drawing the tile map by iterating every cell every frame is fine for a 19x21 grid. For a 500x500 grid it would be slow. The optimization is an offscreen canvas -- draw the static tiles once to a hidden canvas, then drawImage that whole thing to the main canvas each frame. One draw call vs thousands. We'll touch on this more in Part 5.

// checkpoint -- part 04
  • I can load and draw images with drawImage (all three argument forms)
  • I understand sprite sheets and how to cut frames out of them
  • I can read and modify pixel data with getImageData / putImageData
  • I understand tile maps and how to use them for collision
  • I built a full Pac-Man with ghost AI and win/lose logic

// up next — Part 05: Fake Depth, Real Math

The final part is the most mathematically interesting. We leave game mechanics behind and get into transformations, matrix math and the trick that makes 3D rendering possible on a 2D canvas. Then we use pixel manipulation to build an image-to-ASCII converter and a live webcam ASCII renderer. No library, no WebGL.