How to Build a Popstar Match‑3 Game with MVC Architecture in JavaScript
This article explains the rules, scoring system, and level design of the classic "Popstar" match‑3 game, then details a full MVC implementation in JavaScript—including tile representation, wave‑average distribution, shuffle, wall solidification, view updates, control bindings, and a discussion of the knapsack‑style max‑score problem—while providing complete code snippets and a GitHub repository link.
1. Game Rules
The game uses a 10 × 10 grid populated with five colors (red, green, blue, yellow, purple). The number of tiles of each color is chosen randomly within a fixed interval, not a strict equal split. A group of two or more orthogonally adjacent tiles of the same color can be eliminated.
Scoring formulas:
Total score for eliminating n tiles: n * n * 5 Bonus score after a move: 2000 - n * n * 20 Score contributed by the i ‑th tile in the elimination order (starting at 0): 10 * i + 5 Penalty for a remaining tile at index i: 40 * i + 20 Level target score is 1000 + (level - 1) * 2000. A level is cleared when no more eliminable groups exist and the accumulated score meets or exceeds the target.
2. MVC Design Pattern
The implementation follows a classic Model‑View‑Controller architecture:
Model – stores the tile grid, generates the initial wall, performs elimination, solidifies the wall after holes appear, and clears residual bricks.
View – renders the UI, animates tile movements and removals, and updates visual state based on model notifications.
Control – binds model and view, processes user input, computes level scores, checks win conditions, and exposes a simple API for external events.
3. Model
The 10 × 10 grid is represented by a flat array of length 100, e.g.:
[R, R, G, G, B, B, Y, Y, P, P,
R, R, G, G, B, B, Y, Y, P, P,
...
R, R, G, G, B, B, Y, Y, P, P]Tile generation uses a “wave‑average” distribution to keep each color count within a bounded interval. Pseudo‑code:
// Wave‑average distribution (5 colors, average 20 tiles each, ±4 variance)
waveaverage(5, 4, 4).forEach((count, clr) => {
tiles = tiles.concat(generateTiles(count, clr));
});
// Fisher‑Yates shuffle to randomize positions
shuffle(tiles);Elimination is performed iteratively to avoid recursion depth limits:
function clean(tile) {
let count = 1;
let sameTiles = searchSameTiles(tile);
if (sameTiles.length > 0) {
deleteTile(tile);
while (true) {
let nextSameTiles = [];
sameTiles.forEach(t => {
nextSameTiles.push(...searchSameTiles(t));
makeScore(++count * 10 + 5); // record per‑tile score
deleteTile(t);
});
if (nextSameTiles.length === 0) break;
sameTiles = nextSameTiles;
}
}
}When a tile is removed, the position is marked as a hole so that later solidification can operate only on affected columns:
function deleteTile(tile) {
// Mark the cell as empty
markHollow(tile.index);
// Actual removal logic (e.g., splice from array or set flag)
...
}The wall is modeled as an array of column descriptors, each tracking the number of tiles, the top and bottom occupied rows, and any hole segments:
let wall = [
{count, start, end, pitCount, topPit, bottomPit},
{count, start, end, pitCount, topPit, bottomPit},
// … one entry per column
];This structure distinguishes three column states:
Empty column (no tiles).
Column with a continuous hole segment.
Column with non‑continuous holes.
Residual‑brick clearing iterates over the column set, assigns a negative score to each remaining tile, and marks it as removed:
function clearAll() {
let count = 0;
for (let col = 0; col < this.wall.length; ++col) {
let colInfo = this.wall[col];
for (let row = colInfo.start; row <= colInfo.end; ++row) {
let tile = this.grid[row * this.col + col];
tile.score = -20 - 40 * count++;
tile.removed = true;
}
}
}4. View
The view updates UI elements in response to model changes. Core update routine:
update({originIndex, index, clr, removed, score}) {
if (originIndex === undefined || clr === undefined) return;
let tile = this.tiles[originIndex];
if (tile.clr !== clr) this.updateTileClr(tile, clr);
if (tile.index !== index) this.updateTileIndex(tile, index);
if (tile.score !== score) tile.score = score;
if (tile.removed !== removed) {
removed ? this.bomb(tile) : this.area.addChild(tile.sprite);
tile.removed = removed;
}
}Model properties are defined with Object.defineProperties so that any change automatically triggers view.update.
5. Control
Control binds model and view, generates level scores, checks win conditions, and provides an API for external events and user actions.
Events: pass, pause, resume, gameover APIs: init, next, enter, pause, resume, destroy Binding is performed via property descriptors that forward changes to the view:
Object.defineProperties(model.tile, {
originIndex: { get(){...}, set(v){ view.update({originIndex: v}); } },
index: { get(){...}, set(v){ view.update({index: v}); } },
clr: { get(){...}, set(v){ view.update({clr: v}); } },
removed: { get(){...}, set(v){ view.update({removed: v}); } },
score: { get(){...}, set(v){ view.update({score: v}); } }
});6. Problem Discussion
“Generate random levels, compute scores offline, and keep only those that satisfy the level‑target condition.”
The maximum achievable score for a given matrix is a knapsack‑type optimization problem. A brute‑force recursive search can enumerate all elimination sequences but quickly exceeds JavaScript’s call‑stack limit. One practical workaround is to generate random levels, compute their scores offline, and filter out unsolvable matrices. The author adopts a simpler, though imperfect, approach: before starting a game, detect whether the generated matrix is unsolvable (i.e., no eliminable groups) and regenerate it.
7. Conclusion
The complete source code for this Popstar implementation is hosted at https://github.com/leeenx/popstar. The article demonstrates a concrete MVC‑based architecture for a match‑3 game, efficient tile management, and basic algorithmic considerations.
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.
Aotu Lab
Aotu Lab, founded in October 2015, is a front-end engineering team serving multi-platform products. The articles in this public account are intended to share and discuss technology, reflecting only the personal views of Aotu Lab members and not the official stance of JD.com Technology.
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.
