Skip to main content

How to Make a Match-3 Game

Copied to clipboard!
No boilerplate memorization required

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 Philosophy

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, 4x combo multiplier on every drop.
  • Add 4-match row blasters and 5-match color bombs with a single follow-up prompt.
Execution

The 4-Step Blueprint

From a clean 8x8 board to cascading gem combos, power-ups, and level objectives.

Step 01

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.

๐Ÿ’ก Pro Vibe Tip: Draw each gem with both a unique color AND a unique shape (diamond, circle, star, hexagon, triangle, square) for instant visual clarity.
Step 02

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.

๐Ÿ”‘ Key Constraint: Tell the AI: "Pre-clear any initial matches when generating the starting board so the player starts on a stable grid."
Step 03

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.

โœจ Iteration Secret: Ask for floating score popups (+150 COMBO x3!) at the exact grid coordinates where gems pop.
Step 04

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.

๐Ÿ† The Grand Payoff: A sparkling, responsive Match-3 puzzle game that works with mouse clicks or touch taps in any browser.
Interactive Tool

Vibe Prompt Configurator

Select your puzzle theme, level objective, and power-up rules. Copy the generated master prompt into Claude, Cursor, or Gemini.

Special Feature Vibe
Optional custom details added straight into your master prompt.
Generated Master Prompt
Ready to paste into Claude, Gemini, or ChatGPT Single-file HTML5 Canvas ready
Puzzle Spec

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.

UX Checklist Before Shipping: Support both click-then-click and drag-to-swap inputs, highlight the selected tile clearly, and always ensure the initial board starts with zero pre-existing matches.
Starter Template

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.

index.html
HTML5 Canvas + Vanilla JS
<!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>
Troubleshooting

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

Extensions

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
Prompt Template

"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
Prompt Template

"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
Prompt Template

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

We use cookies for ads and analytics.