
Build a Bejeweled-Style Match-3 in Phaser 3
A good match-3 puzzle is easy to understand at a glance: choose two neighboring gems, make a line, and watch the board respond. The interesting work happens behind that simple gesture. Here, we’ll break the game into small systems you can inspect, adapt, and build on.
How This Guide Was Built
This guide combines desk research with a prototype we built and tested in node and headless Chromium; it is not a report of hands-on testing of the commercial Bejeweled. The prototype’s core logic and browser behavior were checked against specific tests, and the implementation details below describe that game.
The commercial game’s history is distinct from this tutorial. Bejeweled was developed and published by PopCap Games, designed by Jason Kapalka, and programmed by Brian Fiete. It began as the browser game Diamond Mine in 2000 before being licensed to MSN Games and renamed. Bejeweled Deluxe followed in May 2001.
What You’re Building
You’re building a self-contained match-3 game in one HTML file, with an 800×600 play area, an eight-by-eight board, and procedural gems. The player gets 30 moves to reach 2500 points. Try the game here; the sections below explain the systems behind its board, input, scoring, and feedback.
> **Controls:** Click or tap a gem, then an adjacent gem to swap them. You can also drag a gem and release it on a neighbor. Press `R` to restart.The game uses Phaser 3.90.0 from the jsDelivr CDN. The page contains the game and its logic together, so you can follow the path from grid data to what appears on screen without needing a separate asset folder. Its board starts without a match already waiting to clear and has at least one valid move.
How the Board and Gems Work
The board’s shape comes from COLS = 8, ROWS = 8, and CELL = 64; each grid position corresponds to a 64-pixel square. There are GEM_TYPES = 6 kinds of gem. Procedural textures give every type both a distinct colour and shape, so recognizing a piece never depends on colour alone.
The remaining gameplay constants are BASE_POINTS = 10, MOVES = 30, and TARGET_SCORE = 2500. Keeping these values together makes balancing approachable: changing the target or starting move count doesn’t require editing match detection. The board’s upper-left corner is (BOARD_X, BOARD_Y) = (144, 40), which leaves room for the 512-pixel-wide grid inside the 800-pixel canvas.
Each gem texture is drawn once with Phaser Graphics and baked into a texture with generateTexture. The six combinations are a purple circle, cyan diamond, green square, pink hexagon, yellow triangle, and orange star. Colour and silhouette work together to make the pieces easier to distinguish, including for players who have difficulty telling similar colours apart.
Match Detection That Handles Intersections
A match detector checks every row for horizontal runs of at least three equal gem types, then every column for vertical runs. It returns one match record per run, with the cells, run length, and direction. When runs cross, a shared cell belongs to both records; clearing uses a set of cells so that intersection is removed only once.
function findMatches(grid) {
const matches = [];
const rows = grid.length;
const cols = grid[0].length;
for (let r = 0; r < rows; r++) {
let start = 0;
while (start < cols) {
let end = start + 1;
while (end < cols && grid[r][end] === grid[r][start]) end++;
if (grid[r][start] != null && end - start >= 3) {
matches.push({
cells: Array.from({ length: end - start }, (_, i) => ({ r, c: start + i })),
runLength: end - start,
horizontal: true
});
}
start = end;
}
}
for (let c = 0; c < cols; c++) {
let start = 0;
while (start < rows) {
let end = start + 1;
while (end < rows && grid[end][c] === grid[start][c]) end++;
if (grid[start][c] != null && end - start >= 3) {
matches.push({
cells: Array.from({ length: end - start }, (_, i) => ({ r: start + i, c })),
runLength: end - start,
horizontal: false
});
}
start = end;
}
}
return matches;
}
Scanning complete runs, instead of stopping as soon as three pieces match, also handles four- and five-gem lines. The loops skip empty cells and advance to the end of each run before continuing. This makes the function useful after gravity and refills, where a new line may be longer than three.
Swaps, Taps, and Drags
Input first converts the pointer position into a board cell, then checks whether that cell is next to the selected gem. A click on a neighbor swaps; a drag uses the released pointer position. Only orthogonally neighboring cells are valid. A swap that creates no match animates back and leaves the move counter unchanged.
function cellAt(pointer) {
const col = Math.floor((pointer.x - BOARD_X) / CELL);
const row = Math.floor((pointer.y - BOARD_Y) / CELL);
if (row < 0 || row >= ROWS || col < 0 || col >= COLS) return null;
return { r: row, c: col };
}
function areAdjacent(a, b) {
return Math.abs(a.r - b.r) + Math.abs(a.c - b.c) === 1;
}
function trySwap(a, b) {
if (!areAdjacent(a, b)) return;
swapCells(grid, a, b);
if (findMatches(grid).length === 0) {
swapCells(grid, a, b); // restore the board
tweenSwapBack(a, b);
return;
}
moves--;
resolve(1);
}
The real input flow supports both selecting a gem with pointerdown and dragging before release. A non-adjacent click is ignored rather than treated as a diagonal or long-distance swap. Keeping cellAt and areAdjacent separate from the event handlers makes the interaction easier to test and reuse.
The coordinate calculation subtracts the board’s offset before dividing by cell size. That detail matters because the grid does not start at the top-left corner of the canvas. The bounds check rejects pointer positions outside the board, so taps on the surrounding interface can’t accidentally address a grid cell.
Gravity and Refills
After matched gems are cleared, each column compacts toward the bottom. Existing gems keep their order, while empty cells collect at the top. The refill step replaces those null cells with random gem types from the supplied random-number function, which also makes deterministic testing possible.
function applyGravity(grid) {
for (let c = 0; c < COLS; c++) {
const kept = [];
for (let r = ROWS - 1; r >= 0; r--) {
if (grid[r][c] != null) kept.push(grid[r][c]);
}
for (let r = ROWS - 1, i = 0; r >= 0; r--, i++) {
grid[r][c] = i < kept.length ? kept[i] : null;
}
}
}
function refill(grid, rng) {
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
if (grid[r][c] == null) {
grid[r][c] = Math.floor(rng() * GEM_TYPES);
}
}
}
}
The board uses null to represent a cleared space, keeping the data model simple: a cell either has a gem type or it does not. In the visual layer, falling gems tween into their new positions and freshly created gems drop in from above. The logic can finish its update first; the animation then shows the player how the board changed.
The Cascade Loop and Combo Scoring
A valid swap begins a resolve cycle at combo depth one. Each pass finds and clears matches, awards points, applies gravity, refills, and scans again. If the refill creates another match, the loop runs at the next combo depth. It stops only when the board is stable, preventing cascades from being cut short.
function pointsFor(n, combo) {
return n * 10 * combo;
}
// pointsFor(n, combo) = n * 10 * combo
function resolve(combo) {
const matches = findMatches(grid);
if (matches.length === 0) {
finishTurn();
return;
}
const cells = new Map();
for (const match of matches) {
for (const cell of match.cells) {
cells.set(`${cell.r},${cell.c}`, cell);
}
}
score += pointsFor(cells.size, combo);
popGems([...cells.values()], () => {
applyGravity(grid);
refill(grid, rng);
drawBoard();
resolve(combo + 1);
});
}
A set-like map ensures that an intersection cell contributes only once to the cleared-gem count, even if it appears in both a horizontal and vertical run. The scoring rule is pointsFor(gemsCleared, combo) = gemsCleared * BASE_POINTS * combo. So combo depth scales the points for every gem cleared in that cascade step.
When the board settles, the turn can finish by checking for a win or a deadlock. The first resolve of a move uses combo one, the next uses two, and each later pass increments again. This sequencing is why the increment belongs in the recursive call: every newly formed match gets its own cascade depth.
Generating a Fair Board
A fair starting board has no matches waiting to clear and at least one legal swap available. Board generation can create random grids until both conditions hold. If the player later runs out of legal swaps, the game shuffles the existing gem types rather than silently leaving an unplayable board.
function createBoard(rng) {
return Array.from({ length: ROWS }, () =>
Array.from({ length: COLS }, () => Math.floor(rng() * GEM_TYPES))
);
}
function createPlayableBoard(rng) {
let grid;
do {
grid = createBoard(rng);
} while (findMatches(grid).length > 0 || !hasValidMove(grid));
return grid;
}
function reshuffle(grid, rng) {
const gems = grid.flat();
do {
fisherYatesShuffle(gems, rng);
grid = Array.from({ length: ROWS }, (_, r) =>
gems.slice(r * COLS, (r + 1) * COLS)
);
} while (findMatches(grid).length > 0 || !hasValidMove(grid));
showToast("No moves — shuffling");
return grid;
}
The shuffle preserves the multiset of gems: it changes their positions but does not create or remove any. Its rejection check is important because a random arrangement could still contain an immediate match, or could leave no swap that makes a match. hasValidMove tests neighboring swaps and restores the grid after each check.
Wiring It Up in Phaser 3
The Phaser scene draws the procedural textures once, positions gem objects on the grid, and uses tweens to make state changes readable. The scale configuration fits the game into its container and centers it. Phaser documents these pieces in its Scale Manager, Graphics, Tweens, and Input guides.
The canvas is configured with width: 800, height: 600, and parent: 'game', alongside scale: { mode: Phaser.Scale.FIT, autoCenter: Phaser.Scale.CENTER_BOTH }. The running Phaser instance is exposed as window.__G, useful when inspecting the page during development. The responsive fit keeps the canvas within its available space while preserving the intended play area.
Texture creation uses Phaser Graphics to draw the six shapes and calls generateTexture('gem0' ... 'gem5', 56, 56). There are no image, sprite-sheet, or audio assets to download. Three tween roles give actions distinct timing: swaps take about 140 milliseconds with Sine.easeInOut, pops shrink and fade over about 240 milliseconds with Back.easeIn, and falling gems use Bounce.easeOut with duration increasing by row.
Win, Lose, and the Core API
The game checks the score and remaining moves after a turn settles. Reaching 2500 points wins; using all 30 moves below the target loses. A final overlay shows the score and a [ PLAY AGAIN ] button, while R restarts. The logic functions are also available through window.Match3Core for isolated tests.
The exposed core includes createBoard(rng), findMatches(grid), areAdjacent(a, b), swapCells(grid, a, b), applyGravity(grid), refill(grid, rng), hasValidMove(grid), createPlayableBoard(rng), and pointsFor(n, combo). Separating these functions from Phaser’s drawing and event handling makes it easier to reason about the rules without needing to animate a scene.
A node test suite has 21 assertions covering the core logic, and all 21 pass. A headless Chromium run loaded the page on an 800×600 canvas with zero page errors and zero console errors. In scripted browser checks, one swap changed the score from 0 to 30 and moves from 30 to 29; a second swap raised the score from 30 to 60.
FAQ
These quick answers focus on choices that often come up when turning a small prototype into a project: how to grow the code safely, why cascades need repeated checks, and what makes a board fair. The examples stay grounded in this implementation, so you can compare each answer with the game and its core functions.
Why does a swap that makes no match return to its starting position?
The game’s rule is that a swap is valid only when it creates a run of three or more. The prototype checks for a match immediately after swapping; if there is none, it swaps the same cells back, plays the return tween, and does not deduct a move. This keeps failed experiments low-risk for the player.
Why check for matches again after a refill?
Gravity and random refills can line up gems that were not adjacent before the clear. One scan would miss those newly created runs, leaving matches sitting on the board. The resolve loop scans after each refill and advances the combo depth until it finds no matches, then lets the turn finish.
How can I make the random board reproducible in tests?
Pass an injectable rng function into board creation and refill rather than calling Math.random() inside every helper. A test can provide a predictable sequence of values, then check the resulting grid and its matches. The same design keeps production behavior random while making tricky board states repeatable.
What You Learned
The central lesson is to separate board rules from presentation: a small set of grid functions handles matching, swaps, gravity, refills, and scoring, while Phaser turns those changes into visible feedback. With those boundaries in place, you can test the rules, tune the feel, and add features without rewriting the whole game.
- Scan horizontal and vertical runs, and count intersecting gems only once.
- Convert pointer positions to grid coordinates before validating swaps.
- Compact columns, refill gaps, and resolve cascades until the board settles.
- Generate boards with no opening match and at least one legal move.
- Use procedural shape-and-colour textures and Phaser tweens to communicate game state.
HERO_IMAGE_PROMPT: A vivid dark-purple game-development desk scene with an 8×8 board of glowing circle, diamond, square, hexagon, triangle, and star gems mid-cascade, bright particle pops, and a subtle Phaser-inspired coding window in the background.