Test store. Prices are the planned list prices stored as test data, license terms are drafts, and payments go to a Stripe test-mode account. No real money moves.

Match-3 Game in HTML5: Gem-Matching Code and Tutorial

This match-3 game is one HTML file of about 380 lines: an 8×8 board with six gem kinds, tap or drag to swap, row and column match detection, gravity, refills, cascades and a goal of 1,500 points in 20 moves. Play it above, copy the code and open it in any browser. It's MIT-licensed, so you can change it and ship it.

Runs in your browser in a sandbox · 383 lines · no downloads

Match-3 looks simple from the outside: swap two neighbouring gems, line up three of a kind, watch them clear. Under the hood it's a small state machine that has to swap, check, clear, drop, refill and check again until the board settles. This HTML5 match-3 game tutorial walks through a complete gem matching game in plain JavaScript, so you can see each of those steps in code and change any of them.

How to run it

The game is a single file with no libraries, so there's nothing to install:

  1. Press Copy code above (or Download .html).
  2. Paste it into a new file called match-3.html in any text editor.
  3. Double-click the file to open it in your browser.

To play, tap a gem and then a neighbour, or drag a gem toward the neighbour you want to trade with. On a keyboard, the arrow keys move a yellow cursor, Space picks up the gem under it and an arrow key swaps it. You can also paste the code into the HTML game code runner, change a setting such as MOVES and press Run.

How the match-3 code works

The page holds one <canvas> and one script. The most useful idea in the file is that the game logic and the animation are kept apart: an array says where every gem belongs, and each gem carries its own drawn position that slides toward that cell a little every frame. The logic can swap, clear and drop gems instantly, and the animation catches up on its own.

1. Settings and gem kinds

Every number worth tuning sits at the top. TARGET and MOVES decide how hard a game is. We picked 1,500 points in 20 moves by letting a script play 3,000 games with this exact code: a player that picks random legal swaps reaches the goal about 38% of the time, and one that always takes the biggest available match gets there about 72% of the time.

match-3.html
const COLS = 8, ROWS = 8;            // board size in cells
const MOVES = 20;                    // swaps allowed per game
const TARGET = 1500;                 // score needed to win
const POINTS = 10;                   // points per cleared gem, times the cascade level
const GEMS = [
  { color: "#ff5f7e", corners: 0 },                                   // red circle
  { color: "#4aa8ff", corners: 4, size: 1.12 },                        // blue diamond
  { color: "#46d98a", corners: 4, size: 1.18, turn: Math.PI / 4 },     // green square

Each gem kind has a colour and a shape. Players who can't tell red from green can still read the board, and you can add a seventh kind by adding one line to GEMS. More kinds mean fewer matches and fewer cascades, so the game gets harder.

2. Sizing the board

resize() picks the largest square cell that fits the window, leaves 60 pixels on top for the score bar, and sizes the canvas buffer by devicePixelRatio so the shapes stay sharp on phones. On a 390-pixel-wide phone the cells come out at 47 pixels, a comfortable size for a finger.

js
const dpr = window.devicePixelRatio || 1;
cell = Math.max(20, Math.floor(Math.min((window.innerWidth - 8) / COLS, (window.innerHeight - HUD - 8) / ROWS)));
const w = cell * COLS, h = cell * ROWS + HUD;
canvas.style.width = w + "px";
canvas.style.height = h + "px";
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);

3. The board as a grid of gem objects

The board is grid[row][col], and each cell holds an object rather than a bare number. t is the gem kind, x and y are where the gem is drawn (in cells, so 3.5 means halfway between rows 3 and 4), and s is its scale, which shrinks to zero while a matched gem disappears.

js
// grid[row][col] holds a gem { t: kind, x, y: where it is drawn (in cells), s: scale, dying } or null.
// The array says where each gem belongs; x and y trail behind it, and that gap is the animation.
let grid = [];
let state = "ready";                 // ready → play → over
let phase = "idle";                  // while playing: idle → swap → clear → fall (→ clear → fall …) → idle

