
Tile-matching
You move, swap or pick tiles on a grid to bring matching pieces together, which then vanish and let others drop in. The challenge is reading the board and setting up chain reactions.
Also known as: match-three, match-3, tile-matching game
Defining mechanics
- A grid board full of tiles of a few kinds, told apart by colour, shape or both.
- One action to bring matching tiles together, which depending on the game is swapping two neighbours, picking a group, pairing two free tiles or dropping pieces.
- Matches disappear, the tiles above fall and new ones come in, which can form further matches in a cascade.
- A limit that creates tension, such as a number of moves, a clock or a board that keeps filling up.
- Matching more than three usually creates special tiles that clear large areas of the board.
Gameplay loop
Scan the board, find a match (or the one that sets up a cascade), make it, watch everything fall and start looking again.
Our demo
Controls. Click or tap a piece and then a neighbour (above, below or to the side), or drag a piece onto its neighbour. Space or Enter starts the game.
Open full screenAn 8 × 8 board of coloured pieces. You swap two neighbours to make lines of three or more alike, which vanish; the ones above fall, new pieces come in and sometimes cascades form that multiply your points. You have 20 moves to reach the target.
Made with Kaplay, in about 196 lines of JavaScript.
How it's made
Setup
The board size, the number of moves and the points target are set here; they are the numbers that most change the difficulty. Each kind of piece has a colour and also a different shape (circle, square, triangle, diamond, hexagon and pentagon), so the board can be read without relying on colour alone.
kaplay({ width: 640, height: 360, letterbox: true, pixelDensity, background: [30, 24, 48], // The board is played with the mouse or by touch; the keyboard only starts the game. buttons: { start: { keyboard: ["space", "enter"] } }, }); const SIZE = 8; // the board is SIZE × SIZE cells const CELL = 40; const BOARD_X = 290; // top-left corner of the board, in pixels const BOARD_Y = 20; const MOVES = 20; const TARGET = 1600; // points needed before running out of moves const SLIDE_SPEED = 700; // pixels per second for swaps and falls // Each kind of gem has a colour AND a shape, so the board can be read without relying on colour. const polygon = (sides, r, turn) => Array.from({ length: sides }, (_, i) => Vec2.fromAngle(turn - 90 + (i * 360) / sides).scale(r)); const GEMS = [ { color: rgb(235, 80, 90), pts: null }, // circle { color: rgb(250, 200, 60), pts: polygon(4, 17, 45) }, // square { color: rgb(90, 200, 110), pts: polygon(3, 18, 0) }, // triangle { color: rgb(80, 150, 240), pts: polygon(4, 17, 0) }, // diamond { color: rgb(180, 110, 230), pts: polygon(6, 16, 0) }, // hexagon { color: rgb(245, 145, 60), pts: polygon(5, 16, 0) }, // pentagon ];The board as a matrix
The board is an 8 × 8 matrix in which each cell holds a piece: its kind and how far it still is on screen from its place (dx, dy). When it is created, any piece that would make three in a row with the two to its left or the two above is drawn again, so the game does not start with matches already made.
// The board is a matrix of gems: grid[y][x] = { kind, dx, dy }. dx/dy is how far the // gem still is from its cell on screen; it shrinks to 0 to animate swaps and falls. const newGem = (dy = 0) => ({ kind: randi(GEMS.length), dx: 0, dy }); // A fresh board with no lines already made: re-roll any gem that would complete // three in a row with the two to its left or the two above it. function newBoard() { const grid = []; for (let y = 0; y < SIZE; y++) { grid.push([]); for (let x = 0; x < SIZE; x++) { const left = x >= 2 && grid[y][x - 1].kind === grid[y][x - 2].kind ? grid[y][x - 1].kind : -1; const above = y >= 2 && grid[y - 1][x].kind === grid[y - 2][x].kind ? grid[y - 1][x].kind : -1; let gem; do gem = newGem(); while (gem.kind === left || gem.kind === above); grid[y].push(gem); } } return grid; } const swap = (grid, a, b) => ([grid[a.y][a.x], grid[b.y][b.x]] = [grid[b.y][b.x], grid[a.y][a.x]]);Detecting matches
findMatches() goes through every cell and checks whether it and the next two, to the right or downwards, are the same kind. If they are, it stores all three in a set. The triples overlap, so a line of four or five is marked in full, and the set avoids counting the same cell twice when a horizontal and a vertical line cross. hasMove() tries every possible swap and undoes it, to know whether any move is left.
// Look at every cell: if it and the next two (to the right, or below) are the same // kind, those three are part of a line. Overlapping triples also cover lines of 4 or 5. function findMatches(grid) { const found = new Set(); const kindAt = (x, y) => grid[y]?.[x]?.kind; for (let y = 0; y < SIZE; y++) { for (let x = 0; x < SIZE; x++) { const kind = kindAt(x, y); if (kind === undefined) continue; if (kindAt(x + 1, y) === kind && kindAt(x + 2, y) === kind) [0, 1, 2].forEach((i) => found.add(`${x + i},${y}`)); if (kindAt(x, y + 1) === kind && kindAt(x, y + 2) === kind) [0, 1, 2].forEach((i) => found.add(`${x},${y + i}`)); } } return found; } // Is there any swap left that would make a line? Try them all and undo each one. function hasMove(grid) { const works = (a, b) => { swap(grid, a, b); const ok = findMatches(grid).size > 0; swap(grid, a, b); return ok; }; for (let y = 0; y < SIZE; y++) for (let x = 0; x < SIZE; x++) if ((x + 1 < SIZE && works({ x, y }, { x: x + 1, y })) || (y + 1 < SIZE && works({ x, y }, { x, y: y + 1 }))) return true; return false; }Board gravity
After a line is cleared, the matrix has holes. applyGravity() goes through each column from bottom to top and moves each piece down to the lowest free cell; then it fills the top cells with new pieces. In the data, the piece changes cell at once; on screen, it gets a negative offset (dy) that shrinks to zero every frame, so you see it fall. New pieces start above the board.
// Column by column, from the bottom up: each gem drops into the lowest free cell, // then new gems fill the top, starting above the board so they visibly fall in. function applyGravity(grid) { for (let x = 0; x < SIZE; x++) { let free = SIZE - 1; // lowest empty row in this column for (let y = SIZE - 1; y >= 0; y--) { const gem = grid[y][x]; if (!gem) continue; grid[y][x] = null; grid[free][x] = gem; gem.dy -= (free - y) * CELL; // it is drawn where it was and slides down free--; } for (let y = free; y >= 0; y--) grid[y][x] = newGem(-(free + 1) * CELL); } }The swap
Swapping means exchanging two cells of the matrix and animating the slide. The genre's rule is that only a swap that makes at least one line counts; if it does not, it is undone and costs no move. While the animation runs, the board takes no new moves.
const adjacent = (a, b) => Math.abs(a.x - b.x) + Math.abs(a.y - b.y) === 1; // After swapping in the matrix, each gem starts drawn at the other's place and slides. function slideSwap(a, b) { swap(grid, a, b); Object.assign(grid[a.y][a.x], { dx: (b.x - a.x) * CELL, dy: (b.y - a.y) * CELL }); Object.assign(grid[b.y][b.x], { dx: (a.x - b.x) * CELL, dy: (a.y - b.y) * CELL }); } async function trySwap(a, b) { [busy, selected] = [true, null]; slideSwap(a, b); await settle(); if (findMatches(grid).size === 0) { // Only swaps that make a line are allowed: undo it, and it costs no move. slideSwap(a, b); await settle(); say(t({ es: "Ese cambio no forma una línea", en: "That swap makes no line" })); } else { moves--; await resolve(); if (score >= TARGET || moves === 0) return go("end", score >= TARGET, score); } busy = false; }Cascades and multiplier
resolve() repeats a cycle: find lines, clear them, let pieces fall and refill, then look again. Every round after the first is a cascade the player did not make directly, and it scores multiplied by the round number. That is where the genre's depth lies: a move near the bottom of the board shifts more pieces and is more likely to set off a chain.
// Clear → fall → refill → look again. Every extra round is a cascade the player did // not make directly, and it scores with a growing multiplier. async function resolve() { let chain = 1; for (let found = findMatches(grid); found.size > 0; found = findMatches(grid), chain++) { clearing = found; // highlighted for a moment before vanishing await sleep(0.25); const points = found.size * 10 * chain; score += points; if (chain > 1) say(t({ es: "Cascada", en: "Cascade" }) + ` ×${chain} +${points}`); // Empty those cells: the matrix now has holes for gravity to fill. for (const [x, y] of [...found].map((key) => key.split(",").map(Number))) grid[y][x] = null; clearing = new Set(); applyGravity(grid); await settle(); } // No swap left anywhere? Deal a new board so the game never gets stuck. if (!hasMove(grid)) { grid = newBoard(); say(t({ es: "Sin jugadas: tablero nuevo", en: "No moves left: new board" })); } }Click, tap and drag
cellAt() turns the pointer position into a cell. On release, if you started on a piece and end on its neighbour, it is a drag and they swap. If you released on the same one, it is a click that selects the piece, and the next click on a neighbour makes the swap. Kaplay treats a touch as a click, so it works the same on mobile.
// Click or tap a gem and then a neighbour, or drag from one gem onto its neighbour. const cellAt = (p) => { const [x, y] = [Math.floor((p.x - BOARD_X) / CELL), Math.floor((p.y - BOARD_Y) / CELL)]; return x >= 0 && x < SIZE && y >= 0 && y < SIZE ? { x, y } : null; }; const same = (a, b) => a && b && a.x === b.x && a.y === b.y; function pick(cell) { if (selected && adjacent(selected, cell)) trySwap(selected, cell); else selected = same(selected, cell) ? null : cell; } let pressed = null; onMousePress(() => (pressed = cellAt(mousePos()))); onMouseRelease(() => { const cell = cellAt(mousePos()); if (!busy && cell && pressed) { if (adjacent(pressed, cell)) trySwap(pressed, cell); // drag else if (same(pressed, cell)) pick(cell); // click } pressed = null; });Animation and drawing
Every frame, onUpdate moves the offsets of all pieces towards zero at a constant speed, and onDraw paints each one at its cell plus its offset. The pieces about to vanish are highlighted for a moment. The scoreboard shows the points, the target, the moves left and a progress bar.
const toward0 = (v, step) => Math.sign(v) * Math.max(0, Math.abs(v) - step); onUpdate(() => { const step = SLIDE_SPEED * dt(); grid.forEach((row) => row.forEach((g) => g && Object.assign(g, { dx: toward0(g.dx, step), dy: toward0(g.dy, step) }))); messageTime -= dt(); }); const outline = (cell, c) => drawRect({ pos: vec2(BOARD_X + cell.x * CELL + 2, BOARD_Y + cell.y * CELL + 2), width: CELL - 4, height: CELL - 4, fill: false, outline: { width: 3, color: c } }); onDraw(() => { drawRect({ pos: vec2(BOARD_X, BOARD_Y), width: SIZE * CELL, height: SIZE * CELL, color: rgb(18, 14, 32), radius: 6 }); grid.forEach((row, y) => row.forEach((g, x) => { const p = vec2(BOARD_X + x * CELL + CELL / 2 + g?.dx, BOARD_Y + y * CELL + CELL / 2 + g?.dy); if (!g || p.y < BOARD_Y) return; // gems still above the board are not drawn yet if (clearing.has(`${x},${y}`)) drawCircle({ pos: p, radius: 19, color: rgb(255, 255, 255), opacity: 0.7 }); const { color, pts } = GEMS[g.kind]; if (pts) drawPolygon({ pos: p, pts, color }); else drawCircle({ pos: p, radius: 15, color }); }), ); if (selected) outline(selected, rgb(255, 255, 255)); const hud = t({ es: `Puntos: ${score} / ${TARGET}\nMovimientos: ${moves}`, en: `Score: ${score} / ${TARGET}\nMoves: ${moves}` }); drawText({ text: hud, pos: vec2(24, 28), size: 18, font: FONT, lineSpacing: 10 }); drawRect({ pos: vec2(24, 100), width: 240, height: 10, color: rgb(60, 50, 90), radius: 5 }); drawRect({ pos: vec2(24, 100), width: 240 * Math.min(1, score / TARGET), height: 10, color: rgb(250, 200, 60), radius: 5 }); if (messageTime > 0) drawText({ text: message, pos: vec2(24, 130), size: 16, font: FONT, width: 250, color: rgb(255, 220, 140) }); });Scenes
The game has three scenes, title, game and end, and go() jumps between them. The game ends when you reach the target or use up your last move.
function screen(title, subtitle, next) { add([text(title, { size: 34, font: FONT, align: "center" }), pos(center().sub(0, 50)), anchor("center")]); add([text(subtitle, { size: 16, font: FONT, align: "center", width: 560 }), pos(center().add(0, 30)), anchor("center"), color(210, 200, 255)]); onButtonPress("start", next); onClick(next); } scene("title", () => screen( t({ es: "Combina tres", en: "Match three" }), t({ es: `Intercambia dos figuras vecinas para formar una línea de tres o más iguales.\nConsigue ${TARGET} puntos en ${MOVES} movimientos; las cascadas multiplican.\nToca una figura y luego su vecina, o arrástrala.\n\nPulsa espacio, Enter o toca para empezar.`, en: `Swap two neighbouring pieces to make a line of three or more alike.\nScore ${TARGET} points in ${MOVES} moves; cascades multiply.\nTap a piece and then its neighbour, or drag it.\n\nPress space, Enter or tap to start.`, }), () => go("game"), ), ); scene("end", (won, score) => screen( won ? t({ es: "¡Meta alcanzada!", en: "Target reached!" }) : t({ es: "Se acabaron los movimientos", en: "Out of moves" }), t({ es: `Puntos: ${score} de ${TARGET}.\nPulsa espacio o toca para jugar otra vez.`, en: `Score: ${score} of ${TARGET}.\nPress space or tap to play again.` }), () => go("game"), ), ); go("title");
What we simplified
- There are no special pieces; in the genre, a line of four or five usually creates a piece that explodes or clears a whole row.
- There is a single goal (points) and a single level; commercial games string together hundreds of levels with different objectives and obstacles on the board.
- If no move is left, a whole new board is dealt instead of shuffling the existing pieces.
- The pieces are drawn with simple shapes, with no sound or particle effects.
To modify it
- Make a line of four create a special piece that clears its whole row when matched.
- Add a hint button that highlights a valid move. You can reuse the logic of hasMove().
- Change the number of piece kinds (from 6 to 5 or 7) and note how the number of cascades and the difficulty change.
- Design a level with a different goal, for example clearing 15 pieces of one kind in 15 moves.
Full code
// Tile-matching (match-three) demo
// Engine: Kaplay 3001 (https://kaplayjs.com). MIT licence.
import kaplay from "../../vendor/kaplay.mjs";
import { t, FONT, pixelDensity } from "../common.js";
// #region setup
kaplay({
width: 640, height: 360, letterbox: true, pixelDensity, background: [30, 24, 48],
// The board is played with the mouse or by touch; the keyboard only starts the game.
buttons: { start: { keyboard: ["space", "enter"] } },
});
const SIZE = 8; // the board is SIZE × SIZE cells
const CELL = 40;
const BOARD_X = 290; // top-left corner of the board, in pixels
const BOARD_Y = 20;
const MOVES = 20;
const TARGET = 1600; // points needed before running out of moves
const SLIDE_SPEED = 700; // pixels per second for swaps and falls
// Each kind of gem has a colour AND a shape, so the board can be read without relying on colour.
const polygon = (sides, r, turn) =>
Array.from({ length: sides }, (_, i) => Vec2.fromAngle(turn - 90 + (i * 360) / sides).scale(r));
const GEMS = [
{ color: rgb(235, 80, 90), pts: null }, // circle
{ color: rgb(250, 200, 60), pts: polygon(4, 17, 45) }, // square
{ color: rgb(90, 200, 110), pts: polygon(3, 18, 0) }, // triangle
{ color: rgb(80, 150, 240), pts: polygon(4, 17, 0) }, // diamond
{ color: rgb(180, 110, 230), pts: polygon(6, 16, 0) }, // hexagon
{ color: rgb(245, 145, 60), pts: polygon(5, 16, 0) }, // pentagon
];
// #endregion
// #region board
// The board is a matrix of gems: grid[y][x] = { kind, dx, dy }. dx/dy is how far the
// gem still is from its cell on screen; it shrinks to 0 to animate swaps and falls.
const newGem = (dy = 0) => ({ kind: randi(GEMS.length), dx: 0, dy });
// A fresh board with no lines already made: re-roll any gem that would complete
// three in a row with the two to its left or the two above it.
function newBoard() {
const grid = [];
for (let y = 0; y < SIZE; y++) {
grid.push([]);
for (let x = 0; x < SIZE; x++) {
const left = x >= 2 && grid[y][x - 1].kind === grid[y][x - 2].kind ? grid[y][x - 1].kind : -1;
const above = y >= 2 && grid[y - 1][x].kind === grid[y - 2][x].kind ? grid[y - 1][x].kind : -1;
let gem;
do gem = newGem();
while (gem.kind === left || gem.kind === above);
grid[y].push(gem);
}
}
return grid;
}
const swap = (grid, a, b) => ([grid[a.y][a.x], grid[b.y][b.x]] = [grid[b.y][b.x], grid[a.y][a.x]]);
// #endregion
// #region matches
// Look at every cell: if it and the next two (to the right, or below) are the same
// kind, those three are part of a line. Overlapping triples also cover lines of 4 or 5.
function findMatches(grid) {
const found = new Set();
const kindAt = (x, y) => grid[y]?.[x]?.kind;
for (let y = 0; y < SIZE; y++) {
for (let x = 0; x < SIZE; x++) {
const kind = kindAt(x, y);
if (kind === undefined) continue;
if (kindAt(x + 1, y) === kind && kindAt(x + 2, y) === kind) [0, 1, 2].forEach((i) => found.add(`${x + i},${y}`));
if (kindAt(x, y + 1) === kind && kindAt(x, y + 2) === kind) [0, 1, 2].forEach((i) => found.add(`${x},${y + i}`));
}
}
return found;
}
// Is there any swap left that would make a line? Try them all and undo each one.
function hasMove(grid) {
const works = (a, b) => {
swap(grid, a, b);
const ok = findMatches(grid).size > 0;
swap(grid, a, b);
return ok;
};
for (let y = 0; y < SIZE; y++)
for (let x = 0; x < SIZE; x++)
if ((x + 1 < SIZE && works({ x, y }, { x: x + 1, y })) || (y + 1 < SIZE && works({ x, y }, { x, y: y + 1 }))) return true;
return false;
}
// #endregion
// #region gravity
// Column by column, from the bottom up: each gem drops into the lowest free cell,
// then new gems fill the top, starting above the board so they visibly fall in.
function applyGravity(grid) {
for (let x = 0; x < SIZE; x++) {
let free = SIZE - 1; // lowest empty row in this column
for (let y = SIZE - 1; y >= 0; y--) {
const gem = grid[y][x];
if (!gem) continue;
grid[y][x] = null;
grid[free][x] = gem;
gem.dy -= (free - y) * CELL; // it is drawn where it was and slides down
free--;
}
for (let y = free; y >= 0; y--) grid[y][x] = newGem(-(free + 1) * CELL);
}
}
// #endregion
scene("game", () => {
let grid = newBoard();
let [score, moves, busy, selected, clearing, message, messageTime] = [0, MOVES, false, null, new Set(), "", 0];
const say = (text) => ([message, messageTime] = [text, 1.5]);
const sleep = (s) => new Promise((done) => wait(s, done));
const moving = () => grid.some((row) => row.some((g) => g && (g.dx || g.dy)));
const settle = async () => { while (moving()) await sleep(0.02); };
// #region swap
const adjacent = (a, b) => Math.abs(a.x - b.x) + Math.abs(a.y - b.y) === 1;
// After swapping in the matrix, each gem starts drawn at the other's place and slides.
function slideSwap(a, b) {
swap(grid, a, b);
Object.assign(grid[a.y][a.x], { dx: (b.x - a.x) * CELL, dy: (b.y - a.y) * CELL });
Object.assign(grid[b.y][b.x], { dx: (a.x - b.x) * CELL, dy: (a.y - b.y) * CELL });
}
async function trySwap(a, b) {
[busy, selected] = [true, null];
slideSwap(a, b);
await settle();
if (findMatches(grid).size === 0) {
// Only swaps that make a line are allowed: undo it, and it costs no move.
slideSwap(a, b);
await settle();
say(t({ es: "Ese cambio no forma una línea", en: "That swap makes no line" }));
} else {
moves--;
await resolve();
if (score >= TARGET || moves === 0) return go("end", score >= TARGET, score);
}
busy = false;
}
// #endregion
// #region cascades
// Clear → fall → refill → look again. Every extra round is a cascade the player did
// not make directly, and it scores with a growing multiplier.
async function resolve() {
let chain = 1;
for (let found = findMatches(grid); found.size > 0; found = findMatches(grid), chain++) {
clearing = found; // highlighted for a moment before vanishing
await sleep(0.25);
const points = found.size * 10 * chain;
score += points;
if (chain > 1) say(t({ es: "Cascada", en: "Cascade" }) + ` ×${chain} +${points}`);
// Empty those cells: the matrix now has holes for gravity to fill.
for (const [x, y] of [...found].map((key) => key.split(",").map(Number))) grid[y][x] = null;
clearing = new Set();
applyGravity(grid);
await settle();
}
// No swap left anywhere? Deal a new board so the game never gets stuck.
if (!hasMove(grid)) {
grid = newBoard();
say(t({ es: "Sin jugadas: tablero nuevo", en: "No moves left: new board" }));
}
}
// #endregion
// #region input
// Click or tap a gem and then a neighbour, or drag from one gem onto its neighbour.
const cellAt = (p) => {
const [x, y] = [Math.floor((p.x - BOARD_X) / CELL), Math.floor((p.y - BOARD_Y) / CELL)];
return x >= 0 && x < SIZE && y >= 0 && y < SIZE ? { x, y } : null;
};
const same = (a, b) => a && b && a.x === b.x && a.y === b.y;
function pick(cell) {
if (selected && adjacent(selected, cell)) trySwap(selected, cell);
else selected = same(selected, cell) ? null : cell;
}
let pressed = null;
onMousePress(() => (pressed = cellAt(mousePos())));
onMouseRelease(() => {
const cell = cellAt(mousePos());
if (!busy && cell && pressed) {
if (adjacent(pressed, cell)) trySwap(pressed, cell); // drag
else if (same(pressed, cell)) pick(cell); // click
}
pressed = null;
});
// #endregion
// #region draw
const toward0 = (v, step) => Math.sign(v) * Math.max(0, Math.abs(v) - step);
onUpdate(() => {
const step = SLIDE_SPEED * dt();
grid.forEach((row) => row.forEach((g) => g && Object.assign(g, { dx: toward0(g.dx, step), dy: toward0(g.dy, step) })));
messageTime -= dt();
});
const outline = (cell, c) =>
drawRect({ pos: vec2(BOARD_X + cell.x * CELL + 2, BOARD_Y + cell.y * CELL + 2), width: CELL - 4, height: CELL - 4, fill: false, outline: { width: 3, color: c } });
onDraw(() => {
drawRect({ pos: vec2(BOARD_X, BOARD_Y), width: SIZE * CELL, height: SIZE * CELL, color: rgb(18, 14, 32), radius: 6 });
grid.forEach((row, y) =>
row.forEach((g, x) => {
const p = vec2(BOARD_X + x * CELL + CELL / 2 + g?.dx, BOARD_Y + y * CELL + CELL / 2 + g?.dy);
if (!g || p.y < BOARD_Y) return; // gems still above the board are not drawn yet
if (clearing.has(`${x},${y}`)) drawCircle({ pos: p, radius: 19, color: rgb(255, 255, 255), opacity: 0.7 });
const { color, pts } = GEMS[g.kind];
if (pts) drawPolygon({ pos: p, pts, color });
else drawCircle({ pos: p, radius: 15, color });
}),
);
if (selected) outline(selected, rgb(255, 255, 255));
const hud = t({ es: `Puntos: ${score} / ${TARGET}\nMovimientos: ${moves}`, en: `Score: ${score} / ${TARGET}\nMoves: ${moves}` });
drawText({ text: hud, pos: vec2(24, 28), size: 18, font: FONT, lineSpacing: 10 });
drawRect({ pos: vec2(24, 100), width: 240, height: 10, color: rgb(60, 50, 90), radius: 5 });
drawRect({ pos: vec2(24, 100), width: 240 * Math.min(1, score / TARGET), height: 10, color: rgb(250, 200, 60), radius: 5 });
if (messageTime > 0) drawText({ text: message, pos: vec2(24, 130), size: 16, font: FONT, width: 250, color: rgb(255, 220, 140) });
});
// #endregion
});
// #region scenes
function screen(title, subtitle, next) {
add([text(title, { size: 34, font: FONT, align: "center" }), pos(center().sub(0, 50)), anchor("center")]);
add([text(subtitle, { size: 16, font: FONT, align: "center", width: 560 }), pos(center().add(0, 30)), anchor("center"), color(210, 200, 255)]);
onButtonPress("start", next);
onClick(next);
}
scene("title", () =>
screen(
t({ es: "Combina tres", en: "Match three" }),
t({
es: `Intercambia dos figuras vecinas para formar una línea de tres o más iguales.\nConsigue ${TARGET} puntos en ${MOVES} movimientos; las cascadas multiplican.\nToca una figura y luego su vecina, o arrástrala.\n\nPulsa espacio, Enter o toca para empezar.`,
en: `Swap two neighbouring pieces to make a line of three or more alike.\nScore ${TARGET} points in ${MOVES} moves; cascades multiply.\nTap a piece and then its neighbour, or drag it.\n\nPress space, Enter or tap to start.`,
}),
() => go("game"),
),
);
scene("end", (won, score) =>
screen(
won ? t({ es: "¡Meta alcanzada!", en: "Target reached!" }) : t({ es: "Se acabaron los movimientos", en: "Out of moves" }),
t({ es: `Puntos: ${score} de ${TARGET}.\nPulsa espacio o toca para jugar otra vez.`, en: `Score: ${score} of ${TARGET}.\nPress space or tap to play again.` }),
() => go("game"),
),
);
go("title");
// #endregionDesign challenges
- Generating the board and the refills at random without matches already made, checking whether any move is left and reshuffling if not.
- Chaining the swap, vanish, fall, refill and cascade animations without player input breaking the state, keeping the logic separate from the drawing.
- Making the board easy to read, with different shapes as well as colours for players who cannot tell colours apart, and a hint if the player goes a while without moving.
- Measuring luck, because random refills decide many cascades and a level's difficulty is only known by playing it many times, often with a program that simulates it.
- Designing many levels with different goals and obstacles without them feeling repetitive.
History
- 1985OriginSameGame
Chain Shot! (1985), by Kuniaki Moribe, later known as SameGame: you remove groups of same-coloured tiles and the rest fall into the gap.
SameGame (Wikipedia) - 1986Shanghai
Activision's Shanghai brings mahjong solitaire pair-matching to the computer.
Shanghai (video game) (Wikipedia) - 1989Columns
Columns, a falling-block game, starts a line of tile-matching games based on shifting or swapping tiles.
Tile-matching video game (Wikipedia) - 1994Shariki
Eugene Alemzhin's Shariki is the first of what would become known as match-three games, in which you make clusters of three or more identical tiles on a grid.
Tile-matching video game (Wikipedia) - 1995Tetris Attack
Panel de Pon (Tetris Attack outside Japan) brings tile swapping to a stack that keeps rising from below, with a two-player mode.
Tetris Attack (Wikipedia) - 2001Bejeweled
Bejeweled is released, after starting out in 2000 as a browser game. It is credited with popularising match-three and launching the casual games industry.
Bejeweled (video game) (Wikipedia) - 2007Puzzle Quest: Challenge of the Warlords
Puzzle Quest merges match-three with role-playing, so battles are fought in turns on a board of gems.
Puzzle Quest: Challenge of the Warlords (Wikipedia) - 2012Candy Crush Saga
King's Candy Crush Saga comes out on Facebook and then on mobile as a free-to-play, level-based game, each level with a goal to meet within a fixed number of moves.
Candy Crush Saga (Wikipedia)
Reference games
1985
SameGame
You pick a group of neighbouring same-coloured blocks to remove it, and the ones above fall into the gap. The challenge is to clear as much of the board as you can. It came out in 1985 as Chain Shot!, published in the Japanese magazine Gekkan ASCII.
1986
Shanghai
A mahjong solitaire with stacked tiles in which you remove matching pairs that are free on one side. You have to choose the order carefully so as not to get stuck.
1989
Columns
Columns of three coloured jewels fall and you can reorder them on the way down; three or more alike in a line, diagonals included, disappear. Jay Geertsen shared it in 1989 and Sega released it in arcades and on consoles.
1994
Shariki
You swap two neighbouring balls to line up three or more of the same colour, which vanish and let new ones drop in. Every swap must make a line, and the game ends when none is possible.
1995
Tetris Attack
Blocks keep rising from the bottom and you swap two horizontal neighbours to line up three or more of the same colour. It came out in Japan as Panel de Pon and, despite its Western name, has nothing to do with Tetris.
2001
Bejeweled
You swap two neighbouring gems to line up three or more alike. It began in 2000 as the browser game Diamond Mine and its retail version came out in 2001; it is credited with popularising match-three games and launching the casual games industry.
2007
Puzzle Quest: Challenge of the Warlords
It blends role-playing with match-three. Battles are fought in turns on a board of gems, where each match can damage the opponent or give mana to cast spells, and between battles you explore a fantasy world.
2012
Candy Crush Saga
You swap neighbouring candies to line up three or more; matches of four or more create special candies. Each level has a goal to meet within a fixed number of moves. It is free to play, with in-game purchases.
2018
Emoji Swap
Match-three meets tower defence. Each line of three emojis builds a tower depending on its kind and, when you run out of swaps, night falls and aliens invade. It was made for the js13kGames 2018 competition.
Exercise
Play the demo twice and note which moves set off cascades and where on the board they were. Then change TARGET, MOVES or the number of piece kinds until you win about half your games, and explain which number you changed and why.