Fixing balloon hits with offscreen canvas

I made this tiny game called Pop the Balloons.

You just have to pop the balloons. That's it. Nothing else.

While fixing a small issue in the game, I accidentally learned something new pixel-perfect hit testing.

These are the balloon images I used (in PNG formats).

Balloon formats

Each balloon had a thread at the bottom (of course every balloon does). But when I exported them as PNGs, they had some empty space around them. Since PNGs don't have a background color like JPGs, that transparent area still counts as part of the image.

So in the game, when I tapped that empty space, the balloon still popped. It wasn't wrong in code, but for users, it looked like a bug. Now I know clearly what is the issue was.


The question/prompt I asked Cursor:

Even if we touch anywhere on the balloon image it pops, because it has a thread and it's PNG, right? How can we fix it?

And Cursor replied:

You can fix it using pixel-perfect hit testing with an offscreen canvas

I had no idea what that meant, but I watched it write some magical code 👇

// Build an offscreen canvas once for pixel-perfect hit testing
const offscreen = document.createElement("canvas");
offscreen.width = width;
offscreen.height = height;
const offctx = offscreen.getContext("2d");

if (offctx) {
  offctx.imageSmoothingEnabled = true;
  offctx.imageSmoothingQuality = "high";
  offctx.clearRect(0, 0, width, height);
  offctx.drawImage(imageEl, 0, 0, width, height);
}

const isOpaqueAt = (x, y) => {
  if (!offctx) return true; // fallback: treat as hit
  if (x < 0 || y < 0 || x >= width || y >= height) return false;
  const { data } = offctx.getImageData(x, y, 1, 1);
  const alpha = data[3]; // 0..255
  return alpha > 32; // threshold to ignore semi-transparent thread/background
};

const onPointerDown = (ev) => {
  const rect = element.getBoundingClientRect();
  // Map pointer coordinates to canvas pixel coordinates (handles CSS scaling)
  const scaleX = width / rect.width;
  const scaleY = height / rect.height;
  const x = Math.floor((ev.clientX - rect.left) * scaleX);
  const y = Math.floor((ev.clientY - rect.top) * scaleY);
  if (isOpaqueAt(x, y)) {
    kill();
  }
};

element.addEventListener("pointerdown", onPointerDown);

What it actually did

  1. Created a hidden canvas in memory, same size as the balloon.
  2. Drew the balloon image on that hidden canvas.
  3. Checked every tiny pixel: if it's transparent, ignore it. If it's solid, count it as a hit.
  4. When I clicked, it only popped the balloon if that pixel wasn't see-through.

That's it. Now it works perfectly. Only the colorful part of the balloon pops, not the empty space around it.


What I learned

You don't have to know everything.
Just know how to ask, and be curious enough to try.