document.addEventListener('DOMContentLoaded', () => { const COLS = 10; const ROWS = 20; const BLOCK = 30; const STORAGE_KEYS = { best: 'tetris_best_score_v1', history: 'tetris_run_history_v1' }; const MULTI_STORAGE_KEYS = { room: 'tetris_room_code_v1', token: 'tetris_player_token_v1', name: 'tetris_player_name_v1', slot: 'tetris_player_slot_v1' }; const MULTI_API_URL = 'api/multiplayer.php'; const SCOREBOARD_API_URL = 'api/scoreboard.php'; const MULTI_POLL_INTERVAL = 800; const SCOREBOARD_TIMEOUT_MS = 4000; const SCOREBOARD_PENDING_KEY = 'tetris_scoreboard_pending_v1'; const PIECE_DEFS = { I: { matrix: [ [0, 0, 0, 0], [1, 1, 1, 1], [0, 0, 0, 0], [0, 0, 0, 0] ], color: '#7cb8c7' }, J: { matrix: [ [2, 0, 0], [2, 2, 2], [0, 0, 0] ], color: '#7a8db6' }, L: { matrix: [ [0, 0, 3], [3, 3, 3], [0, 0, 0] ], color: '#c49a63' }, O: { matrix: [ [4, 4], [4, 4] ], color: '#d0b768' }, S: { matrix: [ [0, 5, 5], [5, 5, 0], [0, 0, 0] ], color: '#86b494' }, T: { matrix: [ [0, 6, 0], [6, 6, 6], [0, 0, 0] ], color: '#b18ab8' }, Z: { matrix: [ [7, 7, 0], [0, 7, 7], [0, 0, 0] ], color: '#cb7d7d' } }; const colorLookup = Object.entries(PIECE_DEFS).reduce((acc, [type, def], index) => { acc[index + 1] = def.color; return acc; }, {}); const boardCanvas = document.getElementById('tetris-board'); const nextCanvas = document.getElementById('next-piece'); const opponentCanvas = document.getElementById('opponent-board'); const boardCtx = boardCanvas.getContext('2d'); const nextCtx = nextCanvas.getContext('2d'); boardCtx.scale(BLOCK, BLOCK); const opponentCtx = opponentCanvas ? opponentCanvas.getContext('2d') : null; const OPPONENT_BLOCK = 20; if (opponentCtx) { opponentCtx.setTransform(1, 0, 0, 1, 0, 0); opponentCtx.scale(OPPONENT_BLOCK, OPPONENT_BLOCK); } const ui = { score: document.getElementById('score-value'), lines: document.getElementById('lines-value'), level: document.getElementById('level-value'), speed: document.getElementById('speed-value'), bestInline: document.getElementById('best-score-inline'), elapsedInline: document.getElementById('elapsed-time-inline'), badge: document.getElementById('game-state-badge'), overlay: document.getElementById('board-overlay'), pauseButton: document.getElementById('pause-button'), restartButton: document.getElementById('restart-button'), modalRestartButton: document.getElementById('modal-restart-button'), detailStatus: document.getElementById('detail-status'), detailPieces: document.getElementById('detail-pieces'), detailLines: document.getElementById('detail-lines'), detailDuration: document.getElementById('detail-duration'), finalScore: document.getElementById('final-score'), finalLines: document.getElementById('final-lines'), finalLevel: document.getElementById('final-level'), finalDuration: document.getElementById('final-duration'), historyEmpty: document.getElementById('history-empty'), historyList: document.getElementById('history-list'), historyDetail: document.getElementById('history-detail'), scoreboardStatus: document.getElementById('scoreboard-status'), scoreboardEmpty: document.getElementById('scoreboard-empty'), scoreboardList: document.getElementById('scoreboard-list'), selectedScore: document.getElementById('selected-score'), selectedLevel: document.getElementById('selected-level'), selectedLines: document.getElementById('selected-lines'), selectedDuration: document.getElementById('selected-duration'), selectedFinished: document.getElementById('selected-finished'), toastEl: document.getElementById('game-toast'), toastBody: document.getElementById('toast-body'), toastTimestamp: document.getElementById('toast-timestamp'), playerNameInput: document.getElementById('player-name'), createRoomBtn: document.getElementById('create-room-btn'), joinRoomBtn: document.getElementById('join-room-btn'), leaveRoomBtn: document.getElementById('leave-room-btn'), roomCodeInput: document.getElementById('room-code-input'), roomCodeCard: document.getElementById('room-code-card'), roomCodeValue: document.getElementById('room-code-value'), copyRoomBtn: document.getElementById('copy-room-btn'), roomStatusBadge: document.getElementById('room-status-badge'), roomStatusText: document.getElementById('room-status-text'), opponentName: document.getElementById('opponent-name'), opponentStats: document.getElementById('opponent-stats'), opponentStatus: document.getElementById('opponent-status') }; const bootstrapToast = window.bootstrap ? bootstrap.Toast.getOrCreateInstance(ui.toastEl, { delay: 2200 }) : null; const gameOverModalEl = document.getElementById('gameOverModal'); const gameOverModal = window.bootstrap ? new bootstrap.Modal(gameOverModalEl) : null; function safeOn(element, eventName, handler) { if (!element) return; element.addEventListener(eventName, handler); } let board = createBoard(); let currentPiece = null; let nextQueue = []; let bag = []; let animationFrameId = null; let lastTime = 0; let dropCounter = 0; let dropInterval = 900; let score = 0; let lines = 0; let level = 1; let piecesPlaced = 0; let bestScore = Number(localStorage.getItem(STORAGE_KEYS.best) || 0); let history = loadHistory(); let scoreboardEntries = []; let selectedRunId = history[0]?.id || null; let startedAt = null; let endedAt = null; let isRunning = false; let isPaused = false; let isGameOver = false; const multiplayer = { roomCode: null, token: null, slot: null, displayName: '', pollTimer: null, isSyncing: false, opponent: null, roomStatus: 'offline', playerCount: 0 }; function createBoard() { return Array.from({ length: ROWS }, () => Array(COLS).fill(0)); } function cloneMatrix(matrix) { return matrix.map((row) => row.slice()); } function shuffle(array) { const clone = array.slice(); for (let i = clone.length - 1; i > 0; i -= 1) { const j = Math.floor(Math.random() * (i + 1)); [clone[i], clone[j]] = [clone[j], clone[i]]; } return clone; } function fillQueue() { while (nextQueue.length < 4) { if (bag.length === 0) { bag = shuffle(Object.keys(PIECE_DEFS)); } nextQueue.push(bag.pop()); } } function createPiece(type) { const definition = PIECE_DEFS[type]; const matrix = cloneMatrix(definition.matrix); return { type, matrix, x: Math.floor(COLS / 2) - Math.ceil(matrix[0].length / 2), y: 0, color: definition.color }; } function spawnPiece() { fillQueue(); currentPiece = createPiece(nextQueue.shift()); fillQueue(); if (collides(board, currentPiece)) { finishGame(); } } function collides(targetBoard, piece, moveX = 0, moveY = 0, testMatrix = piece.matrix) { for (let y = 0; y < testMatrix.length; y += 1) { for (let x = 0; x < testMatrix[y].length; x += 1) { if (testMatrix[y][x] === 0) continue; const boardX = x + piece.x + moveX; const boardY = y + piece.y + moveY; if (boardX < 0 || boardX >= COLS || boardY >= ROWS) return true; if (boardY >= 0 && targetBoard[boardY][boardX] !== 0) return true; } } return false; } function mergePiece() { currentPiece.matrix.forEach((row, y) => { row.forEach((value, x) => { if (value !== 0 && board[y + currentPiece.y] && board[y + currentPiece.y][x + currentPiece.x] !== undefined) { board[y + currentPiece.y][x + currentPiece.x] = value; } }); }); } function clearLines() { let cleared = 0; outer: for (let y = ROWS - 1; y >= 0; y -= 1) { for (let x = 0; x < COLS; x += 1) { if (board[y][x] === 0) { continue outer; } } const row = board.splice(y, 1)[0].fill(0); board.unshift(row); cleared += 1; y += 1; } return cleared; } function rotate(matrix, direction) { const rotated = matrix.map((_, index) => matrix.map((row) => row[index])); if (direction > 0) { rotated.forEach((row) => row.reverse()); } else { rotated.reverse(); } return rotated; } function attemptRotate(direction) { if (!currentPiece || isPaused || isGameOver) return; const rotated = rotate(currentPiece.matrix, direction); const offsets = [0, -1, 1, -2, 2]; const originalX = currentPiece.x; for (const offset of offsets) { if (!collides(board, currentPiece, offset, 0, rotated)) { currentPiece.x += offset; currentPiece.matrix = rotated; draw(); return; } } currentPiece.x = originalX; } function movePiece(deltaX) { if (!currentPiece || isPaused || isGameOver) return; if (!collides(board, currentPiece, deltaX, 0)) { currentPiece.x += deltaX; draw(); } } function dropPiece(softDrop = false) { if (!currentPiece || isPaused || isGameOver) return; if (!collides(board, currentPiece, 0, 1)) { currentPiece.y += 1; if (softDrop) score += 1; dropCounter = 0; updateStats(); draw(); return true; } mergePiece(); piecesPlaced += 1; const cleared = clearLines(); if (cleared > 0) { const lineScores = [0, 100, 300, 500, 800]; score += lineScores[cleared] * level; lines += cleared; showToast(cleared === 4 ? 'Tetris! Four lines cleared.' : `${cleared} line${cleared > 1 ? 's' : ''} cleared.`); } level = Math.floor(lines / 10) + 1; dropInterval = Math.max(120, 900 - ((level - 1) * 65)); spawnPiece(); updateStats(); draw(); return false; } function hardDrop() { if (!currentPiece || isPaused || isGameOver) return; let distance = 0; while (!collides(board, currentPiece, 0, 1)) { currentPiece.y += 1; distance += 1; } if (distance > 0) { score += distance * 2; } dropPiece(false); } function calculateGhostY() { if (!currentPiece) return 0; let ghostY = currentPiece.y; while (!collides(board, { ...currentPiece, y: ghostY }, 0, 1)) { ghostY += 1; } return ghostY; } function drawCell(ctx, x, y, color, alpha = 1) { ctx.save(); ctx.globalAlpha = alpha; ctx.fillStyle = color; ctx.fillRect(x + 0.04, y + 0.04, 0.92, 0.92); ctx.strokeStyle = 'rgba(255,255,255,0.15)'; ctx.lineWidth = 0.05; ctx.strokeRect(x + 0.06, y + 0.06, 0.88, 0.88); ctx.restore(); } function drawBoard() { boardCtx.fillStyle = '#0e1218'; boardCtx.fillRect(0, 0, COLS, ROWS); for (let y = 0; y < ROWS; y += 1) { for (let x = 0; x < COLS; x += 1) { if (board[y][x] !== 0) { drawCell(boardCtx, x, y, colorLookup[board[y][x]]); } } } boardCtx.strokeStyle = 'rgba(255,255,255,0.06)'; boardCtx.lineWidth = 0.03; for (let x = 0; x <= COLS; x += 1) { boardCtx.beginPath(); boardCtx.moveTo(x, 0); boardCtx.lineTo(x, ROWS); boardCtx.stroke(); } for (let y = 0; y <= ROWS; y += 1) { boardCtx.beginPath(); boardCtx.moveTo(0, y); boardCtx.lineTo(COLS, y); boardCtx.stroke(); } } function drawPiece(piece, alpha = 1) { piece.matrix.forEach((row, y) => { row.forEach((value, x) => { if (value !== 0) { drawCell(boardCtx, x + piece.x, y + piece.y, colorLookup[value], alpha); } }); }); } function drawGhostPiece() { if (!currentPiece) return; const ghostY = calculateGhostY(); drawPiece({ ...currentPiece, y: ghostY }, 0.2); } function drawNext() { nextCtx.clearRect(0, 0, nextCanvas.width, nextCanvas.height); nextCtx.fillStyle = '#0e1218'; nextCtx.fillRect(0, 0, nextCanvas.width, nextCanvas.height); const previewType = nextQueue[0]; if (!previewType) return; const matrix = PIECE_DEFS[previewType].matrix; const cell = 28; const width = matrix[0].length * cell; const height = matrix.length * cell; const offsetX = (nextCanvas.width - width) / 2; const offsetY = (nextCanvas.height - height) / 2; matrix.forEach((row, y) => { row.forEach((value, x) => { if (value !== 0) { nextCtx.fillStyle = colorLookup[value]; nextCtx.fillRect(offsetX + (x * cell) + 2, offsetY + (y * cell) + 2, cell - 4, cell - 4); nextCtx.strokeStyle = 'rgba(255,255,255,0.15)'; nextCtx.strokeRect(offsetX + (x * cell) + 2, offsetY + (y * cell) + 2, cell - 4, cell - 4); } }); }); } function formatDuration(ms) { const totalSeconds = Math.max(0, Math.floor(ms / 1000)); const minutes = String(Math.floor(totalSeconds / 60)).padStart(2, '0'); const seconds = String(totalSeconds % 60).padStart(2, '0'); return `${minutes}:${seconds}`; } function getElapsedMs() { if (!startedAt) return 0; if (endedAt) return endedAt - startedAt; return Date.now() - startedAt; } function updateStats() { ui.score.textContent = score.toLocaleString(); ui.lines.textContent = lines.toString(); ui.level.textContent = level.toString(); ui.speed.textContent = `${(dropInterval / 1000).toFixed(2)}s`; ui.bestInline.textContent = bestScore.toLocaleString(); ui.elapsedInline.textContent = formatDuration(getElapsedMs()); ui.detailPieces.textContent = piecesPlaced.toString(); ui.detailLines.textContent = lines.toString(); ui.detailDuration.textContent = formatDuration(getElapsedMs()); ui.detailStatus.textContent = isGameOver ? 'Game over' : (isPaused ? 'Paused' : (isRunning ? 'In progress' : 'Ready to start')); drawNext(); } function getGameStatus() { if (isGameOver) return 'game_over'; if (isPaused) return 'paused'; if (isRunning) return 'playing'; return 'ready'; } function getComposedBoard() { const composed = board.map((row) => row.slice()); if (currentPiece) { currentPiece.matrix.forEach((row, y) => { row.forEach((value, x) => { if (value === 0) return; const boardY = y + currentPiece.y; const boardX = x + currentPiece.x; if (boardY < 0 || boardY >= ROWS || boardX < 0 || boardX >= COLS) return; composed[boardY][boardX] = value; }); }); } return composed; } function drawOpponentBoard(boardState) { if (!opponentCtx) return; opponentCtx.fillStyle = '#0e1218'; opponentCtx.fillRect(0, 0, COLS, ROWS); if (Array.isArray(boardState)) { for (let y = 0; y < ROWS; y += 1) { for (let x = 0; x < COLS; x += 1) { if (boardState[y] && boardState[y][x]) { drawCell(opponentCtx, x, y, colorLookup[boardState[y][x]]); } } } } opponentCtx.strokeStyle = 'rgba(255,255,255,0.06)'; opponentCtx.lineWidth = 0.03; for (let x = 0; x <= COLS; x += 1) { opponentCtx.beginPath(); opponentCtx.moveTo(x, 0); opponentCtx.lineTo(x, ROWS); opponentCtx.stroke(); } for (let y = 0; y <= ROWS; y += 1) { opponentCtx.beginPath(); opponentCtx.moveTo(0, y); opponentCtx.lineTo(COLS, y); opponentCtx.stroke(); } } function setBadge(label, variant = 'ready') { ui.badge.className = `badge status-badge state-${variant}`; ui.badge.textContent = label; } function showOverlay(title, copy, label = 'Press Enter to start') { ui.overlay.innerHTML = `

${label}

${title}

${copy}

`; ui.overlay.classList.remove('hidden'); } function hideOverlay() { ui.overlay.classList.add('hidden'); } function showToast(message) { ui.toastBody.textContent = message; ui.toastTimestamp.textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); if (bootstrapToast) bootstrapToast.show(); } function loadHistory() { try { const parsed = JSON.parse(localStorage.getItem(STORAGE_KEYS.history) || "[]"); return Array.isArray(parsed) ? parsed : []; } catch (error) { console.warn("Failed to load history", error); return []; } } function saveHistory(run) { if (!run || !run.id) return; history = [run, ...history.filter((entry) => String(entry.id) !== String(run.id))].slice(0, 10); selectedRunId = run.id; try { localStorage.setItem(STORAGE_KEYS.history, JSON.stringify(history)); } catch (error) { console.warn('Failed to save history', error); } renderHistory(); } function renderHistory() { if (!ui.historyList || !ui.historyEmpty || !ui.historyDetail) return; if (!Array.isArray(history) || history.length === 0) { ui.historyList.innerHTML = ''; ui.historyEmpty.classList.remove('d-none'); ui.historyDetail.classList.add('d-none'); selectedRunId = null; return; } ui.historyEmpty.classList.add('d-none'); ui.historyDetail.classList.remove('d-none'); const normalizedSelectedId = selectedRunId ? String(selectedRunId) : String(history[0].id); const selectedRun = history.find((entry) => String(entry.id) === normalizedSelectedId) || history[0]; selectedRunId = selectedRun.id; ui.historyList.innerHTML = history.map((entry) => { const isActive = String(entry.id) === String(selectedRunId); const finishedLabel = entry.finishedAt ? new Date(entry.finishedAt).toLocaleString([], { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : 'Just now'; return ` `; }).join(''); ui.historyList.querySelectorAll('[data-run-id]').forEach((button) => { button.addEventListener('click', () => { selectedRunId = button.getAttribute('data-run-id'); renderHistory(); }); }); ui.selectedScore.textContent = Number(selectedRun.score || 0).toLocaleString(); ui.selectedLevel.textContent = Number(selectedRun.level || 0).toString(); ui.selectedLines.textContent = Number(selectedRun.lines || 0).toString(); ui.selectedDuration.textContent = selectedRun.duration || '00:00'; ui.selectedFinished.textContent = selectedRun.finishedAt ? new Date(selectedRun.finishedAt).toLocaleString([], { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : 'Just now'; } function escapeHtml(value) { return String(value ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function renderScoreboard() { if (!ui.scoreboardList || !ui.scoreboardEmpty) return; if (!Array.isArray(scoreboardEntries) || scoreboardEntries.length === 0) { ui.scoreboardList.innerHTML = ''; ui.scoreboardEmpty.classList.remove('d-none'); return; } ui.scoreboardEmpty.classList.add('d-none'); ui.scoreboardList.innerHTML = scoreboardEntries.map((entry, index) => { const playerName = escapeHtml(entry.player_name || 'Player'); const scoreLabel = Number(entry.score || 0).toLocaleString(); const levelLabel = Number(entry.level || 0).toString(); const linesLabel = Number(entry.lines || 0).toString(); const modeLabel = entry.mode === 'multiplayer' ? 'Multiplayer' : 'Solo'; const durationLabel = formatDuration(Number(entry.duration_seconds || 0) * 1000); const createdAt = entry.created_at ? new Date(String(entry.created_at).replace(' ', 'T')).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : 'Just now'; const roomCode = entry.room_code ? ` · Room ${escapeHtml(entry.room_code)}` : ''; return `
#${index + 1}
${playerName} ${scoreLabel}
Level ${levelLabel} · Lines ${linesLabel} · ${durationLabel}
${modeLabel}${roomCode} · ${createdAt}
`; }).join(''); } async function loadScoreboard(options = {}) { if (!ui.scoreboardStatus) return; const preserveOnError = Boolean(options.preserveOnError); ui.scoreboardStatus.textContent = "Loading…"; const controller = new AbortController(); const timeoutId = window.setTimeout(() => controller.abort(), SCOREBOARD_TIMEOUT_MS); try { const response = await fetch(SCOREBOARD_API_URL + "?limit=10", { cache: "no-store", signal: controller.signal }); const payload = await response.json(); if (!response.ok || !payload.success) { throw new Error(payload.error || "Unable to load scoreboard."); } scoreboardEntries = Array.isArray(payload.scores) ? payload.scores : []; renderScoreboard(); ui.scoreboardStatus.textContent = scoreboardEntries.length > 0 ? "Top " + scoreboardEntries.length : "Offline"; } catch (error) { if (error && error.name === "AbortError") { if (!preserveOnError && scoreboardEntries.length === 0 && ui.scoreboardStatus) { ui.scoreboardStatus.textContent = "Offline"; } return; } console.warn("Scoreboard load error", error); if (!preserveOnError) { scoreboardEntries = []; } renderScoreboard(); ui.scoreboardStatus.textContent = scoreboardEntries.length > 0 ? "Top " + scoreboardEntries.length : "Offline"; } finally { window.clearTimeout(timeoutId); } } function getPreferredPlayerName() { // Prefer multiplayer display name (if available), otherwise use the solo input value. let name = (multiplayer && multiplayer.displayName ? multiplayer.displayName : "") || (ui && ui.playerNameInput ? ui.playerNameInput.value : "") || (sessionStorage ? sessionStorage.getItem(MULTI_STORAGE_KEYS.name) : "") || ""; name = String(name).trim(); name = name.replace(/\s+/g, " "); // Keep it short; backend will also normalize/sanitize. if (name.length > 48) name = name.slice(0, 48); return name || "Player"; } function buildScoreboardPayload(run) { return { action: 'save_score', player_name: getPreferredPlayerName(), score: run.score, lines: run.lines, level: run.level, pieces_placed: run.piecesPlaced, duration_seconds: run.durationSeconds, room_code: multiplayer.roomCode || null }; } function queuePendingScoreSave(payload) { if (!payload) return; try { const raw = localStorage.getItem(SCOREBOARD_PENDING_KEY); const list = raw ? JSON.parse(raw) : []; const safeList = Array.isArray(list) ? list : []; safeList.unshift(payload); localStorage.setItem(SCOREBOARD_PENDING_KEY, JSON.stringify(safeList.slice(0, 10))); } catch (error) { console.warn("Failed to queue pending scoreboard save", error); } } async function flushPendingScoreSaves() { let pending = []; try { const raw = localStorage.getItem(SCOREBOARD_PENDING_KEY); const parsed = raw ? JSON.parse(raw) : []; pending = Array.isArray(parsed) ? parsed : []; } catch (error) { console.warn("Failed to read pending scoreboard saves", error); return; } if (pending.length === 0) return; // Stored newest -> oldest. Send oldest -> newest. const toSend = pending.slice(0, 10).reverse(); const keep = []; for (let i = 0; i < toSend.length; i++) { const payload = toSend[i]; const controller = new AbortController(); const timeoutId = window.setTimeout(() => controller.abort(), 2500); try { const response = await fetch(SCOREBOARD_API_URL, { method: "POST", headers: { "Content-Type": "application/json" }, keepalive: true, cache: "no-store", signal: controller.signal, body: JSON.stringify(payload) }); const responsePayload = await response.json().catch(() => null); const ok = response.ok && responsePayload && responsePayload.success; if (!ok) { keep.push(payload); } } catch (error) { // Network down / timeout: keep this + the rest (will retry later). if (error && error.name === "AbortError") keep.push(payload); else keep.push(payload); for (let j = i + 1; j < toSend.length; j++) keep.push(toSend[j]); break; } finally { window.clearTimeout(timeoutId); } } try { // Write newest -> oldest back to storage. const keepNewestFirst = keep.slice().reverse().slice(0, 10); localStorage.setItem(SCOREBOARD_PENDING_KEY, JSON.stringify(keepNewestFirst)); } catch (error) { console.warn("Failed to update pending scoreboard saves", error); } } async function saveScoreToDatabase(run) { const payload = buildScoreboardPayload(run); try { const response = await fetch(SCOREBOARD_API_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, keepalive: true, body: JSON.stringify(payload) }); const responsePayload = await response.json(); if (!response.ok || !responsePayload.success) { throw new Error(responsePayload.error || 'Unable to save scoreboard entry.'); } const savedEntry = responsePayload.entry || { id: run.id, player_name: payload.player_name, score: payload.score, lines: payload.lines, level: payload.level, pieces_placed: payload.pieces_placed, duration_seconds: payload.duration_seconds, mode: payload.room_code ? 'multiplayer' : 'solo', room_code: payload.room_code, created_at: new Date().toISOString().slice(0, 19).replace('T', ' ') }; scoreboardEntries = [savedEntry, ...scoreboardEntries.filter((entry) => Number(entry.id) !== Number(savedEntry.id))].slice(0, 10); renderScoreboard(); if (responsePayload.placement) { showToast(`Run saved to the database scoreboard. Rank #${responsePayload.placement}.`); } else { showToast('Run saved to the database scoreboard.'); } void loadScoreboard({ preserveOnError: true }); } catch (error) { console.warn('Scoreboard save error', error); queuePendingScoreSave(payload); showToast('Run saved locally. It will sync to the database scoreboard when the connection returns.'); if (ui.scoreboardStatus) { ui.scoreboardStatus.textContent = 'Offline'; } } } function setRoomBadge(label, variant) { ui.roomStatusBadge.className = `badge status-badge state-${variant}`; ui.roomStatusBadge.textContent = label; } function updateOpponentUI(opponent) { if (!opponent) { ui.opponentName.textContent = 'Waiting for opponent…'; ui.opponentStats.textContent = 'Score — · Level — · Lines —'; ui.opponentStatus.textContent = 'Room idle'; drawOpponentBoard(null); return; } ui.opponentName.textContent = opponent.display_name || 'Opponent'; ui.opponentStats.textContent = `Score ${opponent.score.toLocaleString()} · Level ${opponent.level} · Lines ${opponent.lines}`; ui.opponentStatus.textContent = opponent.game_status === 'playing' ? 'In play' : (opponent.game_status === 'paused' ? 'Paused' : (opponent.game_status === 'game_over' ? 'Game over' : 'Ready')); drawOpponentBoard(opponent.board); } function updateMultiplayerUI() { const connected = Boolean(multiplayer.roomCode && multiplayer.token); ui.leaveRoomBtn.classList.toggle('d-none', !connected); ui.createRoomBtn.disabled = connected; ui.joinRoomBtn.disabled = connected; ui.roomCodeInput.disabled = connected; ui.roomCodeCard.classList.toggle('d-none', !connected); if (connected) { ui.roomCodeValue.textContent = multiplayer.roomCode; } else { ui.roomCodeValue.textContent = '—'; } if (!connected) { setRoomBadge('Offline', 'ready'); ui.roomStatusText.textContent = 'Create a room to start.'; updateOpponentUI(null); return; } if (multiplayer.playerCount < 2) { setRoomBadge('Waiting', 'paused'); ui.roomStatusText.textContent = 'Share the code so a friend can join.'; } else { setRoomBadge('Live', 'playing'); ui.roomStatusText.textContent = 'Opponent connected.'; } } function saveMultiplayerSession(roomCode, token, displayName, slot) { multiplayer.roomCode = roomCode; multiplayer.token = token; multiplayer.displayName = displayName; multiplayer.slot = slot; sessionStorage.setItem(MULTI_STORAGE_KEYS.room, roomCode); sessionStorage.setItem(MULTI_STORAGE_KEYS.token, token); sessionStorage.setItem(MULTI_STORAGE_KEYS.name, displayName); sessionStorage.setItem(MULTI_STORAGE_KEYS.slot, String(slot)); updateMultiplayerUI(); } function clearMultiplayerSession() { multiplayer.roomCode = null; multiplayer.token = null; multiplayer.displayName = ''; multiplayer.slot = null; multiplayer.playerCount = 0; multiplayer.opponent = null; sessionStorage.removeItem(MULTI_STORAGE_KEYS.room); sessionStorage.removeItem(MULTI_STORAGE_KEYS.token); sessionStorage.removeItem(MULTI_STORAGE_KEYS.name); sessionStorage.removeItem(MULTI_STORAGE_KEYS.slot); stopMultiplayerLoop(); updateMultiplayerUI(); } async function postMultiplayer(action, data) { const response = await fetch(MULTI_API_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action, ...data }) }); const payload = await response.json(); if (!response.ok || !payload.success) { throw new Error(payload.error || 'Multiplayer request failed.'); } return payload; } async function syncMultiplayer() { if (!multiplayer.roomCode || !multiplayer.token || multiplayer.isSyncing) return; multiplayer.isSyncing = true; try { await postMultiplayer('update_state', { room_code: multiplayer.roomCode, player_token: multiplayer.token, board: getComposedBoard(), piece: currentPiece ? { type: currentPiece.type } : null, meta: { piecesPlaced, updatedAt: Date.now() }, score, lines, level, game_status: getGameStatus() }); const state = await postMultiplayer('get_state', { room_code: multiplayer.roomCode, player_token: multiplayer.token }); multiplayer.playerCount = state.room?.player_count || 0; multiplayer.opponent = (state.players || []).find((player) => !player.is_self) || null; updateOpponentUI(multiplayer.opponent); updateMultiplayerUI(); } catch (error) { console.warn('Multiplayer sync error', error); ui.roomStatusText.textContent = 'Connection lost. Try rejoining.'; setRoomBadge('Error', 'game-over'); } finally { multiplayer.isSyncing = false; } } function startMultiplayerLoop() { stopMultiplayerLoop(); syncMultiplayer(); multiplayer.pollTimer = window.setInterval(syncMultiplayer, MULTI_POLL_INTERVAL); } function stopMultiplayerLoop() { if (multiplayer.pollTimer) { clearInterval(multiplayer.pollTimer); multiplayer.pollTimer = null; } } function resetState() { board = createBoard(); nextQueue = []; bag = []; score = 0; lines = 0; level = 1; piecesPlaced = 0; dropInterval = 900; dropCounter = 0; lastTime = 0; startedAt = Date.now(); endedAt = null; isRunning = true; isPaused = false; isGameOver = false; fillQueue(); spawnPiece(); hideOverlay(); setBadge('Playing', 'playing'); ui.pauseButton.textContent = 'Pause'; updateStats(); draw(); } function startGame() { if (gameOverModal) gameOverModal.hide(); resetState(); showToast('Game started. Good luck.'); syncMultiplayer(); } function pauseGame(showNotice = true) { if (!isRunning || isGameOver) return; isPaused = !isPaused; setBadge(isPaused ? 'Paused' : 'Playing', isPaused ? 'paused' : 'playing'); ui.pauseButton.textContent = isPaused ? 'Resume' : 'Pause'; ui.detailStatus.textContent = isPaused ? 'Paused' : 'In progress'; if (isPaused) { showOverlay('Paused', 'Press P to resume, or restart for a clean board.', 'Game paused'); } else { hideOverlay(); } if (showNotice) showToast(isPaused ? 'Game paused.' : 'Back in play.'); syncMultiplayer(); } function finishGame() { endedAt = Date.now(); isGameOver = true; isRunning = false; isPaused = false; setBadge('Game over', 'game-over'); ui.pauseButton.textContent = 'Pause'; showOverlay('Run complete', 'Restart to jump into a fresh round. This run was saved locally and is being submitted to the scoreboard.', 'Game over'); const elapsedMs = getElapsedMs(); const run = { id: `${Date.now()}`, score, lines, level, piecesPlaced, duration: formatDuration(elapsedMs), durationSeconds: Math.max(0, Math.round(elapsedMs / 1000)), finishedAt: endedAt }; saveHistory(run); ui.finalScore.textContent = score.toLocaleString(); ui.finalLines.textContent = lines.toString(); ui.finalLevel.textContent = level.toString(); ui.finalDuration.textContent = run.duration; updateStats(); draw(); showToast('Game over. Run saved locally.'); if (gameOverModal) gameOverModal.show(); void saveScoreToDatabase(run); syncMultiplayer(); } function draw() { drawBoard(); if (currentPiece && !isGameOver) { drawGhostPiece(); drawPiece(currentPiece); } else if (currentPiece && isGameOver) { drawPiece(currentPiece, 0.8); } } function update(time = 0) { const deltaTime = time - lastTime; lastTime = time; if (isRunning && !isPaused && !isGameOver) { dropCounter += deltaTime; if (dropCounter > dropInterval) { dropPiece(false); } } updateStats(); draw(); animationFrameId = requestAnimationFrame(update); } function handleKeydown(event) { const target = event.target; const isTypingTarget = Boolean(target && ( target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable )); if (isTypingTarget) { return; } const code = event.code; const gameKeys = ['ArrowLeft', 'ArrowRight', 'ArrowDown', 'ArrowUp', 'KeyX', 'KeyZ', 'Space', 'KeyP', 'KeyR', 'Enter']; if (gameKeys.includes(code)) { event.preventDefault(); } if ((code === 'Enter' && !isRunning) || (code === 'KeyR')) { startGame(); return; } if (!isRunning || isGameOver) { return; } switch (code) { case 'ArrowLeft': movePiece(-1); break; case 'ArrowRight': movePiece(1); break; case 'ArrowDown': dropPiece(true); break; case 'ArrowUp': case 'KeyX': attemptRotate(1); break; case 'KeyZ': attemptRotate(-1); break; case 'Space': hardDrop(); break; case 'KeyP': pauseGame(true); break; default: break; } } safeOn(ui.playerNameInput, 'change', () => { const name = (ui.playerNameInput.value || '').trim(); if (name) { sessionStorage.setItem(MULTI_STORAGE_KEYS.name, name); } }); safeOn(ui.createRoomBtn, 'click', async () => { if (multiplayer.roomCode) return; const displayName = (ui.playerNameInput.value || '').trim() || 'Player 1'; try { const result = await postMultiplayer('create_room', { display_name: displayName }); saveMultiplayerSession(result.room.code, result.self.token, result.self.display_name, result.self.slot); startMultiplayerLoop(); showToast(`Room ${result.room.code} created. Share the code.`); } catch (error) { showToast(error.message || 'Unable to create room.'); } }); safeOn(ui.joinRoomBtn, 'click', async () => { if (multiplayer.roomCode) return; const roomCode = (ui.roomCodeInput.value || '').trim(); if (!roomCode) { showToast('Enter a room code to join.'); return; } const displayName = (ui.playerNameInput.value || '').trim() || 'Player 2'; try { const result = await postMultiplayer('join_room', { room_code: roomCode, display_name: displayName }); saveMultiplayerSession(result.room.code, result.self.token, result.self.display_name, result.self.slot); startMultiplayerLoop(); showToast(`Joined room ${result.room.code}.`); } catch (error) { showToast(error.message || 'Unable to join room.'); } }); safeOn(ui.leaveRoomBtn, 'click', async () => { if (!multiplayer.roomCode || !multiplayer.token) return; try { await postMultiplayer('leave_room', { room_code: multiplayer.roomCode, player_token: multiplayer.token }); } catch (error) { console.warn('Leave room error', error); } finally { clearMultiplayerSession(); showToast('Left the room.'); } }); safeOn(ui.copyRoomBtn, 'click', async () => { if (!multiplayer.roomCode) return; try { await navigator.clipboard.writeText(multiplayer.roomCode); showToast('Room code copied.'); } catch (error) { showToast('Copy failed. Select and copy the code manually.'); } }); safeOn(ui.pauseButton, 'click', () => { if (!isRunning || isGameOver) { showToast('Start a run before using pause.'); return; } pauseGame(true); }); safeOn(ui.restartButton, 'click', startGame); safeOn(ui.modalRestartButton, 'click', startGame); document.addEventListener('keydown', handleKeydown); if (gameOverModalEl) { gameOverModalEl.addEventListener('hidden.bs.modal', () => { if (isGameOver) { boardCanvas.focus?.(); } }); } const savedName = sessionStorage.getItem(MULTI_STORAGE_KEYS.name); if (savedName && ui.playerNameInput) { ui.playerNameInput.value = savedName; } const savedRoom = sessionStorage.getItem(MULTI_STORAGE_KEYS.room); const savedToken = sessionStorage.getItem(MULTI_STORAGE_KEYS.token); const savedSlot = sessionStorage.getItem(MULTI_STORAGE_KEYS.slot); if (savedRoom && savedToken) { saveMultiplayerSession(savedRoom, savedToken, savedName || 'Player', Number(savedSlot || 0)); startMultiplayerLoop(); } renderHistory(); void loadScoreboard(); void flushPendingScoreSaves(); updateStats(); updateMultiplayerUI(); showOverlay('Stack clean. Clear fast.', 'Use the keyboard to play a faithful Tetris loop with local history and a database scoreboard.', 'Press Enter to start'); setBadge('Ready', 'ready'); update(); });