(() => {
const rows = 20;
const cols = 10;
const boardEl = document.getElementById('board');
const previewEl = document.getElementById('preview');
const scoreEl = document.getElementById('score');
const linesEl = document.getElementById('lines');
const bestEl = document.getElementById('best');
const newGameBtn = document.getElementById('new-game');
const overlay = document.getElementById('overlay');
const startBtn = document.getElementById('start');
const touchControls = document.getElementById('touch-controls');
const bestKey = 'starlitBestScore';
const shapes = [
[[1, 1, 1, 1]],
[[1, 0, 0], [1, 1, 1]],
[[0, 0, 1], [1, 1, 1]],
[[1, 1], [1, 1]],
[[0, 1, 1], [1, 1, 0]],
[[0, 1, 0], [1, 1, 1]],
[[1, 1, 0], [0, 1, 1]],
];
const colors = ['#f9b5ff', '#9dd1ff', '#a3ffde', '#ffd4a8', '#ff9bd1', '#c4a5ff', '#a5ffe3'];
let board = createBoard();
let current = null;
let nextPiece = null;
let dropTimer = null;
let dropInterval = 800;
let score = 0;
let lines = 0;
let bestScore = Number(localStorage.getItem(bestKey)) || 0;
let isActive = false;
const audio = createAudio();
const cells = [];
function initGrid() {
boardEl.innerHTML = '';
for (let r = 0; r < rows; r += 1) {
const rowCells = [];
for (let c = 0; c < cols; c += 1) {
const cell = document.createElement('div');
cell.className = 'cell';
boardEl.appendChild(cell);
rowCells.push(cell);
}
cells.push(rowCells);
}
}
function createBoard() {
return Array.from({ length: rows }, () => Array(cols).fill(null));
}
function randomPiece() {
const idx = Math.floor(Math.random() * shapes.length);
return {
shape: shapes[idx].map((row) => row.slice()),
row: 0,
col: Math.floor(cols / 2) - 2,
color: colors[idx],
};
}
function spawnPiece() {
current = nextPiece || randomPiece();
current.row = -1;
current.col = Math.floor(cols / 2) - Math.ceil(current.shape[0].length / 2);
nextPiece = randomPiece();
renderPreview();
if (!canMove(current.shape, current.row + 1, current.col)) {
endGame();
return;
}
current.row += 1;
}
function renderPreview() {
previewEl.innerHTML = '';
const grid = Array.from({ length: 4 }, () => Array(4).fill(false));
nextPiece.shape.forEach((row, r) => {
row.forEach((val, c) => {
if (val && r < 4 && c < 4) grid[r][c] = true;
});
});
grid.forEach((row) => {
row.forEach((filled) => {
const cell = document.createElement('div');
cell.className = 'preview-cell';
if (filled) {
cell.style.background = nextPiece.color;
}
previewEl.appendChild(cell);
});
});
}
function canMove(shape, targetRow, targetCol) {
for (let r = 0; r < shape.length; r += 1) {
for (let c = 0; c < shape[r].length; c += 1) {
if (!shape[r][c]) continue;
const newRow = targetRow + r;
const newCol = targetCol + c;
if (newCol < 0 || newCol >= cols || newRow >= rows) return false;
if (newRow >= 0 && board[newRow][newCol]) return false;
}
}
return true;
}
function placePiece() {
const piece = current;
piece.shape.forEach((row, r) => {
row.forEach((val, c) => {
if (val) {
const boardRow = piece.row + r;
const boardCol = piece.col + c;
if (boardRow >= 0) {
board[boardRow][boardCol] = current.color;
}
}
});
});
current = null;
playSound('lock');
const cleared = clearLines();
if (cleared === 0) {
spawnPiece();
} else {
playSound(cleared >= 4 ? 'tetris' : 'clear');
setTimeout(spawnPiece, 60);
}
}
function rotate(shape) {
const size = shape.length;
const width = shape[0].length;
const newShape = Array.from({ length: width }, () => Array(size).fill(0));
for (let r = 0; r < size; r += 1) {
for (let c = 0; c < width; c += 1) {
newShape[c][size - 1 - r] = shape[r][c];
}
}
return newShape;
}
function tryRotate() {
const nextShape = rotate(current.shape);
if (canMove(nextShape, current.row, current.col)) {
current.shape = nextShape;
}
}
function clearLines() {
let cleared = 0;
for (let r = rows - 1; r >= 0; r -= 1) {
if (board[r].every(Boolean)) {
board.splice(r, 1);
board.unshift(Array(cols).fill(null));
cleared += 1;
r += 1;
}
}
if (cleared > 0) {
score += cleared * 100;
lines += cleared;
dropInterval = Math.max(200, 800 - lines * 10);
updateHUD();
restartDropTimer();
}
return cleared;
}
function moveDown() {
if (!current) return;
if (canMove(current.shape, current.row + 1, current.col)) {
current.row += 1;
} else {
placePiece();
}
render();
}
function hardDrop() {
if (!current) return;
while (canMove(current.shape, current.row + 1, current.col)) {
current.row += 1;
}
placePiece();
render();
playSound('drop');
}
function render() {
for (let r = 0; r < rows; r += 1) {
for (let c = 0; c < cols; c += 1) {
const cell = cells[r][c];
const color = board[r][c];
if (color) {
cell.classList.add('filled');
cell.style.background = color;
} else {
cell.classList.remove('filled');
cell.style.background = 'var(--cell-empty)';
}
}
}
if (current) {
current.shape.forEach((row, rIndex) => {
row.forEach((val, cIndex) => {
if (val) {
const br = current.row + rIndex;
const bc = current.col + cIndex;
if (br >= 0 && br < rows && bc >= 0 && bc < cols) {
const cell = cells[br][bc];
cell.classList.add('filled');
cell.style.background = current.color;
}
}
});
});
}
}
function updateHUD() {
scoreEl.textContent = score;
linesEl.textContent = lines;
if (score > bestScore) {
bestScore = score;
localStorage.setItem(bestKey, String(bestScore));
}
bestEl.textContent = bestScore;
}
function startGame() {
if (audio) audio.resume();
board = createBoard();
score = 0;
lines = 0;
dropInterval = 800;
updateHUD();
if (dropTimer) clearInterval(dropTimer);
current = null;
nextPiece = randomPiece();
spawnPiece();
render();
dropTimer = setInterval(moveDown, dropInterval);
isActive = true;
overlay.classList.add('hidden');
}
function endGame() {
isActive = false;
clearInterval(dropTimer);
dropTimer = null;
overlay.querySelector('p').textContent = `Final score: ${score} ⭐`;
overlay.classList.remove('hidden');
}
function restartDropTimer() {
if (dropTimer) {
clearInterval(dropTimer);
}
dropTimer = setInterval(moveDown, dropInterval);
}
function move(direction) {
if (!isActive) return;
const offset = direction === 'left' ? -1 : 1;
if (canMove(current.shape, current.row, current.col + offset)) {
current.col += offset;
render();
playSound('move');
}
}
function softDrop() {
if (canMove(current.shape, current.row + 1, current.col)) {
current.row += 1;
score += 1;
updateHUD();
render();
playSound('soft');
}
}
document.addEventListener('keydown', (event) => {
if (!isActive && event.code !== 'Space') return;
switch (event.code) {
case 'ArrowLeft':
event.preventDefault();
move('left');
break;
case 'ArrowRight':
event.preventDefault();
move('right');
break;
case 'ArrowUp':
event.preventDefault();
tryRotate();
render();
playSound('rotate');
break;
case 'ArrowDown':
event.preventDefault();
softDrop();
break;
case 'Space':
event.preventDefault();
hardDrop();
break;
default:
break;
}
});
touchControls.addEventListener('click', (event) => {
const button = event.target.closest('button');
if (!button) return;
const action = button.dataset.action;
if (!isActive && action !== 'drop') return;
switch (action) {
case 'left':
move('left');
break;
case 'right':
move('right');
break;
case 'rotate':
tryRotate();
render();
break;
case 'drop':
softDrop();
break;
default:
break;
}
});
newGameBtn.addEventListener('click', startGame);
startBtn.addEventListener('click', startGame);
bestEl.textContent = bestScore;
initGrid();
render();
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();
const tones = {
move: 760,
rotate: 840,
lock: 520,
clear: 960,
tetris: 0,
drop: 620,
soft: 700,
};
function schedule(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.0001, 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(type) {
if (type === 'tetris') {
[880, 980, 1100, 1300].forEach((freq, i) => schedule(freq, 0.2, i * 0.05));
return;
}
const freq = tones[type] || 600;
schedule(freq);
},
};
}
})();