(() => {
const size = 6;
const scoreEl = document.getElementById('score');
const movesEl = document.getElementById('moves');
const boardEl = document.getElementById('board');
const newGameBtn = document.getElementById('new-game');
const overlay = document.getElementById('overlay');
const startBtn = document.getElementById('start');
const tiles = ['🌙', '🍄', '🦋', '🌸', '💫', '🌿'];
let grid = [];
let selected = null;
let score = 0;
let moves = 25;
let busy = false;
const audio = createAudio();
function createBoard() {
const newGrid = Array.from({ length: size }, () => Array(size).fill(null));
for (let r = 0; r < size; r += 1) {
for (let c = 0; c < size; c += 1) {
let value;
do {
value = randomTile();
newGrid[r][c] = value;
} while (createsMatch(newGrid, r, c));
}
}
return newGrid;
}
function randomTile() {
return tiles[Math.floor(Math.random() * tiles.length)];
}
function createsMatch(gridRef, r, c) {
const value = gridRef[r][c];
if (!value) return false;
const horiz = c >= 2 && gridRef[r][c - 1] === value && gridRef[r][c - 2] === value;
const vert = r >= 2 && gridRef[r - 1][c] === value && gridRef[r - 2][c] === value;
return horiz || vert;
}
function renderBoard() {
boardEl.innerHTML = '';
for (let r = 0; r < size; r += 1) {
for (let c = 0; c < size; c += 1) {
const tile = document.createElement('button');
tile.type = 'button';
tile.className = 'tile';
tile.textContent = grid[r][c];
tile.dataset.row = r;
tile.dataset.col = c;
if (selected && selected.row === r && selected.col === c) {
tile.classList.add('selected');
}
tile.addEventListener('click', () => handleSelect(r, c));
boardEl.appendChild(tile);
}
}
}
function handleSelect(row, col) {
if (busy) return;
if (!selected) {
selected = { row, col };
renderBoard();
return;
}
if (selected.row === row && selected.col === col) {
selected = null;
renderBoard();
return;
}
if (!areNeighbors(selected, { row, col })) {
selected = { row, col };
renderBoard();
return;
}
attemptSwap(selected, { row, col });
}
function areNeighbors(a, b) {
const dr = Math.abs(a.row - b.row);
const dc = Math.abs(a.col - b.col);
return (dr === 1 && dc === 0) || (dr === 0 && dc === 1);
}
function attemptSwap(a, b) {
busy = true;
swap(a, b);
const matches = findMatches();
moves = Math.max(0, moves - 1);
updateHUD();
selected = null;
renderBoard();
if (matches.length === 0) {
busy = false;
playSound('bump');
if (moves === 0) endGame();
return;
}
playSound('swap');
resolveMatches(matches, 1);
}
function swap(a, b) {
const temp = grid[a.row][a.col];
grid[a.row][a.col] = grid[b.row][b.col];
grid[b.row][b.col] = temp;
}
function findMatches() {
const matches = [];
// horizontal
for (let r = 0; r < size; r += 1) {
let run = 1;
for (let c = 1; c < size; c += 1) {
if (grid[r][c] && grid[r][c] === grid[r][c - 1]) {
run += 1;
} else {
if (run >= 3) {
matches.push(...rangeCells(r, c - run, run, 'row'));
}
run = 1;
}
}
if (run >= 3) {
matches.push(...rangeCells(r, size - run, run, 'row'));
}
}
// vertical
for (let c = 0; c < size; c += 1) {
let run = 1;
for (let r = 1; r < size; r += 1) {
if (grid[r][c] && grid[r][c] === grid[r - 1][c]) {
run += 1;
} else {
if (run >= 3) {
matches.push(...rangeCells(r - run, c, run, 'col'));
}
run = 1;
}
}
if (run >= 3) {
matches.push(...rangeCells(size - run, c, run, 'col'));
}
}
const seen = new Set();
return matches.filter(({ row, col }) => {
const key = `${row}-${col}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function rangeCells(startRowOrCol, fixed, length, mode) {
const cells = [];
for (let i = 0; i < length; i += 1) {
if (mode === 'row') {
cells.push({ row: startRowOrCol, col: fixed + i });
} else {
cells.push({ row: startRowOrCol + i, col: fixed });
}
}
return cells;
}
function resolveMatches(matchCells, chain) {
if (matchCells.length === 0) {
busy = false;
if (moves === 0) endGame();
return;
}
matchCells.forEach(({ row, col }) => {
grid[row][col] = null;
});
score += matchCells.length * 60 * chain;
updateHUD();
animateMatches(matchCells);
playSound(chain > 1 ? 'cascade' : 'match');
setTimeout(() => {
applyGravity();
fillNew();
renderBoard();
const nextMatches = findMatches();
if (nextMatches.length > 0) {
resolveMatches(nextMatches, chain + 1);
} else {
busy = false;
if (moves === 0) endGame();
}
}, 350);
}
function animateMatches(cells) {
const map = new Map();
Array.from(boardEl.children).forEach((tile) => {
const r = Number(tile.dataset.row);
const c = Number(tile.dataset.col);
map.set(`${r}-${c}`, tile);
});
cells.forEach(({ row, col }) => {
const tile = map.get(`${row}-${col}`);
if (tile) tile.classList.add('matching');
});
}
function applyGravity() {
for (let c = 0; c < size; c += 1) {
let writeRow = size - 1;
for (let r = size - 1; r >= 0; r -= 1) {
if (grid[r][c]) {
grid[writeRow][c] = grid[r][c];
if (writeRow !== r) {
grid[r][c] = null;
}
writeRow -= 1;
}
}
for (let r = writeRow; r >= 0; r -= 1) {
grid[r][c] = null;
}
}
}
function fillNew() {
for (let r = 0; r < size; r += 1) {
for (let c = 0; c < size; c += 1) {
if (!grid[r][c]) {
grid[r][c] = randomTile();
}
}
}
}
function updateHUD() {
scoreEl.textContent = score;
movesEl.textContent = moves;
}
function startGame() {
audio.resume();
grid = createBoard();
score = 0;
moves = 25;
selected = null;
busy = false;
updateHUD();
renderBoard();
overlay.classList.add('hidden');
}
function endGame() {
overlay.querySelector('p').textContent = `Final score: ${score} sparkles!`;
overlay.classList.remove('hidden');
playSound('end');
}
newGameBtn.addEventListener('click', startGame);
startBtn.addEventListener('click', startGame);
function playSound(type) {
if (audio) audio.play(type);
}
function createAudio() {
const AudioCtx = window.AudioContext || window.webkitAudioContext;
if (!AudioCtx) return null;
const ctx = new AudioCtx();
const master = ctx.createGain();
master.gain.value = 0.18;
master.connect(ctx.destination);
ctx.suspend();
function blip(freq, duration = 0.18, delay = 0) {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'triangle';
osc.frequency.value = freq;
osc.connect(gain);
gain.connect(master);
const start = ctx.currentTime + delay;
gain.gain.setValueAtTime(0.001, start);
gain.gain.linearRampToValueAtTime(0.25, start + 0.02);
gain.gain.exponentialRampToValueAtTime(0.002, start + duration);
osc.start(start);
osc.stop(start + duration + 0.02);
}
return {
resume() {
if (ctx.state === 'suspended') ctx.resume();
},
play(event) {
switch (event) {
case 'bump':
blip(320);
break;
case 'swap':
blip(640);
break;
case 'match':
blip(900);
break;
case 'cascade':
[900, 1080].forEach((freq, i) => blip(freq, 0.2, i * 0.05));
break;
case 'end':
blip(280, 0.4);
break;
default:
break;
}
},
};
}
})();