Two variables describe the game. state is the outer screen (start, playing, game over), and phase is where the current turn is. Input is only accepted when phase is idle, which is how the game avoids a player swapping gems that are still falling.

4. Dealing a board with no matches

A fresh board must not contain a line of three, or the player would score points they didn't earn. newBoard() fills the grid left to right, top to bottom, and rerolls a gem's kind whenever it would complete a line with the two gems to its left or the two above it. Only those two directions matter, because the cells to the right and below are still empty.

js
do {
  grid = [];
  for (let r = 0; r < ROWS; r++) {
    grid.push([]);
    for (let c = 0; c < COLS; c++) {
      let t;
      do t = randomKind();
      while ((c >= 2 && grid[r][c - 1].t === t && grid[r][c - 2].t === t) ||
             (r >= 2 && grid[r - 1][c].t === t && grid[r - 2][c].t === t));
      grid[r].push({ t, x: c, y: dropIn ? r - ROWS : r, s: 1 });
    }
  }
} while (!hasMove());

The outer do … while (!hasMove()) throws the board away in the rare case where no swap makes a match. With six kinds, at most two are ever ruled out for a cell, so the inner loop always finishes quickly.

5. Finding matches in rows and columns

findMatches() scans each row and counts runs of the same kind. When the run breaks, or the row ends, a run of three or more is added to the result. The loop goes one step past the last column (c <= COLS) so the final run gets checked without a second copy of the code. Columns use the same loop with rows and columns swapped.

js
for (let r = 0; r < ROWS; r++) {
  let run = 1;
  for (let c = 1; c <= COLS; c++) {
    if (c < COLS && sameKind(r, c, r, c - 1)) { run++; continue; }
    if (run >= 3) for (let k = c - run; k < c; k++) hit.add(r * COLS + k);
    run = 1;
  }
}

Matches go into a Set of cell numbers (row * COLS + col). A gem at the corner of an L or T shape belongs to a row match and a column match at the same time; the set makes sure it's cleared and scored once.

6. Swaps that only stick when they match

When the player swaps two gems, trySwap() swaps them in the grid straight away and sets phase = "swap". The gems then slide toward their new cells. Once nothing is moving, the update function looks for matches. If there are none, it swaps the two gems back and plays the return animation, and the move isn't counted.

js
const hits = findMatches();
if (phase === "swap") {
  if (hits.length) { moves--; chain = 0; startClear(hits); }
  else { swapCells(swapped[0], swapped[1]); phase = "unswap"; }   // no match: the swap doesn't stick
} else if (phase === "unswap") phase = "idle";

Checking the whole board after a swap is fine here: the board is always free of matches when the player moves, so any match found must involve the two swapped gems. On an 8×8 grid the full scan costs almost nothing.

7. Clearing, gravity and refill

startClear() marks the matched gems as dying and starts a short timer that shrinks them. When the timer runs out, collapse() handles gravity and refill in one pass per column. It walks up from the bottom with a write pointer that marks the lowest empty slot. Every surviving gem is moved down to that slot, and whatever is left at the top gets new random gems.

js
for (let c = 0; c < COLS; c++) {
  let write = ROWS - 1;            // the lowest cell still waiting for a gem
  for (let r = ROWS - 1; r >= 0; r--) {
    const g = grid[r][c];
    if (g && !g.dying) { grid[r][c] = null; grid[write][c] = g; write--; }
  }
  const missing = write + 1;       // rows 0 … write are empty now
  for (let r = 0; r < missing; r++) grid[r][c] = { t: randomKind(), x: c, y: r - missing, s: 1 };
}

Moved gems keep their old y, so they fall into place. New gems start above the board at negative y values and fall in as a group. The drawing code clips to the board area, so they appear from behind the score bar instead of drawing over it.

8. Cascades and scoring

After everything lands, the game looks for matches again. New gems often line up by themselves, and each extra round raises chain, which multiplies the points. A three-gem match is worth 30 points, the same three gems in the first cascade are worth 60, and so on. A "Cascade x2" message shows the multiplier.

