Game Development 15 min read

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.

JD.com Experience Design Center
JD.com Experience Design Center
JD.com Experience Design Center
How to Build a Match‑3 Game Engine in Vue: From Logic to Rendering

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.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

animationJavaScriptgridmatch-3game-logic
JD.com Experience Design Center
Written by

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.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.