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:
- Declarative vector illustration using Canvas Path2D API
- Instant vector region hit-testing with ctx.isPointInPath(path, x, y)
- Dictionary-based region fill state mapping
- Action history stack for instant Undo functionality
- Client-side PNG image export using canvas.toDataURL()
Step-by-Step Implementation
Define Animal Regions with Path2D
Construct modular vector paths using standard curves: bezierCurveTo, quadraticCurveTo, and ellipse.
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 }
];
}
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.
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;
}
}
});
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.
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:
<!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:
- Local High Scores: Use
localStorage.setItem()andlocalStorage.getItem()to persist personal bests. - Visual Polish: Add screen shake on impacts by displacing
ctx.translate(shakeX, shakeY). - Mobile Touch Controls: Bind on-screen directional buttons using
onpointerdownandonpointerup.