js
function startClear(hits) {
  chain++;                           // 1 for the player's own match, 2+ for cascades
  score += hits.length * POINTS * chain;
  for (const i of hits) grid[Math.floor(i / COLS)][i % COLS].dying = true;
  if (chain > 1) { note = `Cascade x${chain}`; noteTimer = 1; }
  clearTimer = CLEAR_TIME;
  phase = "clear";
}

9. The loop: animate, then decide

The requestAnimationFrame loop calls update(dt), where dt is the time since the last frame in seconds, capped at 0.05. animate(dt) moves every gem toward its cell at a fixed speed in cells per second, so falls take the same time on a 60 Hz laptop and a 120 Hz phone. While anything is still moving, update returns early. Only when the board is still does it decide what comes next: check the swap, start a cascade or end the turn.

js
function animate(dt) {
  let busy = false;
  const step = (phase === "fall" ? FALL_SPEED : SWAP_SPEED) * dt;
  for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) {
    const g = grid[r][c];
    if (!g || (g.x === c && g.y === r)) continue;
    busy = true;
    g.x += Math.sign(c - g.x) * Math.min(step, Math.abs(c - g.x));
    g.y += Math.sign(r - g.y) * Math.min(step, Math.abs(r - g.y));
  }
  return busy;
}

The Math.min stops a gem from overshooting its cell, so it lands exactly on a whole number and the "is anything moving?" check can compare with ===.

10. Input: tap, drag and keys

Pointer events give mouse, pen and touch one code path. A press remembers the cell under the finger. If the pointer moves more than 40% of a cell before it's released, the gem swaps in that direction; otherwise the release counts as a tap, and two taps on neighbours swap them.

js
const dx = e.clientX - press.x, dy = e.clientY - press.y;
if (Math.max(Math.abs(dx), Math.abs(dy)) < cell * 0.4) return;
const a = press.at;
press = null;                      // one swap per drag
if (Math.abs(dx) > Math.abs(dy)) trySwap(a, { c: a.c + Math.sign(dx), r: a.r });
else trySwap(a, { c: a.c, r: a.r + Math.sign(dy) });

11. Drawing gems without images

There are no image files. drawGem() draws a circle, or a polygon with 3, 4, 5 or 6 corners, then a small white dot for shine. Stars alternate between the outer radius and an inner one. Shrinking a cleared gem is just a smaller r.

12. Winning, losing and stuck boards

endTurn() runs when the board has settled. The game is won as soon as the score reaches the target, and lost when the moves run out first. If neither has happened but no swap can make a match, the game deals a fresh board that drops in from above, using the same hasMove() check that newBoard() uses: swap two neighbours in the grid, look for a match, swap them back.

