How to Build a Match‑3 Game Engine in Vue: From Logic to Rendering
This article walks through the complete implementation of a match‑3 (three‑in‑a‑row) game using Vue, covering the core grid logic, swap mechanics, combo detection, crushing, sorting, filling new tiles, move‑possibility checks, and the Vue rendering pipeline with animation states.
1. Game Logic Overview
The game operates on a grid where swapping two adjacent cells creates a new board; if three or more identical tiles align horizontally or vertically they are cleared, otherwise the swap is reverted. After clearing, tiles above fall down and new random tiles fill the gaps, and the process repeats until no more combos exist.
2. Grid Storage
The board is stored as a two‑dimensional array of columns (outer array) each containing rows (inner array). Each cell has a type (Number) indicating its tile kind and a lastPos object tracking its previous position for animation.
Swap Cells
swap(target, source){
const cell = this.map[target.colIndex][target.rowIndex]
this.map[target.colIndex][target.rowIndex] = {
...this.map[source.colIndex][source.rowIndex],
lastPos: { row: source.rowIndex - target.rowIndex, col: source.colIndex - target.colIndex }
}
this.map[source.colIndex][source.rowIndex] = {
...cell,
lastPos: { row: target.rowIndex - source.rowIndex, col: target.colIndex - source.colIndex }
}
}Combo Detection (Column Scan)
scanCol(target, combo = 0){
const targetCell = this.map[target.colIndex][target.rowIndex]
if (target.rowIndex >= this.size.rowCount - 1) return { combo, type: targetCell.type, endTarget: target }
const nextCell = this.map[target.colIndex][target.rowIndex + 1]
if (nextCell.type === targetCell.type) {
return this.scanCol({ rowIndex: target.rowIndex + 1, colIndex: target.colIndex }, combo + 1)
}
return { combo, type: targetCell.type, endTarget: target }
}Combo Detection (Row Scan)
scanRow(target, combo = 0){
const targetCell = this.map[target.colIndex][target.rowIndex]
if (target.colIndex >= this.size.colCount - 1) return { combo, type: targetCell.type, endTarget: target }
const nextCell = this.map[target.colIndex + 1][target.rowIndex]
if (nextCell.type === targetCell.type) {
return this.scanRow({ colIndex: target.colIndex + 1, rowIndex: target.rowIndex }, combo + 1)
}
return { combo, type: targetCell.type, endTarget: target }
}3. Crushing and Scoring
checkCrush(){
let pointToGet = 0
const comboMap = Array(this.size.colCount).fill(Array(this.size.rowCount).fill(0))
let hasCrush = false
// column scan
for (let col = 0; col < this.size.colCount; col++) {
const comboCol = Array(this.size.rowCount).fill(0)
let row = 0
while (row < this.size.rowCount) {
const info = this.scanCol({ colIndex: col, rowIndex: row })
if (info.combo >= 2) {
for (let r = info.endTarget.rowIndex; r > info.endTarget.rowIndex - info.combo; r--) {
comboCol[r] = info.type
}
pointToGet += GOT_POINT[info.combo]
hasCrush = true
}
row = info.endTarget.rowIndex + 1
}
comboMap[col] = comboCol
}
// row scan (similar)
// ...
return { hasCrush, comboMap, pointToGet }
}4. Clearing Tiles
crush(){
const { hasCrush, comboMap, pointToGet } = this.checkCrush()
if (!hasCrush) return
comboMap.forEach((col, colIdx) => {
col.forEach((cell, rowIdx) => {
if (cell > 0) {
this.map[colIdx][rowIdx] = { type: 0, lastPos: { row: 0, col: 0 }, lastType: cell }
}
})
})
this.point += pointToGet
}5. Sorting (Gravity)
sort(){
this.map.forEach((col, colIdx) => {
const sorted = []
col.forEach(cell => {
if (cell.type > 0) sorted.push({ ...cell, lastPos: { row: col.length - sorted.length, col: 0 } })
})
while (sorted.length < col.length) {
sorted.push({ type: 0, lastPos: { row: col.length - sorted.length, col: 0 } })
}
this.map[colIdx] = sorted
})
}6. Filling New Tiles
fillMap(){
this.map.forEach((col, colIdx) => {
const copy = col.slice()
col.forEach((cell, rowIdx) => {
if (!cell || cell.type === 0) {
copy[rowIdx] = { ...copy[rowIdx], type: Math.ceil(Math.random() * this.typeCount) }
}
})
this.map[colIdx] = copy
})
}7. Detecting Possible Moves
The algorithm searches for cells that, after swapping with a neighbor, could form a combo. Two patterns are considered:
Pattern A – a pair of identical tiles with an empty slot on either side.
Pattern B – a single tile surrounded by at least three identical neighbors.
Scanning is performed both column‑wise and row‑wise, building a chanceMap that records positions and required tile types. If any entry satisfies the needed count, the board has a possible move.
checkChance(){
const chanceMap = []
// column scan for pattern A
// row scan for pattern B
// combine results into toMoveMap
return { hasChance: !!toMoveMap.some(col => col.some(v => v)) }
}8. Vue Rendering Pipeline
Each cell stores lastPos to calculate its animated offset. The board rendering state boardStatus cycles through:
0 – render cells at their previous offset.
1 – apply transition animation; cells with type===0 also shrink.
2 – render cells at their final positions without transition.
The next() method updates the Vue data, forces a re‑render, and resolves a Promise after the animation delay, enabling the game loop to be written as asynchronous recursion.
next(){
return new Promise(resolve => {
this.boardStatus = 0
Vue.set(this, 'map', this.game.map)
this.$nextTick(() => {
setTimeout(() => {
this.boardStatus = 1
this.game.resetPos()
setTimeout(() => {
Vue.set(this, 'map', this.game.map)
this.boardStatus = 2
this.isNoMove = !this.game.checkChance().hasChance
this.gotPoint = this.game.point
resolve()
}, 300)
}, 10)
})
})
}9. Asynchronous Game Loop
async crushUntilEnd(){
if (this.game.checkCrush().hasCrush) {
this.game.crush()
await this.next()
this.game.sort()
await this.next()
this.game.fillMap()
await this.next()
if (this.game.checkCrush().hasCrush) {
await this.crushUntilEnd()
}
}
}By chaining these steps, the implementation ensures that the board state, visual animation, scoring, and move‑availability checks stay in sync, delivering a smooth match‑3 experience built entirely with Vue and plain JavaScript.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
JD.com Experience Design Center
Professional, creative, passionate about design. The JD.com User Experience Design Department is committed to creating better e-commerce shopping experiences.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
