How to Make a Match-3 Game
How to Vibe Code a Juicy Match-3 Puzzle Game
Stay in your creative flow. Direct AI models to wire 8x8 grid scanning, gravity cascades, and chain-reaction combo multipliers, and ship a playable Match-3 puzzle game in your browser in under 20 minutes.
Core Stack
HTML5 Canvas + JS
Grid Engine
Cascades & Combos
AI Partner
Claude / Gemini
Target Time
15โ20 Mins
The Vibe Coding Match-3 Philosophy
Coding a Match-3 algorithm by hand often means wrestling with 2D matrix indices, invalid swap rollbacks, and recursive gravity refill loops. Vibe coding lets the AI handle the grid math while you focus on Puzzle Feel & Cascade Drama: distinct gem shapes, chain-reaction multipliers, row-clearing power-up bombs, and level target scores.
Old-School Grid Stress
- Off-by-one array bugs when scanning horizontal and vertical runs of 3, 4, or 5.
- Generating starting boards that accidentally explode before the player makes a move.
- Writing complex async state locks so players can't swap tiles while gems are still falling.
- Hard-to-distinguish gem colors that frustrate colorblind or mobile players.
The Vibe Coder Way
- Clean 8x8 HTML5 Canvas grid with distinct geometric gem silhouettes and colors.
- Prompt the rules: "Only allow adjacent swaps that form a valid match of 3+, otherwise animate a snap-back."
- Automatic cascade chains that increment a
2x, 3x, 4xcombo multiplier on every drop. - Add 4-match row blasters and 5-match color bombs with a single follow-up prompt.
The 4-Step Blueprint
From a clean 8x8 board to cascading gem combos, power-ups, and level objectives.
Start with a Clean 8x8 Board
Create a single index.html file. An 8x8 board with 6 distinct gem shapes is the sweet spot: easy for players to read and rich with chain opportunities.
The "One-Shot Architect" Prompt
Instruct your AI to build the complete swap → match scan → clear → gravity drop → cascade refill pipeline with a 25-move counter and target score.
Combos, Chains & Power-Ups
Reward chain reactions when falling gems trigger secondary matches, and spawn special bomb gems when the player matches 4 or 5 in a row.
+150 COMBO x3!) at the exact grid coordinates where gems pop.
Playtest & Level Progression
Persist unlocked levels and high scores in localStorage, add a "Shuffle Board" button if no moves remain, and share your puzzle game online.
Vibe Prompt Configurator
Select your puzzle theme, level objective, and power-up rules. Copy the generated master prompt into Claude, Cursor, or Gemini.
Essential Elements of a Match-3 Game
Every great Match-3 puzzle game combines simple swap rules with deeply satisfying chain reactions:
-
Adjacent Swap & Match Validation
Players swap two adjacent pieces horizontally or vertically to create a line of 3 or more identical gems; invalid swaps snap back cleanly.
-
Gravity Refill & Chain Reactions
When matched pieces vanish, gems above fall into place and new ones drop from the topโtriggering automatic Chains when they align.
-
High-Value Combo Multipliers
Making multiple matches in a single turn or cascade builds a Combo multiplier that rewards smart board setups with massive points.
-
Special Power-Ups & Boosters
Matching 4 or 5 pieces spawns special power-up gems (line blasters, area bombs, or color clears) that blast through tough board states.
-
Crystal-Clear Piece Silhouettes
Start with a manageable 8x8 board and give every gem a distinct color and geometric silhouette so players can scan matches at a glance.
-
Clear Level Goals & Move Limits
Give the player an unmistakable objectiveโsuch as reaching a target score within 20 moves or clearing special tilesโto create tension.
The "Golden" Single-File Match-3 Game Template
Save this as index.html and open it in any browser to play a complete 8x8 Match-3 Puzzle Game with cascades, combos, and 4-match bombs.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vibe Match-3: Crystal Cascade</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; user-select: none; }
body {
background: #090d18;
color: #f8fafc;
font-family: system-ui, -apple-system, sans-serif;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 12px;
}
.hud {
width: 520px;
max-width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
background: #131c31;
border: 1px solid #283655;
padding: 10px 16px;
border-radius: 12px;
margin-bottom: 10px;
font-size: 15px;
font-weight: 700;
}
.btn-shuffle {
background: #38bdf8;
color: #090d18;
border: none;
padding: 6px 12px;
border-radius: 8px;
font-weight: 800;
cursor: pointer;
}
canvas {
background: #111827;
border: 2px solid #334155;
border-radius: 16px;
box-shadow: 0 20px 50px rgba(0,0,0,0.65);
max-width: 100%;
height: auto;
cursor: pointer;
}
</style>
</head>
<body>
<div class="hud">
<span id="score-el">๐ Score: 0 / 1500</span>
<span id="moves-el">๐ฏ Moves: 20</span>
<button class="btn-shuffle" id="shuffle-btn">๐ New Board</button>
</div>
<canvas id="game" width="520" height="520"></canvas>
<script>
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const scoreEl = document.getElementById('score-el');
const movesEl = document.getElementById('moves-el');
const ROWS = 8;
const COLS = 8;
const CELL = 60;
const PAD = 20;
const GEMS = [
{ color: '#f43f5e', icon: 'โฆ' },
{ color: '#38bdf8', icon: 'โ' },
{ color: '#10b981', icon: 'โฒ' },
{ color: '#facc15', icon: 'โ
' },
{ color: '#a855f7', icon: 'โ ' },
{ color: '#fb923c', icon: 'โฌข' }
];
let grid = [];
let selected = null;
let busy = false;
let score = 0;
let moves = 20;
let target = 1500;
let popups = [];
let particles = [];
function randomType() {
return Math.floor(Math.random() * GEMS.length);
}
function initBoard() {
score = 0;
moves = 20;
selected = null;
busy = false;
grid = [];
for (let r = 0; r < ROWS; r++) {
grid[r] = [];
for (let c = 0; c < COLS; c++) {
let t;
do {
t = randomType();
} while (
(c >= 2 && grid[r][c - 1].type === t && grid[r][c - 2].type === t) ||
(r >= 2 && grid[r - 1][c].type === t && grid[r - 2][c].type === t)
);
grid[r][c] = { type: t, yOffset: 0, bomb: false };
}
}
updateHud();
}
function updateHud() {
scoreEl.textContent = `๐ Score: ${score} / ${target}`;
movesEl.textContent = `๐ฏ Moves: ${moves}`;
}
function findMatches() {
const matched = Array.from({ length: ROWS }, () => Array(COLS).fill(false));
const bombsToCreate = [];
// Horizontal scan
for (let r = 0; r < ROWS; r++) {
let run = 1;
for (let c = 1; c <= COLS; c++) {
if (c < COLS && grid[r][c] && grid[r][c - 1] && grid[r][c].type === grid[r][c - 1].type) {
run++;
} else {
if (run >= 3) {
for (let k = c - run; k < c; k++) matched[r][k] = true;
if (run >= 4) bombsToCreate.push({ r, c: c - 2, type: grid[r][c - 1].type });
}
run = 1;
}
}
}
// Vertical scan
for (let c = 0; c < COLS; c++) {
let run = 1;
for (let r = 1; r <= ROWS; r++) {
if (r < ROWS && grid[r][c] && grid[r - 1][c] && grid[r][c].type === grid[r - 1][c].type) {
run++;
} else {
if (run >= 3) {
for (let k = r - run; k < r; k++) matched[k][c] = true;
if (run >= 4) bombsToCreate.push({ r: r - 2, c, type: grid[r - 1][c].type });
}
run = 1;
}
}
}
// Expand row/col if a matched tile is a bomb
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
if (matched[r][c] && grid[r][c] && grid[r][c].bomb) {
for (let k = 0; k < COLS; k++) matched[r][k] = true;
for (let k = 0; k < ROWS; k++) matched[k][c] = true;
}
}
}
let count = 0;
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
if (matched[r][c]) count++;
}
}
return { matched, count, bombsToCreate };
}
function resolveCascades(chain) {
const { matched, count, bombsToCreate } = findMatches();
if (count === 0) {
busy = false;
return;
}
const pts = count * 25 * chain;
score += pts;
updateHud();
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
if (matched[r][c] && grid[r][c]) {
const gx = PAD + c * CELL + CELL / 2;
const gy = PAD + r * CELL + CELL / 2;
const col = GEMS[grid[r][c].type].color;
for (let i = 0; i < 6; i++) {
particles.push({
x: gx, y: gy,
vx: (Math.random() - 0.5) * 8,
vy: (Math.random() - 0.5) * 8,
color: col, life: 22
});
}
grid[r][c] = null;
}
}
}
bombsToCreate.forEach(b => {
grid[b.r][b.c] = { type: b.type, yOffset: 0, bomb: true };
});
popups.push({
x: canvas.width / 2,
y: canvas.height / 2,
text: chain > 1 ? `CHAIN x${chain}! +${pts}` : `+${pts}`,
alpha: 1
});
// Gravity drop
for (let c = 0; c < COLS; c++) {
let empty = 0;
for (let r = ROWS - 1; r >= 0; r--) {
if (grid[r][c] === null) {
empty++;
} else if (empty > 0) {
grid[r + empty][c] = grid[r][c];
grid[r + empty][c].yOffset = -empty * CELL;
grid[r][c] = null;
}
}
for (let r = 0; r < empty; r++) {
grid[r][c] = { type: randomType(), yOffset: -(empty - r) * CELL, bomb: false };
}
}
setTimeout(() => resolveCascades(chain + 1), 340);
}
function trySwap(r1, c1, r2, c2) {
if (busy || moves <= 0) return;
busy = true;
const temp = grid[r1][c1];
grid[r1][c1] = grid[r2][c2];
grid[r2][c2] = temp;
const { count } = findMatches();
if (count > 0) {
moves--;
updateHud();
resolveCascades(1);
} else {
// Invalid swap, revert
setTimeout(() => {
const revert = grid[r1][c1];
grid[r1][c1] = grid[r2][c2];
grid[r2][c2] = revert;
busy = false;
}, 180);
}
}
canvas.addEventListener('pointerdown', e => {
if (busy) return;
if (moves <= 0 || score >= target) {
if (score >= target) target += 800;
initBoard();
return;
}
const rect = canvas.getBoundingClientRect();
const mx = (e.clientX - rect.left) * (canvas.width / rect.width);
const my = (e.clientY - rect.top) * (canvas.height / rect.height);
const c = Math.floor((mx - PAD) / CELL);
const r = Math.floor((my - PAD) / CELL);
if (r < 0 || r >= ROWS || c < 0 || c >= COLS) return;
if (!selected) {
selected = { r, c };
} else {
const dist = Math.abs(selected.r - r) + Math.abs(selected.c - c);
if (dist === 1) {
const sr = selected.r;
const sc = selected.c;
selected = null;
trySwap(sr, sc, r, c);
} else {
selected = { r, c };
}
}
});
document.getElementById('shuffle-btn').addEventListener('click', initBoard);
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const x = PAD + c * CELL;
const y = PAD + r * CELL;
// Grid tile background
ctx.fillStyle = (r + c) % 2 === 0 ? '#1e293b' : '#172033';
ctx.beginPath();
ctx.roundRect(x + 2, y + 2, CELL - 4, CELL - 4, 10);
ctx.fill();
if (selected && selected.r === r && selected.c === c) {
ctx.strokeStyle = '#facc15';
ctx.lineWidth = 3;
ctx.strokeRect(x + 3, y + 3, CELL - 6, CELL - 6);
}
const cell = grid[r][c];
if (!cell) continue;
if (cell.yOffset < 0) cell.yOffset = Math.min(0, cell.yOffset + 8);
const gem = GEMS[cell.type];
const gx = x + CELL / 2;
const gy = y + CELL / 2 + cell.yOffset;
ctx.fillStyle = gem.color;
ctx.beginPath();
ctx.arc(gx, gy, 21, 0, Math.PI * 2);
ctx.fill();
if (cell.bomb) {
ctx.strokeStyle = '#fff';
ctx.lineWidth = 3;
ctx.stroke();
}
ctx.fillStyle = '#090d18';
ctx.font = 'bold 20px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(cell.bomb ? '๐ฃ' : gem.icon, gx, gy + 1);
}
}
particles.forEach(p => {
ctx.fillStyle = p.color;
ctx.fillRect(p.x, p.y, 5, 5);
p.x += p.vx;
p.y += p.vy;
p.life--;
});
particles = particles.filter(p => p.life > 0);
popups.forEach(p => {
ctx.fillStyle = `rgba(250, 204, 21, ${p.alpha})`;
ctx.font = 'bold 26px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(p.text, p.x, p.y);
p.y -= 1.2;
p.alpha -= 0.025;
});
popups = popups.filter(p => p.alpha > 0);
if (!busy && (moves <= 0 || score >= target)) {
ctx.fillStyle = 'rgba(9, 13, 24, 0.85)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = score >= target ? '#10b981' : '#f43f5e';
ctx.font = 'bold 34px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(score >= target ? 'STAGE CLEARED! ๐' : 'OUT OF MOVES!', canvas.width / 2, 235);
ctx.fillStyle = '#f8fafc';
ctx.font = '18px sans-serif';
ctx.fillText(`Final Score: ${score} โ Click to ${score >= target ? 'Play Next Stage' : 'Retry'}`, canvas.width / 2, 280);
}
requestAnimationFrame(draw);
}
initBoard();
draw();
</script>
</body>
</html>
How to Debug Without Losing Your Vibe
Match-3 grids have three classic edge cases. Use these vibe debugging prompts to keep your board rock-solid:
Lock Input During Cascades
Stop players from swapping mid-animation and corrupting the 2D array by prompting: "Set a boolean busy = true when a swap begins and only set busy = false once resolveCascades() finds zero new matches."
Simultaneous L/T Match Scans
Make sure L-shaped and T-shaped 5-gem matches score both directions by telling the AI: "Mark matched coordinates in a separate boolean matrix during horizontal and vertical scans before deleting any gems."
Deadlock Detection & Shuffle
Ensure players never get stuck with zero valid moves left by instructing the AI: "Provide a Shuffle button or automatically reshuffle the grid when no adjacent swap produces a 3-match."
The Vibe Prompt Vault
Copy-paste prompts to add Color Bombs, Ice Blockers, and an Idle Move Hint Finder.
๐ Prompt: Add 5-Match Rainbow Color Bombs & L-Shape Area Bombs
"Upgrade my HTML5 Canvas Match-3 game in index.html with two advanced power-ups: 1) Matching 5 gems in a line creates a Rainbow Prism gemโswapping it with any adjacent gem blasts every gem of that color off the board with lightning lines. 2) Matching in an L or T shape creates a 3x3 Area Bomb."
๐ง Prompt: Add Frosted Ice Blocker Tiles & Stage Objectives
"Extend my Match-3 game so each stage spawns 12 frosted 'Ice Overlay' cells on the board. Whenever a match occurs on an iced cell, shatter the ice overlay with blue crystal particles. Change the win condition to clearing all 12 ice cells before Moves run out."
๐ก Prompt: Add an Automatic Valid-Move Hint Pulse After 5 Seconds
"Write a findValidMove() helper for my 8x8 Match-3 game that checks every possible adjacent horizontal and vertical swap without mutating the live board. If the player hasn't clicked for 5 seconds, pulse a golden glow around one valid pair of swappable gems."