js
if (score >= TARGET || moves <= 0) {
  won = score >= TARGET;
  best = Math.max(best, score);
  state = "over";
  endedAt = performance.now();
} else if (!hasMove()) {
  newBoard(true);                  // stuck: deal a fresh board (it never starts with a match)

Make it work on phones

Phones work out of the box. The CSS sets touch-action: none, so dragging a gem doesn't scroll or zoom the page, and pointer events cover touch and mouse with the same handlers. Because the drag threshold is a fraction of the cell size, a swipe feels the same on a small phone and a large monitor.

At 390 pixels wide the board uses 47-pixel cells, which is above the 44 by 44 CSS pixel target size in WCAG's target size guidance. The square board is centred with flexbox, so it works in portrait and landscape; in portrait there's empty space above and below it. For a taller board on phones, try COLS = 7 and ROWS = 9: everything else, including match detection and refills, already reads the board size from those two settings.

For more on scaling, orientation and touch input, read our guide to HTML5 mobile game development.

Ideas to extend it

  • Special gems. A match of four creates a gem that clears its whole row; a match of five creates one that clears every gem of one kind. Keep the run length in findMatches() to know when to create them.
  • Hints. After five seconds without a move, highlight a valid swap. hasMove() already finds one; return it instead of true.
  • Levels. Replace TARGET and MOVES with an array of levels, each with its own goal, move count and number of gem kinds.
  • Blockers. Add ice or crates that only break when a match happens next to them.
  • Bonus for spare moves. Turn each unused move into points when the goal is reached.
  • Sound. Play a short sound per clear with the Web Audio API, pitched higher on each cascade. Start audio after the first tap, as browsers require.
  • Easing. Replace the constant-speed slide with an ease-out curve so gems land with a small bounce.

If you like grid puzzles, the 2048 code page uses a similar slide-and-merge board, and the memory game is a gentler way to practise matching logic.

From prototype to a game you can sell

This file shows every rule a match-3 game needs, and it's a good base for a class project, a jam entry or a quick test of an idea. A game that players return to, or that a portal will accept, wraps the same core in much more:

This match-3 fileA production match-3 game
ContentOne endless board and one goalDozens of levels with goals, blockers and a difficulty curve
RulesLines of 3 or more, cascadesSpecial gems, combos between specials, boosters
Art and soundShapes drawn with canvas paths, no audioOriginal art, particles, music and effects
SavesBest score lost on reloadLevel progress and settings saved per player
PlatformsDesktop and phone browsersPortal SDK hooks, ads, localisation, app wrappers
QualityTested by hand and in a sandboxUnit tests for the board logic, cross-browser end-to-end tests
LicenceMIT, do anythingA commercial licence that covers your use

That gap is what our games are for. Bakery Blast is our match-3: fast matching boards with specials and combos, levels and a bakery you decorate with what you earn. Like every game in our catalog, it ships as a buildable TypeScript project on Phaser 4.2.1, with tuning in JSON data files and a quick start, so you can reskin it and launch. Gem Forge and Hexa Garden Stack are close cousins if you'd rather merge than match:

You can also see more puzzle games with source code or go back to all the free HTML game code examples.

The complete code

One file, no libraries. Save it as match-3.html and open it in any browser. Free to use under the MIT license: copy it, change it, ship it.

match-3.html
<!doctype html>
<!-- SPDX-License-Identifier: MIT -->
<!-- Gem Swap: a complete match-3 game in one HTML file. Free to use, change and ship (MIT license). -->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Gem Swap: Match-3 (HTML5 canvas)</title>
<style>
  html, body { margin: 0; height: 100%; background: #140f24; color: #f3eeff; font: 16px system-ui, sans-serif; overflow: hidden; touch-action: none; user-select: none; }
  body { display: flex; align-items: center; justify-content: center; }
  canvas { display: block; }
</style>
</head>
<body>
<canvas id="game"></canvas>
<script>
// ----- Settings -----
const COLS = 8, ROWS = 8;            // board size in cells
const MOVES = 20;                    // swaps allowed per game
const TARGET = 1500;                 // score needed to win
const POINTS = 10;                   // points per cleared gem, times the cascade level
const SWAP_SPEED = 7;                // cells per second while two gems trade places
const FALL_SPEED = 11;               // cells per second while gems drop
const CLEAR_TIME = 0.25;             // seconds a matched gem takes to shrink away
const BG = "#140f24";
// Gem kinds: a colour AND a shape each, so the board stays readable for colour-blind players.
// corners 0 = circle; inner = star point depth; turn = rotation; size = scale; dy = nudge down.
const GEMS = [
  { color: "#ff5f7e", corners: 0 },                                   // red circle
  { color: "#4aa8ff", corners: 4, size: 1.12 },                        // blue diamond
  { color: "#46d98a", corners: 4, size: 1.18, turn: Math.PI / 4 },     // green square
  { color: "#ffd447", corners: 3, size: 1.2, dy: 0.15 },               // yellow triangle
  { color: "#b583ff", corners: 6, size: 1.05, turn: Math.PI / 6 },     // purple hexagon
  { color: "#ff9b42", corners: 5, size: 1.2, inner: 0.5 },             // orange star
];

const canvas = document.getElementById("game");
const ctx = canvas.getContext("2d");
const HUD = 60;                      // room above the board for score, goal and moves
let cell = 40;                       // pixel size of one cell, set by resize()

// ----- Sizing: the largest square cells that fit, sharp on high-DPI screens -----
function resize() {
  const dpr = window.devicePixelRatio || 1;
  cell = Math.max(20, Math.floor(Math.min((window.innerWidth - 8) / COLS, (window.innerHeight - HUD - 8) / ROWS)));
  const w = cell * COLS, h = cell * ROWS + HUD;
  canvas.style.width = w + "px";
  canvas.style.height = h + "px";
  canvas.width = Math.round(w * dpr);
  canvas.height = Math.round(h * dpr);
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  draw();
}

// ----- Game state -----
// grid[row][col] holds a gem { t: kind, x, y: where it is drawn (in cells), s: scale, dying } or null.
// The array says where each gem belongs; x and y trail behind it, and that gap is the animation.
let grid = [];
let state = "ready";                 // ready → play → over
let phase = "idle";                  // while playing: idle → swap → clear → fall (→ clear → fall …) → idle
let score = 0, best = 0, moves = MOVES, chain = 0, won = false, endedAt = 0;
let selected = null, swapped = null, clearTimer = 0;
let cursor = { c: 3, r: 4 }, usingKeys = false;
let note = "", noteTimer = 0;        // a short message such as "Cascade x2"

const inside = (c, r) => c >= 0 && r >= 0 && c < COLS && r < ROWS;
const randomKind = () => Math.floor(Math.random() * GEMS.length);
const sameKind = (r1, c1, r2, c2) => grid[r1][c1] && grid[r2][c2] && grid[r1][c1].t === grid[r2][c2].t;

// Fill the board with no line of 3 already in it, and keep trying until at least one swap works.
function newBoard(dropIn) {
  do {
    grid = [];
    for (let r = 0; r < ROWS; r++) {
      grid.push([]);
      for (let c = 0; c < COLS; c++) {
        let t;
        do t = randomKind();
        while ((c >= 2 && grid[r][c - 1].t === t && grid[r][c - 2].t === t) ||
               (r >= 2 && grid[r - 1][c].t === t && grid[r - 2][c].t === t));
        grid[r].push({ t, x: c, y: dropIn ? r - ROWS : r, s: 1 });
      }
    }
  } while (!hasMove());
}

// Every gem that is part of a line of 3 or more, as cell numbers (row * COLS + col).
// A Set means a gem shared by a row and a column match (an L or T shape) counts once.
function findMatches() {
  const hit = new Set();
  for (let r = 0; r < ROWS; r++) {
    let run = 1;
    for (let c = 1; c <= COLS; c++) {
      if (c < COLS && sameKind(r, c, r, c - 1)) { run++; continue; }
      if (run >= 3) for (let k = c - run; k < c; k++) hit.add(r * COLS + k);
      run = 1;
    }
  }
  for (let c = 0; c < COLS; c++) {
    let run = 1;
    for (let r = 1; r <= ROWS; r++) {
      if (r < ROWS && sameKind(r, c, r - 1, c)) { run++; continue; }
      if (run >= 3) for (let k = r - run; k < r; k++) hit.add(k * COLS + c);
      run = 1;
    }
  }
  return [...hit];
}

function swapCells(a, b) {
  const g = grid[a.r][a.c];
  grid[a.r][a.c] = grid[b.r][b.c];
  grid[b.r][b.c] = g;
}

// True when some swap of two neighbours makes a match. Try each one, look, and swap back.
function hasMove() {
  for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) {
    for (const [dc, dr] of [[1, 0], [0, 1]]) {
      const a = { c, r }, b = { c: c + dc, r: r + dr };
      if (!inside(b.c, b.r)) continue;
      swapCells(a, b);
      const works = findMatches().length > 0;
      swapCells(a, b);
      if (works) return true;
    }
  }
  return false;
}

// ----- Turns -----
function begin() {
  if (state === "over" && performance.now() - endedAt < 700) return;   // ignore a stray tap at the end
  score = 0; moves = MOVES; selected = null; noteTimer = 0;
  newBoard(true);                    // the new gems drop in from above
  state = "play";
  phase = "fall";
}

function trySwap(a, b) {
  if (state !== "play" || phase !== "idle" || !inside(b.c, b.r)) return;
  swapCells(a, b);                   // swap in the grid now; the drawing catches up
  swapped = [a, b];
  selected = null;
  phase = "swap";
}

// A tap selects a gem, a tap on a neighbour swaps the two, a second tap on the same gem lets go.
function tap(p) {
  cursor = p;
  if (selected && Math.abs(selected.c - p.c) + Math.abs(selected.r - p.r) === 1) trySwap(selected, p);
  else selected = selected && selected.c === p.c && selected.r === p.r ? null : p;
}

function startClear(hits) {
  chain++;                           // 1 for the player's own match, 2+ for cascades
  score += hits.length * POINTS * chain;
  for (const i of hits) grid[Math.floor(i / COLS)][i % COLS].dying = true;
  if (chain > 1) { note = `Cascade x${chain}`; noteTimer = 1; }
  clearTimer = CLEAR_TIME;
  phase = "clear";
}

// Gravity and refill: survivors slide down into the holes, new gems start above the board.
function collapse() {
  for (let c = 0; c < COLS; c++) {
    let write = ROWS - 1;            // the lowest cell still waiting for a gem
    for (let r = ROWS - 1; r >= 0; r--) {
      const g = grid[r][c];
      if (g && !g.dying) { grid[r][c] = null; grid[write][c] = g; write--; }
    }
    const missing = write + 1;       // rows 0 … write are empty now
    for (let r = 0; r < missing; r++) grid[r][c] = { t: randomKind(), x: c, y: r - missing, s: 1 };
  }
}

function endTurn() {
  phase = "idle";
  if (score >= TARGET || moves <= 0) {
    won = score >= TARGET;
    best = Math.max(best, score);
    state = "over";
    endedAt = performance.now();
  } else if (!hasMove()) {
    newBoard(true);                  // stuck: deal a fresh board (it never starts with a match)
    phase = "fall";
    note = "No moves: new board"; noteTimer = 1.5;
  }
}

// Slide every gem toward its cell. Returns true while anything is still moving.
function animate(dt) {
  let busy = false;
  const step = (phase === "fall" ? FALL_SPEED : SWAP_SPEED) * dt;
  for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) {
    const g = grid[r][c];
    if (!g || (g.x === c && g.y === r)) continue;
    busy = true;
    g.x += Math.sign(c - g.x) * Math.min(step, Math.abs(c - g.x));
    g.y += Math.sign(r - g.y) * Math.min(step, Math.abs(r - g.y));
  }
  return busy;
}

