G GAMESRICH
Kids & Toddlers · Intermediate

How to Build a Vector Coloring Game: Warna Haiwan

Build an interactive animal coloring studio with Canvas Path2D, region hit-testing, and PNG exports.

By GamesRich Engineering 7 min read Zero External Libraries

Play the Live Game First

Experience the mechanics firsthand to understand what you are building.

PLAY LIVE GAME ↗

Architecture & Core Concept

Traditional raster flood fills require pixel-by-pixel stack scans that can be slow on mobile. Warna Haiwan uses vector geometry: Each animal is decomposed into distinct named regions (head, body, wing, tail), each defined by a Path2D object. When the user clicks coordinate (x, y): We iterate through regions backwards and test: if (ctx.isPointInPath(region.path, x, y)) { regionFills[region.id] = activeColor; } Redrawing takes less than 1 millisecond and never creates fuzzy edges or pixel bleeding!

Key Game Mechanics Taught:

Step-by-Step Implementation

1

Define Animal Regions with Path2D

Construct modular vector paths using standard curves: bezierCurveTo, quadraticCurveTo, and ellipse.

Step 1 Implementation
function createCatPaths() {
  const head = new Path2D();
  head.ellipse(450, 205, 135, 120, 0, 0, Math.PI * 2);

  const leftEar = new Path2D();
  leftEar.moveTo(335, 155);
  leftEar.lineTo(340, 45);
  leftEar.lineTo(410, 102);
  leftEar.closePath();

  const body = new Path2D();
  body.ellipse(450, 355, 150, 145, 0, 0, Math.PI * 2);

  return [
    { id: 'body', path: body },
    { id: 'head', path: head },
    { id: 'leftEar', path: leftEar }
  ];
}
2

Detect Region Clicks with isPointInPath

When the canvas is tapped, convert client coordinates to canvas internal space, and use ctx.isPointInPath to identify the tapped shape instantly.

Step 2 Implementation
canvas.addEventListener('pointerdown', e => {
  const rect = canvas.getBoundingClientRect();
  const x = (e.clientX - rect.left) * (canvas.width / rect.width);
  const y = (e.clientY - rect.top) * (canvas.height / rect.height);

  // Iterate backwards so top overlapping regions catch clicks first
  for (let i = regions.length - 1; i >= 0; i--) {
    if (ctx.isPointInPath(regions[i].path, x, y)) {
      saveSnapshot(); // For undo
      fills[regions[i].id] = currentColor;
      redrawScene();
      playColorSound();
      return;
    }
  }
});
3

Render Shapes with Stroke Borders and Facial Details

Loop through all regions, fill them with their stored color, and outline them with a bold border to produce a clean coloring book look.

Step 3 Implementation
function redrawScene() {
  ctx.clearRect(0, 0, 900, 560);
  regions.forEach(r => {
    ctx.fillStyle = fills[r.id] || '#ffffff';
    ctx.fill(r.path);
    ctx.strokeStyle = '#30364d';
    ctx.lineWidth = 8;
    ctx.lineJoin = 'round';
    ctx.stroke(r.path);
  });
}

Complete Standalone Source Code

Here is the full, self-contained implementation. Save this as an index.html file on your computer and open it in any web browser to run your game immediately:

Complete Runnable Game (index.html)
<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>Coloring Studio Demo</title>
<style>
body{margin:0;background:#edf8e8;display:flex;flex-direction:column;align-items:center;padding:20px;font-family:sans-serif;}
canvas{background:#fff;border:3px solid #30364d;border-radius:12px;cursor:pointer;}
.palette{display:flex;gap:10px;margin-top:14px;}
.palette button{width:36px;height:36px;border-radius:50%;border:3px solid #fff;cursor:pointer;box-shadow:0 2px 6px rgba(0,0,0,0.2);}
</style></head><body>
<canvas id="c" width="600" height="400"></canvas>
<div class="palette">
  <button style="background:#ff5f78" onclick="setCol('#ff5f78')"></button>
  <button style="background:#ffd447" onclick="setCol('#ffd447')"></button>
  <button style="background:#55e0d6" onclick="setCol('#55e0d6')"></button>
  <button style="background:#70a6ff" onclick="setCol('#70a6ff')"></button>
  <button style="background:#b388ff" onclick="setCol('#b388ff')"></button>
</div>
<script>
const c = document.querySelector('#c'), ctx = c.getContext('2d');
let color = '#ff5f78', fills = {};
const head = new Path2D(), body = new Path2D();
head.arc(300, 160, 80, 0, 7);
body.arc(300, 290, 100, 0, 7);
const regions = [{id:'body', p:body}, {id:'head', p:head}];

function setCol(hex) { color = hex; }
function draw() {
  ctx.clearRect(0,0,600,400);
  regions.forEach(r => {
    ctx.fillStyle = fills[r.id] || '#ffffff';
    ctx.fill(r.p);
    ctx.strokeStyle = '#30364d'; ctx.lineWidth = 6; ctx.stroke(r.p);
  });
  // Eyes
  ctx.fillStyle = '#30364d';
  ctx.beginPath(); ctx.arc(270, 150, 8, 0, 7); ctx.arc(330, 150, 8, 0, 7); ctx.fill();
}
c.onpointerdown = e => {
  const r = c.getBoundingClientRect(), x = (e.clientX-r.left)*(600/r.width), y = (e.clientY-r.top)*(400/r.height);
  for(let i=regions.length-1; i>=0; i--) {
    if(ctx.isPointInPath(regions[i].p, x, y)) {
      fills[regions[i].id] = color; draw(); return;
    }
  }
};
draw();
</script>
</body></html>

Ideas to Expand Your Game

Now that you have the core mechanics running, consider adding: