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).

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
- Created a hidden canvas in memory, same size as the balloon.
- Drew the balloon image on that hidden canvas.
- Checked every tiny pixel: if it's transparent, ignore it. If it's solid, count it as a hit.
- 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.