// ----- One frame of the board's state machine -----
function update(dt) {
  noteTimer -= dt;
  if (phase === "clear") {
    clearTimer -= dt;
    for (const row of grid) for (const g of row) if (g && g.dying) g.s = Math.max(0, clearTimer / CLEAR_TIME);
    if (clearTimer <= 0) { collapse(); phase = "fall"; }
    return;
  }
  if (phase === "idle" || animate(dt)) return;
  // Everything has stopped moving: decide what happens next.
  const hits = findMatches();
  if (phase === "swap") {
    if (hits.length) { moves--; chain = 0; startClear(hits); }
    else { swapCells(swapped[0], swapped[1]); phase = "unswap"; }   // no match: the swap doesn't stick
  } else if (phase === "unswap") phase = "idle";
  else if (phase === "fall") {
    if (hits.length) startClear(hits);   // the dropped gems lined up by themselves: a cascade
    else endTurn();
  }
}

// ----- Drawing -----
function label(text, x, y, size, weight, align, color = "#f3eeff") {
  ctx.font = `${weight} ${size}px system-ui, sans-serif`;
  ctx.textAlign = align;
  ctx.textBaseline = "middle";
  ctx.fillStyle = color;
  ctx.fillText(text, x, y);
}

function drawGem(t, x, y, r) {
  if (r < 0.5) return;
  const k = GEMS[t];
  ctx.fillStyle = k.color;
  ctx.beginPath();
  if (!k.corners) ctx.arc(x, y, r, 0, Math.PI * 2);
  else {
    const n = k.inner ? k.corners * 2 : k.corners, R = r * k.size;
    for (let i = 0; i < n; i++) {
      const a = (k.turn || 0) - Math.PI / 2 + (i / n) * Math.PI * 2;
      const d = k.inner && i % 2 ? R * k.inner : R;
      ctx.lineTo(x + Math.cos(a) * d, y + (k.dy || 0) * r + Math.sin(a) * d);
    }
    ctx.closePath();
  }
  ctx.fill();
  ctx.fillStyle = "rgba(255, 255, 255, 0.4)";   // a small shine
  ctx.beginPath();
  ctx.arc(x - r * 0.28, y - r * 0.3, r * 0.2, 0, Math.PI * 2);
  ctx.fill();
}

function outline(p, color, width) {
  ctx.strokeStyle = color;
  ctx.lineWidth = width;
  ctx.strokeRect(p.c * cell + 3, HUD + p.r * cell + 3, cell - 6, cell - 6);
}

function draw() {
  const w = cell * COLS, h = cell * ROWS;
  ctx.fillStyle = BG;
  ctx.fillRect(0, 0, w, HUD + h);

  // Score bar: score, goal, moves left and a progress bar toward the goal.
  const size = Math.max(14, Math.min(20, w / 22));
  label(`Score ${score}`, 6, HUD * 0.36, size, 700, "left");
  label(`Goal ${TARGET}`, w / 2, HUD * 0.36, size * 0.85, 500, "center", "#bfb3e0");
  label(`Moves ${moves}`, w - 6, HUD * 0.36, size, 700, "right");
  ctx.fillStyle = "#2c2349";
  ctx.fillRect(6, HUD * 0.7, w - 12, 8);
  ctx.fillStyle = "#ffd447";
  ctx.fillRect(6, HUD * 0.7, (w - 12) * Math.min(1, score / TARGET), 8);

  // The board, clipped so new gems stay hidden until they fall into view.
  ctx.save();
  ctx.beginPath();
  ctx.rect(0, HUD, w, h);
  ctx.clip();
  for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) {
    ctx.fillStyle = (r + c) % 2 ? "#1e1735" : "#261e43";
    ctx.fillRect(c * cell, HUD + r * cell, cell, cell);
  }
  for (const row of grid) for (const g of row) {
    if (g) drawGem(g.t, (g.x + 0.5) * cell, HUD + (g.y + 0.5) * cell, cell * 0.37 * g.s);
  }
  ctx.restore();
  if (selected) outline(selected, "#ffffff", 3);
  if (usingKeys && state === "play" && !selected) outline(cursor, "#ffd447", 2);

  if (noteTimer > 0 && state === "play") {
    ctx.globalAlpha = Math.min(1, noteTimer * 3);
    ctx.font = `800 ${Math.max(20, cell * 0.6)}px system-ui, sans-serif`;
    ctx.textAlign = "center";
    ctx.textBaseline = "middle";
    ctx.strokeStyle = BG;
    ctx.lineWidth = 6;
    ctx.strokeText(note, w / 2, HUD + h / 2);
    label(note, w / 2, HUD + h / 2, Math.max(20, cell * 0.6), 800, "center", "#ffffff");
    ctx.globalAlpha = 1;
  }

  if (state !== "play") {
    ctx.fillStyle = "rgba(12, 8, 22, 0.75)";
    ctx.fillRect(0, HUD, w, h);
    const cx = w / 2, cy = HUD + h / 2, big = Math.min(cell * 0.9, w / 9), small = Math.max(13, Math.min(18, w / 24));
    if (state === "ready") {
      label("Gem Swap", cx, cy - big * 1.3, big, 800, "center");
      label(`Score ${TARGET} points in ${MOVES} moves`, cx, cy, small, 600, "center");
      label("Swap neighbours to line up 3 or more", cx, cy + small * 1.6, small, 400, "center");
    } else {
      label(won ? "Goal reached!" : "Out of moves", cx, cy - big * 1.3, big, 800, "center");
      label(`Score ${score}   Best ${best}`, cx, cy, small, 600, "center");
      label(won ? `${moves} moves to spare` : `${TARGET - score} points short`, cx, cy + small * 1.6, small, 400, "center");
    }
    label("Tap or press Space to " + (state === "ready" ? "start" : "play again"), cx, cy + small * 4, small, 700, "center", "#ffd447");
  }
}

// ----- Main loop -----
let last = 0;
function loop(now) {
  const dt = Math.min(0.05, (now - last) / 1000);   // cap, so a background tab doesn't skip animations
  last = now;
  if (state === "play") update(dt);
  draw();
  requestAnimationFrame(loop);
}

// ----- Input: tap or drag with mouse and touch, arrows and Space on a keyboard -----
function cellAt(e) {
  const rect = canvas.getBoundingClientRect();
  const c = Math.floor((e.clientX - rect.left) / cell), r = Math.floor((e.clientY - rect.top - HUD) / cell);
  return inside(c, r) ? { c, r } : null;
}

let press = null;                    // the cell and point where a tap or drag started
canvas.addEventListener("pointerdown", (e) => {
  usingKeys = false;
  if (state !== "play") { begin(); return; }
  press = { at: cellAt(e), x: e.clientX, y: e.clientY };
});
canvas.addEventListener("pointermove", (e) => {
  if (!press || !press.at) return;
  const dx = e.clientX - press.x, dy = e.clientY - press.y;
  if (Math.max(Math.abs(dx), Math.abs(dy)) < cell * 0.4) return;
  const a = press.at;
  press = null;                      // one swap per drag
  if (Math.abs(dx) > Math.abs(dy)) trySwap(a, { c: a.c + Math.sign(dx), r: a.r });
  else trySwap(a, { c: a.c, r: a.r + Math.sign(dy) });
});
canvas.addEventListener("pointerup", () => {
  if (press && press.at && state === "play" && phase === "idle") tap(press.at);
  press = null;
});
canvas.addEventListener("pointercancel", () => { press = null; });

const DIRS = { ArrowUp: [0, -1], KeyW: [0, -1], ArrowDown: [0, 1], KeyS: [0, 1], ArrowLeft: [-1, 0], KeyA: [-1, 0], ArrowRight: [1, 0], KeyD: [1, 0] };
window.addEventListener("keydown", (e) => {
  const d = DIRS[e.code], act = e.code === "Space" || e.code === "Enter";
  if (!d && !act && e.code !== "Escape") return;
  e.preventDefault();
  usingKeys = true;
  if (state !== "play") { if (act) begin(); return; }
  if (e.code === "Escape") selected = null;
  else if (act) { if (phase === "idle") tap(cursor); }
  else if (selected) trySwap(selected, { c: selected.c + d[0], r: selected.r + d[1] });   // arrow = swap that way
  else cursor = { c: Math.min(COLS - 1, Math.max(0, cursor.c + d[0])), r: Math.min(ROWS - 1, Math.max(0, cursor.r + d[1])) };
});

window.addEventListener("resize", resize);
newBoard(false);
resize();
requestAnimationFrame((t) => { last = t; loop(t); });
</script>
</body>
</html>

Frequently asked questions

Is this match-3 code free to use?

Yes. The file is MIT-licensed: you can copy it, change it and use it in personal or commercial projects. Keep the licence line at the top of the file.

Can I sell a match-3 game I build from this file?

Yes. MIT allows commercial use, and the gem names, shapes and colours are all original to this file. You'll still want your own art, levels and polish before players or portals take it seriously. If you'd rather start from a finished game, our licences cover commercial use of our source code.

Do I need a game engine like Phaser for a match-3 game?

No. A match-3 board is a grid, a few loops and some tweened positions, which the canvas 2D API handles well. An engine such as Phaser starts to pay off when you add sprite sheets, particles, sound, scenes and many levels.

How do I add special gems like striped or bomb gems?

Record the length and direction of each run in findMatches(). When a run of four is cleared, leave one gem behind with a special flag instead of clearing it; when a special gem is part of a later match, add its whole row or column to the cleared set before scoring.

How do I save a high score?

Write best to localStorage when a game ends and read it back when the page loads. Wrap both calls in try/catch, because some browsers and sandboxed frames, including the player on this page, block storage.

We make and sell HTML5 game source code; links to our games are marked as ours.