diff --git a/api/multiplayer.php b/api/multiplayer.php
new file mode 100644
index 0000000..10f26d0
--- /dev/null
+++ b/api/multiplayer.php
@@ -0,0 +1,493 @@
+ false, 'error' => 'Missing action.'], 400);
+ }
+
+ switch ($action) {
+ case 'create_room':
+ ensurePostRequest();
+ createRoom($payload);
+ break;
+
+ case 'join_room':
+ ensurePostRequest();
+ joinRoom($payload);
+ break;
+
+ case 'update_state':
+ ensurePostRequest();
+ updatePlayerState($payload);
+ break;
+
+ case 'get_state':
+ getRoomState($payload);
+ break;
+
+ case 'leave_room':
+ ensurePostRequest();
+ leaveRoom($payload);
+ break;
+
+ default:
+ jsonResponse(['success' => false, 'error' => 'Unsupported action.'], 400);
+ }
+} catch (Throwable $exception) {
+ error_log('Multiplayer API error: ' . $exception->getMessage());
+ jsonResponse(['success' => false, 'error' => 'Internal server error.'], 500);
+}
+
+function getRequestPayload(): array
+{
+ $rawBody = file_get_contents('php://input');
+ $jsonBody = json_decode($rawBody ?: '', true);
+
+ if (is_array($jsonBody)) {
+ return $jsonBody;
+ }
+
+ if (!empty($_POST)) {
+ return $_POST;
+ }
+
+ return $_GET;
+}
+
+function ensurePostRequest(): void
+{
+ if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
+ jsonResponse(['success' => false, 'error' => 'POST required.'], 405);
+ }
+}
+
+function jsonResponse(array $payload, int $statusCode = 200): void
+{
+ http_response_code($statusCode);
+ echo json_encode($payload, JSON_UNESCAPED_SLASHES);
+ exit;
+}
+
+function normalizeDisplayName(?string $displayName, int $slot = 0): string
+{
+ $name = trim((string) $displayName);
+ $name = preg_replace('/\s+/', ' ', $name ?? '') ?? '';
+
+ if ($name === '') {
+ return $slot > 0 ? 'Player ' . $slot : 'Player';
+ }
+
+ $name = function_exists('mb_substr') ? mb_substr($name, 0, 48) : substr($name, 0, 48);
+ $name = strip_tags($name);
+ return trim($name) !== '' ? $name : ($slot > 0 ? 'Player ' . $slot : 'Player');
+}
+
+function normalizeRoomCode(?string $roomCode): string
+{
+ return strtoupper(preg_replace('/[^A-Z0-9]/', '', (string) $roomCode) ?? '');
+}
+
+function generateRoomCode(PDO $pdo): string
+{
+ $alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
+ $checkStmt = $pdo->prepare('SELECT 1 FROM tetris_rooms WHERE room_code = :room_code LIMIT 1');
+
+ for ($attempt = 0; $attempt < 12; $attempt++) {
+ $code = '';
+ for ($i = 0; $i < 6; $i++) {
+ $code .= $alphabet[random_int(0, strlen($alphabet) - 1)];
+ }
+
+ $checkStmt->bindValue(':room_code', $code, PDO::PARAM_STR);
+ $checkStmt->execute();
+ if (!$checkStmt->fetchColumn()) {
+ return $code;
+ }
+ }
+
+ throw new RuntimeException('Could not allocate a unique room code.');
+}
+
+function generatePlayerToken(): string
+{
+ return bin2hex(random_bytes(24));
+}
+
+function cleanupMultiplayerState(): void
+{
+ $pdo = db();
+ $pdo->exec("UPDATE tetris_room_players SET connection_status = 'disconnected' WHERE last_seen_at < (UTC_TIMESTAMP() - INTERVAL 2 MINUTE)");
+ $pdo->exec("UPDATE tetris_rooms SET status = 'closed' WHERE updated_at < (UTC_TIMESTAMP() - INTERVAL 12 HOUR)");
+}
+
+function createRoom(array $payload): void
+{
+ $pdo = db();
+ $roomCode = generateRoomCode($pdo);
+ $playerToken = generatePlayerToken();
+ $displayName = normalizeDisplayName((string) ($payload['display_name'] ?? ''), 1);
+
+ $pdo->beginTransaction();
+
+ try {
+ $insertRoom = $pdo->prepare('INSERT INTO tetris_rooms (room_code, status, expires_at) VALUES (:room_code, :status, (UTC_TIMESTAMP() + INTERVAL 12 HOUR))');
+ $insertRoom->bindValue(':room_code', $roomCode, PDO::PARAM_STR);
+ $insertRoom->bindValue(':status', 'waiting', PDO::PARAM_STR);
+ $insertRoom->execute();
+
+ $roomId = (int) $pdo->lastInsertId();
+
+ $insertPlayer = $pdo->prepare(
+ 'INSERT INTO tetris_room_players (room_id, player_token, player_slot, display_name, connection_status, game_status, last_seen_at) '
+ . 'VALUES (:room_id, :player_token, :player_slot, :display_name, :connection_status, :game_status, UTC_TIMESTAMP())'
+ );
+ $insertPlayer->bindValue(':room_id', $roomId, PDO::PARAM_INT);
+ $insertPlayer->bindValue(':player_token', $playerToken, PDO::PARAM_STR);
+ $insertPlayer->bindValue(':player_slot', 1, PDO::PARAM_INT);
+ $insertPlayer->bindValue(':display_name', $displayName, PDO::PARAM_STR);
+ $insertPlayer->bindValue(':connection_status', 'connected', PDO::PARAM_STR);
+ $insertPlayer->bindValue(':game_status', 'ready', PDO::PARAM_STR);
+ $insertPlayer->execute();
+
+ $playerId = (int) $pdo->lastInsertId();
+
+ $updateRoom = $pdo->prepare('UPDATE tetris_rooms SET host_player_id = :host_player_id WHERE id = :room_id');
+ $updateRoom->bindValue(':host_player_id', $playerId, PDO::PARAM_INT);
+ $updateRoom->bindValue(':room_id', $roomId, PDO::PARAM_INT);
+ $updateRoom->execute();
+
+ $pdo->commit();
+ } catch (Throwable $exception) {
+ if ($pdo->inTransaction()) {
+ $pdo->rollBack();
+ }
+ throw $exception;
+ }
+
+ jsonResponse([
+ 'success' => true,
+ 'room' => [
+ 'code' => $roomCode,
+ 'status' => 'waiting',
+ 'player_count' => 1,
+ ],
+ 'self' => [
+ 'token' => $playerToken,
+ 'slot' => 1,
+ 'display_name' => $displayName,
+ ],
+ ]);
+}
+
+function joinRoom(array $payload): void
+{
+ $roomCode = normalizeRoomCode((string) ($payload['room_code'] ?? ''));
+ if ($roomCode === '') {
+ jsonResponse(['success' => false, 'error' => 'Room code is required.'], 422);
+ }
+
+ $pdo = db();
+ $pdo->beginTransaction();
+
+ try {
+ $roomStmt = $pdo->prepare('SELECT * FROM tetris_rooms WHERE room_code = :room_code LIMIT 1 FOR UPDATE');
+ $roomStmt->bindValue(':room_code', $roomCode, PDO::PARAM_STR);
+ $roomStmt->execute();
+ $room = $roomStmt->fetch();
+
+ if (!$room || $room['status'] === 'closed') {
+ if ($pdo->inTransaction()) {
+ $pdo->rollBack();
+ }
+ jsonResponse(['success' => false, 'error' => 'Room not found.'], 404);
+ }
+
+ $countStmt = $pdo->prepare('SELECT COUNT(*) FROM tetris_room_players WHERE room_id = :room_id');
+ $countStmt->bindValue(':room_id', (int) $room['id'], PDO::PARAM_INT);
+ $countStmt->execute();
+ $playerCount = (int) $countStmt->fetchColumn();
+
+ if ($playerCount >= 2) {
+ if ($pdo->inTransaction()) {
+ $pdo->rollBack();
+ }
+ jsonResponse(['success' => false, 'error' => 'Room is full.'], 409);
+ }
+
+ $playerToken = generatePlayerToken();
+ $displayName = normalizeDisplayName((string) ($payload['display_name'] ?? ''), 2);
+
+ $insertPlayer = $pdo->prepare(
+ 'INSERT INTO tetris_room_players (room_id, player_token, player_slot, display_name, connection_status, game_status, last_seen_at) '
+ . 'VALUES (:room_id, :player_token, :player_slot, :display_name, :connection_status, :game_status, UTC_TIMESTAMP())'
+ );
+ $insertPlayer->bindValue(':room_id', (int) $room['id'], PDO::PARAM_INT);
+ $insertPlayer->bindValue(':player_token', $playerToken, PDO::PARAM_STR);
+ $insertPlayer->bindValue(':player_slot', 2, PDO::PARAM_INT);
+ $insertPlayer->bindValue(':display_name', $displayName, PDO::PARAM_STR);
+ $insertPlayer->bindValue(':connection_status', 'connected', PDO::PARAM_STR);
+ $insertPlayer->bindValue(':game_status', 'ready', PDO::PARAM_STR);
+ $insertPlayer->execute();
+
+ $updateRoom = $pdo->prepare('UPDATE tetris_rooms SET status = :status, expires_at = (UTC_TIMESTAMP() + INTERVAL 12 HOUR) WHERE id = :room_id');
+ $updateRoom->bindValue(':status', 'active', PDO::PARAM_STR);
+ $updateRoom->bindValue(':room_id', (int) $room['id'], PDO::PARAM_INT);
+ $updateRoom->execute();
+
+ $pdo->commit();
+ } catch (Throwable $exception) {
+ if ($pdo->inTransaction()) {
+ $pdo->rollBack();
+ }
+ throw $exception;
+ }
+
+ jsonResponse([
+ 'success' => true,
+ 'room' => [
+ 'code' => $roomCode,
+ 'status' => 'active',
+ 'player_count' => 2,
+ ],
+ 'self' => [
+ 'token' => $playerToken,
+ 'slot' => 2,
+ 'display_name' => $displayName,
+ ],
+ ]);
+}
+
+function updatePlayerState(array $payload): void
+{
+ $roomCode = normalizeRoomCode((string) ($payload['room_code'] ?? ''));
+ $playerToken = trim((string) ($payload['player_token'] ?? ''));
+
+ if ($roomCode === '' || $playerToken === '') {
+ jsonResponse(['success' => false, 'error' => 'Room code and player token are required.'], 422);
+ }
+
+ $boardStateJson = encodeNullableJson($payload['board'] ?? null);
+ $pieceStateJson = encodeNullableJson($payload['piece'] ?? null);
+ $metaJson = encodeNullableJson($payload['meta'] ?? null);
+
+ $score = max(0, (int) ($payload['score'] ?? 0));
+ $linesCleared = max(0, (int) ($payload['lines'] ?? 0));
+ $level = max(1, (int) ($payload['level'] ?? 1));
+ $gameStatus = normalizeGameStatus((string) ($payload['game_status'] ?? 'ready'));
+
+ $pdo = db();
+ $player = fetchPlayerForRoom($pdo, $roomCode, $playerToken);
+ if (!$player) {
+ jsonResponse(['success' => false, 'error' => 'Player or room not found.'], 404);
+ }
+
+ $updatePlayer = $pdo->prepare(
+ 'UPDATE tetris_room_players SET board_state_json = :board_state_json, piece_state_json = :piece_state_json, meta_json = :meta_json, '
+ . 'score = :score, lines_cleared = :lines_cleared, level = :level, game_status = :game_status, connection_status = :connection_status, '
+ . 'last_seen_at = UTC_TIMESTAMP() WHERE id = :player_id'
+ );
+ $updatePlayer->bindValue(':board_state_json', $boardStateJson, $boardStateJson === null ? PDO::PARAM_NULL : PDO::PARAM_STR);
+ $updatePlayer->bindValue(':piece_state_json', $pieceStateJson, $pieceStateJson === null ? PDO::PARAM_NULL : PDO::PARAM_STR);
+ $updatePlayer->bindValue(':meta_json', $metaJson, $metaJson === null ? PDO::PARAM_NULL : PDO::PARAM_STR);
+ $updatePlayer->bindValue(':score', $score, PDO::PARAM_INT);
+ $updatePlayer->bindValue(':lines_cleared', $linesCleared, PDO::PARAM_INT);
+ $updatePlayer->bindValue(':level', $level, PDO::PARAM_INT);
+ $updatePlayer->bindValue(':game_status', $gameStatus, PDO::PARAM_STR);
+ $updatePlayer->bindValue(':connection_status', 'connected', PDO::PARAM_STR);
+ $updatePlayer->bindValue(':player_id', (int) $player['player_id'], PDO::PARAM_INT);
+ $updatePlayer->execute();
+
+ $roomStatus = 'active';
+ $updateRoom = $pdo->prepare('UPDATE tetris_rooms SET status = :status, expires_at = (UTC_TIMESTAMP() + INTERVAL 12 HOUR) WHERE id = :room_id');
+ $updateRoom->bindValue(':status', $roomStatus, PDO::PARAM_STR);
+ $updateRoom->bindValue(':room_id', (int) $player['room_id'], PDO::PARAM_INT);
+ $updateRoom->execute();
+
+ jsonResponse([
+ 'success' => true,
+ 'room' => [
+ 'code' => $roomCode,
+ 'status' => $roomStatus,
+ 'updated_at' => gmdate('c'),
+ ],
+ ]);
+}
+
+function getRoomState(array $payload): void
+{
+ $roomCode = normalizeRoomCode((string) ($payload['room_code'] ?? ''));
+ $playerToken = trim((string) ($payload['player_token'] ?? ''));
+
+ if ($roomCode === '' || $playerToken === '') {
+ jsonResponse(['success' => false, 'error' => 'Room code and player token are required.'], 422);
+ }
+
+ $pdo = db();
+ $self = fetchPlayerForRoom($pdo, $roomCode, $playerToken);
+ if (!$self) {
+ jsonResponse(['success' => false, 'error' => 'Player or room not found.'], 404);
+ }
+
+ $touchStmt = $pdo->prepare('UPDATE tetris_room_players SET last_seen_at = UTC_TIMESTAMP(), connection_status = :connection_status WHERE id = :player_id');
+ $touchStmt->bindValue(':connection_status', 'connected', PDO::PARAM_STR);
+ $touchStmt->bindValue(':player_id', (int) $self['player_id'], PDO::PARAM_INT);
+ $touchStmt->execute();
+
+ $playersStmt = $pdo->prepare(
+ 'SELECT p.id, p.player_slot, p.display_name, p.connection_status, p.game_status, p.score, p.lines_cleared, p.level, '
+ . 'p.board_state_json, p.piece_state_json, p.meta_json, p.last_seen_at, r.room_code, r.status AS room_status, r.updated_at AS room_updated_at '
+ . 'FROM tetris_room_players p '
+ . 'INNER JOIN tetris_rooms r ON r.id = p.room_id '
+ . 'WHERE r.id = :room_id '
+ . 'ORDER BY p.player_slot ASC'
+ );
+ $playersStmt->bindValue(':room_id', (int) $self['room_id'], PDO::PARAM_INT);
+ $playersStmt->execute();
+ $rows = $playersStmt->fetchAll();
+
+ $players = [];
+ foreach ($rows as $row) {
+ $players[] = [
+ 'slot' => (int) $row['player_slot'],
+ 'display_name' => (string) $row['display_name'],
+ 'connection_status' => (string) $row['connection_status'],
+ 'game_status' => (string) $row['game_status'],
+ 'score' => (int) $row['score'],
+ 'lines' => (int) $row['lines_cleared'],
+ 'level' => (int) $row['level'],
+ 'board' => decodeNullableJson($row['board_state_json'] ?? null),
+ 'piece' => decodeNullableJson($row['piece_state_json'] ?? null),
+ 'meta' => decodeNullableJson($row['meta_json'] ?? null),
+ 'last_seen_at' => (string) $row['last_seen_at'],
+ 'is_self' => ((int) ($row['player_slot'] ?? 0)) === ((int) $self['player_slot']),
+ ];
+ }
+
+ $playerCount = count($players);
+ $connectedCount = 0;
+ foreach ($players as $player) {
+ if ($player['connection_status'] === 'connected') {
+ $connectedCount++;
+ }
+ }
+
+ $roomStatus = $playerCount >= 2 ? 'active' : 'waiting';
+ if ($connectedCount === 0) {
+ $roomStatus = 'closed';
+ }
+
+ $roomUpdateStmt = $pdo->prepare('UPDATE tetris_rooms SET status = :status, expires_at = (UTC_TIMESTAMP() + INTERVAL 12 HOUR) WHERE id = :room_id');
+ $roomUpdateStmt->bindValue(':status', $roomStatus, PDO::PARAM_STR);
+ $roomUpdateStmt->bindValue(':room_id', (int) $self['room_id'], PDO::PARAM_INT);
+ $roomUpdateStmt->execute();
+
+ jsonResponse([
+ 'success' => true,
+ 'room' => [
+ 'code' => $roomCode,
+ 'status' => $roomStatus,
+ 'player_count' => $playerCount,
+ ],
+ 'self' => [
+ 'slot' => (int) $self['player_slot'],
+ 'display_name' => (string) $self['display_name'],
+ ],
+ 'players' => $players,
+ ]);
+}
+
+function leaveRoom(array $payload): void
+{
+ $roomCode = normalizeRoomCode((string) ($payload['room_code'] ?? ''));
+ $playerToken = trim((string) ($payload['player_token'] ?? ''));
+
+ if ($roomCode === '' || $playerToken === '') {
+ jsonResponse(['success' => false, 'error' => 'Room code and player token are required.'], 422);
+ }
+
+ $pdo = db();
+ $player = fetchPlayerForRoom($pdo, $roomCode, $playerToken);
+ if (!$player) {
+ jsonResponse(['success' => false, 'error' => 'Player or room not found.'], 404);
+ }
+
+ $disconnectStmt = $pdo->prepare('UPDATE tetris_room_players SET connection_status = :connection_status, game_status = :game_status, last_seen_at = UTC_TIMESTAMP() WHERE id = :player_id');
+ $disconnectStmt->bindValue(':connection_status', 'disconnected', PDO::PARAM_STR);
+ $disconnectStmt->bindValue(':game_status', 'paused', PDO::PARAM_STR);
+ $disconnectStmt->bindValue(':player_id', (int) $player['player_id'], PDO::PARAM_INT);
+ $disconnectStmt->execute();
+
+ $countConnectedStmt = $pdo->prepare('SELECT COUNT(*) FROM tetris_room_players WHERE room_id = :room_id AND connection_status = :connection_status');
+ $countConnectedStmt->bindValue(':room_id', (int) $player['room_id'], PDO::PARAM_INT);
+ $countConnectedStmt->bindValue(':connection_status', 'connected', PDO::PARAM_STR);
+ $countConnectedStmt->execute();
+ $connectedCount = (int) $countConnectedStmt->fetchColumn();
+
+ $roomStatus = $connectedCount > 0 ? 'active' : 'closed';
+ $updateRoom = $pdo->prepare('UPDATE tetris_rooms SET status = :status WHERE id = :room_id');
+ $updateRoom->bindValue(':status', $roomStatus, PDO::PARAM_STR);
+ $updateRoom->bindValue(':room_id', (int) $player['room_id'], PDO::PARAM_INT);
+ $updateRoom->execute();
+
+ jsonResponse([
+ 'success' => true,
+ 'room' => [
+ 'code' => $roomCode,
+ 'status' => $roomStatus,
+ ],
+ ]);
+}
+
+function fetchPlayerForRoom(PDO $pdo, string $roomCode, string $playerToken): ?array
+{
+ $stmt = $pdo->prepare(
+ 'SELECT p.id AS player_id, p.player_slot, p.display_name, p.room_id '
+ . 'FROM tetris_room_players p '
+ . 'INNER JOIN tetris_rooms r ON r.id = p.room_id '
+ . 'WHERE r.room_code = :room_code AND p.player_token = :player_token LIMIT 1'
+ );
+ $stmt->bindValue(':room_code', $roomCode, PDO::PARAM_STR);
+ $stmt->bindValue(':player_token', $playerToken, PDO::PARAM_STR);
+ $stmt->execute();
+ $row = $stmt->fetch();
+
+ return is_array($row) ? $row : null;
+}
+
+function encodeNullableJson($value): ?string
+{
+ if ($value === null || $value === '') {
+ return null;
+ }
+
+ return json_encode($value, JSON_UNESCAPED_SLASHES);
+}
+
+function decodeNullableJson($value)
+{
+ if ($value === null || $value === '') {
+ return null;
+ }
+
+ return json_decode((string) $value, true);
+}
+
+function normalizeGameStatus(string $gameStatus): string
+{
+ $normalized = strtolower(trim($gameStatus));
+ $allowed = ['ready', 'playing', 'paused', 'game_over'];
+
+ return in_array($normalized, $allowed, true) ? $normalized : 'ready';
+}
diff --git a/api/scoreboard.php b/api/scoreboard.php
new file mode 100644
index 0000000..549d822
--- /dev/null
+++ b/api/scoreboard.php
@@ -0,0 +1,173 @@
+ false, 'error' => 'Method not allowed.'], 405);
+ }
+
+ $raw = file_get_contents('php://input');
+ $payload = json_decode($raw ?: '[]', true);
+ if (!is_array($payload)) {
+ jsonResponse(['success' => false, 'error' => 'Invalid JSON payload.'], 400);
+ }
+
+ $action = (string) ($payload['action'] ?? '');
+ if ($action === 'save_score') {
+ saveScore($payload);
+ }
+
+ if ($action === 'list_scores') {
+ listScores((int) ($payload['limit'] ?? 10));
+ }
+
+ jsonResponse(['success' => false, 'error' => 'Unknown action.'], 422);
+} catch (Throwable $exception) {
+ error_log('Scoreboard API error: ' . $exception->getMessage());
+ jsonResponse(['success' => false, 'error' => 'Scoreboard is temporarily unavailable.'], 500);
+}
+
+function saveScore(array $payload): void
+{
+ $playerName = normalizePlayerName((string) ($payload['player_name'] ?? ''));
+ $score = max(0, (int) ($payload['score'] ?? 0));
+ $linesCleared = max(0, (int) ($payload['lines'] ?? 0));
+ $level = max(1, (int) ($payload['level'] ?? 1));
+ $piecesPlaced = max(0, (int) ($payload['pieces_placed'] ?? 0));
+ $durationSeconds = max(0, (int) ($payload['duration_seconds'] ?? 0));
+ $roomCode = normalizeRoomCode((string) ($payload['room_code'] ?? ''));
+ $mode = $roomCode !== '' ? 'multiplayer' : 'solo';
+
+ $pdo = db();
+ $insert = $pdo->prepare(
+ 'INSERT INTO tetris_scores (player_name, score, lines_cleared, level, pieces_placed, duration_seconds, mode, room_code) '
+ . 'VALUES (:player_name, :score, :lines_cleared, :level, :pieces_placed, :duration_seconds, :mode, :room_code)'
+ );
+ $insert->bindValue(':player_name', $playerName, PDO::PARAM_STR);
+ $insert->bindValue(':score', $score, PDO::PARAM_INT);
+ $insert->bindValue(':lines_cleared', $linesCleared, PDO::PARAM_INT);
+ $insert->bindValue(':level', $level, PDO::PARAM_INT);
+ $insert->bindValue(':pieces_placed', $piecesPlaced, PDO::PARAM_INT);
+ $insert->bindValue(':duration_seconds', $durationSeconds, PDO::PARAM_INT);
+ $insert->bindValue(':mode', $mode, PDO::PARAM_STR);
+ if ($roomCode === '') {
+ $insert->bindValue(':room_code', null, PDO::PARAM_NULL);
+ } else {
+ $insert->bindValue(':room_code', $roomCode, PDO::PARAM_STR);
+ }
+ $insert->execute();
+
+ $scoreId = (int) $pdo->lastInsertId();
+
+ $placementQuery = $pdo->prepare(
+ 'SELECT COUNT(*) FROM tetris_scores WHERE '
+ . 'score > :score '
+ . 'OR (score = :score AND lines_cleared > :lines_cleared) '
+ . 'OR (score = :score AND lines_cleared = :lines_cleared AND level > :level) '
+ . 'OR (score = :score AND lines_cleared = :lines_cleared AND level = :level AND duration_seconds < :duration_seconds) '
+ . 'OR (score = :score AND lines_cleared = :lines_cleared AND level = :level AND duration_seconds = :duration_seconds AND id < :score_id)'
+ );
+ $placementQuery->bindValue(':score', $score, PDO::PARAM_INT);
+ $placementQuery->bindValue(':lines_cleared', $linesCleared, PDO::PARAM_INT);
+ $placementQuery->bindValue(':level', $level, PDO::PARAM_INT);
+ $placementQuery->bindValue(':duration_seconds', $durationSeconds, PDO::PARAM_INT);
+ $placementQuery->bindValue(':score_id', $scoreId, PDO::PARAM_INT);
+ $placementQuery->execute();
+ $placement = ((int) $placementQuery->fetchColumn()) + 1;
+
+ jsonResponse([
+ 'success' => true,
+ 'entry' => [
+ 'id' => $scoreId,
+ 'player_name' => $playerName,
+ 'score' => $score,
+ 'lines' => $linesCleared,
+ 'level' => $level,
+ 'pieces_placed' => $piecesPlaced,
+ 'duration_seconds' => $durationSeconds,
+ 'mode' => $mode,
+ 'room_code' => $roomCode !== '' ? $roomCode : null,
+ ],
+ 'placement' => $placement,
+ ]);
+}
+
+function listScores(int $limit = 10): void
+{
+ $limit = max(1, min(25, $limit));
+
+ $pdo = db();
+ $stmt = $pdo->prepare(
+ 'SELECT id, player_name, score, lines_cleared, level, pieces_placed, duration_seconds, mode, room_code, created_at '
+ . 'FROM tetris_scores '
+ . 'ORDER BY score DESC, lines_cleared DESC, level DESC, duration_seconds ASC, id ASC '
+ . 'LIMIT :limit'
+ );
+ $stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
+ $stmt->execute();
+
+ $scores = [];
+ foreach ($stmt->fetchAll() as $row) {
+ $scores[] = [
+ 'id' => (int) $row['id'],
+ 'player_name' => (string) $row['player_name'],
+ 'score' => (int) $row['score'],
+ 'lines' => (int) $row['lines_cleared'],
+ 'level' => (int) $row['level'],
+ 'pieces_placed' => (int) $row['pieces_placed'],
+ 'duration_seconds' => (int) $row['duration_seconds'],
+ 'mode' => (string) $row['mode'],
+ 'room_code' => $row['room_code'] !== null ? (string) $row['room_code'] : null,
+ 'created_at' => (string) $row['created_at'],
+ ];
+ }
+
+ jsonResponse([
+ 'success' => true,
+ 'scores' => $scores,
+ 'limit' => $limit,
+ ]);
+}
+
+function normalizePlayerName(string $value): string
+{
+ $value = trim(preg_replace('/\s+/', ' ', $value) ?? '');
+ $value = preg_replace('/[^\p{L}\p{N} ._\-]/u', '', $value) ?? '';
+ if ($value === '') {
+ return 'Player';
+ }
+
+ if (function_exists('mb_substr')) {
+ return mb_substr($value, 0, 48);
+ }
+
+ return substr($value, 0, 48);
+}
+
+function normalizeRoomCode(string $value): string
+{
+ $value = strtoupper(trim($value));
+ $value = preg_replace('/[^A-Z0-9]/', '', $value) ?? '';
+ return substr($value, 0, 8);
+}
+
+function jsonResponse(array $payload, int $status = 200): void
+{
+ http_response_code($status);
+ echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+ exit;
+}
diff --git a/assets/css/custom.css b/assets/css/custom.css
index 789132e..6a3f93f 100644
--- a/assets/css/custom.css
+++ b/assets/css/custom.css
@@ -1,403 +1,576 @@
+:root {
+ --bg: #0b0d12;
+ --bg-elevated: #12161d;
+ --surface: #161b22;
+ --surface-strong: #1d242d;
+ --surface-soft: #11161d;
+ --border: #27303a;
+ --border-strong: #37424f;
+ --text: #edf1f5;
+ --muted: #98a2ad;
+ --accent: #d2b062;
+ --accent-soft: rgba(210, 176, 98, 0.12);
+ --success: #79c08f;
+ --danger: #e37979;
+ --shadow: 0 24px 60px rgba(0, 0, 0, 0.38);
+ --radius-sm: 8px;
+ --radius-md: 12px;
+ --radius-lg: 16px;
+ --spacing-1: 0.25rem;
+ --spacing-2: 0.5rem;
+ --spacing-3: 0.75rem;
+ --spacing-4: 1rem;
+ --spacing-5: 1.5rem;
+ --spacing-6: 2rem;
+}
+
+html,
body {
- background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab);
- background-size: 400% 400%;
- animation: gradient 15s ease infinite;
- color: #212529;
- font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
- font-size: 14px;
+ min-height: 100%;
+}
+
+body.tetris-body {
margin: 0;
+ background-color: var(--bg);
+ color: var(--text);
+ font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ letter-spacing: -0.01em;
+}
+
+::selection {
+ background: rgba(210, 176, 98, 0.24);
+}
+
+.page-shell {
min-height: 100vh;
}
-.main-wrapper {
- display: flex;
- align-items: center;
- justify-content: center;
- min-height: 100vh;
- width: 100%;
- padding: 20px;
- box-sizing: border-box;
- position: relative;
- z-index: 1;
-}
-
-@keyframes gradient {
- 0% {
- background-position: 0% 50%;
- }
- 50% {
- background-position: 100% 50%;
- }
- 100% {
- background-position: 0% 50%;
- }
-}
-
-.chat-container {
- width: 100%;
- max-width: 600px;
- background: rgba(255, 255, 255, 0.85);
- border: 1px solid rgba(255, 255, 255, 0.3);
- border-radius: 20px;
- display: flex;
- flex-direction: column;
- height: 85vh;
- box-shadow: 0 20px 40px rgba(0,0,0,0.2);
- backdrop-filter: blur(15px);
- -webkit-backdrop-filter: blur(15px);
- overflow: hidden;
-}
-
-.chat-header {
- padding: 1.5rem;
- border-bottom: 1px solid rgba(0, 0, 0, 0.05);
- background: rgba(255, 255, 255, 0.5);
- font-weight: 700;
- font-size: 1.1rem;
- display: flex;
- justify-content: space-between;
- align-items: center;
-}
-
-.chat-messages {
- flex: 1;
- overflow-y: auto;
- padding: 1.5rem;
- display: flex;
- flex-direction: column;
- gap: 1.25rem;
-}
-
-/* Custom Scrollbar */
-::-webkit-scrollbar {
- width: 6px;
-}
-
-::-webkit-scrollbar-track {
- background: transparent;
-}
-
-::-webkit-scrollbar-thumb {
- background: rgba(255, 255, 255, 0.3);
- border-radius: 10px;
-}
-
-::-webkit-scrollbar-thumb:hover {
- background: rgba(255, 255, 255, 0.5);
-}
-
-.message {
- max-width: 85%;
- padding: 0.85rem 1.1rem;
- border-radius: 16px;
- line-height: 1.5;
- font-size: 0.95rem;
- box-shadow: 0 4px 15px rgba(0,0,0,0.05);
- animation: fadeIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
-}
-
-@keyframes fadeIn {
- from { opacity: 0; transform: translateY(20px) scale(0.95); }
- to { opacity: 1; transform: translateY(0) scale(1); }
-}
-
-.message.visitor {
- align-self: flex-end;
- background: linear-gradient(135deg, #212529 0%, #343a40 100%);
- color: #fff;
- border-bottom-right-radius: 4px;
-}
-
-.message.bot {
- align-self: flex-start;
- background: #ffffff;
- color: #212529;
- border-bottom-left-radius: 4px;
-}
-
-.chat-input-area {
- padding: 1.25rem;
- background: rgba(255, 255, 255, 0.5);
- border-top: 1px solid rgba(0, 0, 0, 0.05);
-}
-
-.chat-input-area form {
- display: flex;
- gap: 0.75rem;
-}
-
-.chat-input-area input {
- flex: 1;
- border: 1px solid rgba(0, 0, 0, 0.1);
- border-radius: 12px;
- padding: 0.75rem 1rem;
- outline: none;
- background: rgba(255, 255, 255, 0.9);
- transition: all 0.3s ease;
-}
-
-.chat-input-area input:focus {
- border-color: #23a6d5;
- box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.2);
-}
-
-.chat-input-area button {
- background: #212529;
- color: #fff;
- border: none;
- padding: 0.75rem 1.5rem;
- border-radius: 12px;
- cursor: pointer;
- font-weight: 600;
- transition: all 0.3s ease;
-}
-
-.chat-input-area button:hover {
- background: #000;
- transform: translateY(-2px);
- box-shadow: 0 5px 15px rgba(0,0,0,0.2);
-}
-
-/* Background Animations */
-.bg-animations {
- position: fixed;
+.app-header {
+ position: sticky;
top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- z-index: 0;
- overflow: hidden;
- pointer-events: none;
+ z-index: 20;
+ backdrop-filter: blur(14px);
+ background: rgba(11, 13, 18, 0.92);
}
-.blob {
- position: absolute;
- width: 500px;
- height: 500px;
- background: rgba(255, 255, 255, 0.2);
- border-radius: 50%;
- filter: blur(80px);
- animation: move 20s infinite alternate cubic-bezier(0.45, 0, 0.55, 1);
-}
-
-.blob-1 {
- top: -10%;
- left: -10%;
- background: rgba(238, 119, 82, 0.4);
-}
-
-.blob-2 {
- bottom: -10%;
- right: -10%;
- background: rgba(35, 166, 213, 0.4);
- animation-delay: -7s;
- width: 600px;
- height: 600px;
-}
-
-.blob-3 {
- top: 40%;
- left: 30%;
- background: rgba(231, 60, 126, 0.3);
- animation-delay: -14s;
- width: 450px;
- height: 450px;
-}
-
-@keyframes move {
- 0% { transform: translate(0, 0) rotate(0deg) scale(1); }
- 33% { transform: translate(150px, 100px) rotate(120deg) scale(1.1); }
- 66% { transform: translate(-50px, 200px) rotate(240deg) scale(0.9); }
- 100% { transform: translate(0, 0) rotate(360deg) scale(1); }
-}
-
-.header-link {
- font-size: 14px;
- color: #fff;
- text-decoration: none;
- background: rgba(0, 0, 0, 0.2);
- padding: 0.5rem 1rem;
- border-radius: 8px;
- transition: all 0.3s ease;
-}
-
-.header-link:hover {
- background: rgba(0, 0, 0, 0.4);
- text-decoration: none;
-}
-
-/* Admin Styles */
-.admin-container {
- max-width: 900px;
- margin: 3rem auto;
- padding: 2.5rem;
- background: rgba(255, 255, 255, 0.85);
- backdrop-filter: blur(20px);
- -webkit-backdrop-filter: blur(20px);
- border-radius: 24px;
- box-shadow: 0 20px 50px rgba(0,0,0,0.15);
- border: 1px solid rgba(255, 255, 255, 0.4);
- position: relative;
- z-index: 1;
-}
-
-.admin-container h1 {
- margin-top: 0;
- color: #212529;
- font-weight: 800;
-}
-
-.table {
- width: 100%;
- border-collapse: separate;
- border-spacing: 0 8px;
- margin-top: 1.5rem;
-}
-
-.table th {
- background: transparent;
- border: none;
- padding: 1rem;
- color: #6c757d;
- font-weight: 600;
+.eyebrow,
+.panel-label {
+ font-size: 0.72rem;
+ letter-spacing: 0.14em;
text-transform: uppercase;
- font-size: 0.75rem;
- letter-spacing: 1px;
+ color: var(--muted);
}
-.table td {
- background: #fff;
- padding: 1rem;
- border: none;
+.app-title {
+ font-size: clamp(1.4rem, 2vw, 1.9rem);
+ font-weight: 700;
+ color: var(--text);
}
-.table tr td:first-child { border-radius: 12px 0 0 12px; }
-.table tr td:last-child { border-radius: 0 12px 12px 0; }
-
-.form-group {
- margin-bottom: 1.25rem;
-}
-
-.form-group label {
- display: block;
- margin-bottom: 0.5rem;
+.status-badge {
+ border: 1px solid var(--border);
+ background: var(--surface-strong) !important;
+ color: var(--text);
+ padding: 0.55rem 0.75rem;
font-weight: 600;
- font-size: 0.9rem;
}
-.form-control {
- width: 100%;
- padding: 0.75rem 1rem;
- border: 1px solid rgba(0, 0, 0, 0.1);
- border-radius: 12px;
- background: #fff;
- transition: all 0.3s ease;
- box-sizing: border-box;
+.control-btn {
+ min-width: 92px;
}
-.form-control:focus {
- outline: none;
- border-color: #23a6d5;
- box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.1);
+.status-badge.state-ready {
+ background: rgba(39, 48, 58, 0.9) !important;
}
-.header-container {
- display: flex;
- justify-content: space-between;
+.status-badge.state-playing {
+ background: rgba(210, 176, 98, 0.16) !important;
+ color: #f3e6c8;
+ border-color: rgba(210, 176, 98, 0.5);
+}
+
+.status-badge.state-paused {
+ background: rgba(122, 141, 182, 0.16) !important;
+ color: #d3deef;
+ border-color: rgba(122, 141, 182, 0.4);
+}
+
+.status-badge.state-game-over {
+ background: rgba(203, 125, 125, 0.16) !important;
+ color: #f3d6d6;
+ border-color: rgba(203, 125, 125, 0.4);
+}
+
+.panel {
+ background: rgba(18, 22, 29, 0.96);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow);
+ padding: var(--spacing-5);
+}
+
+.compact-panel {
+ padding: 1.1rem;
+}
+
+.panel-header {
+ margin-bottom: 1rem;
+}
+
+.panel-title {
+ font-size: 1.05rem;
+ font-weight: 650;
+ margin: 0;
+}
+
+.panel-inline-stats {
+ color: var(--muted);
+}
+
+.inline-chip {
+ display: inline-flex;
align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem 0.75rem;
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ background: rgba(17, 22, 29, 0.72);
+ font-size: 0.85rem;
}
-.header-links {
- display: flex;
+.board-layout {
+ display: grid;
gap: 1rem;
}
-.admin-card {
- background: rgba(255, 255, 255, 0.6);
- padding: 2rem;
- border-radius: 20px;
- border: 1px solid rgba(255, 255, 255, 0.5);
- margin-bottom: 2.5rem;
- box-shadow: 0 10px 30px rgba(0,0,0,0.05);
+.board-frame {
+ position: relative;
+ border-radius: var(--radius-lg);
+ overflow: hidden;
+ border: 1px solid var(--border-strong);
+ background: #0e1218;
}
-.admin-card h3 {
- margin-top: 0;
- margin-bottom: 1.5rem;
+#tetris-board {
+ display: block;
+ width: auto;
+ max-width: 100%;
+ height: min(60vh, 560px);
+ aspect-ratio: 1 / 2;
+ margin-inline: auto;
+ background: transparent;
+}
+
+.board-overlay {
+ position: absolute;
+ inset: 0;
+ display: grid;
+ place-items: center;
+ padding: 1.5rem;
+ text-align: center;
+ background: rgba(11, 13, 18, 0.82);
+ color: var(--text);
+ transition: opacity 180ms ease, visibility 180ms ease;
+}
+
+.board-overlay.hidden {
+ opacity: 0;
+ visibility: hidden;
+ pointer-events: none;
+}
+
+.overlay-label {
+ font-size: 0.8rem;
+ text-transform: uppercase;
+ letter-spacing: 0.16em;
+ color: var(--accent);
+}
+
+.overlay-title {
+ font-size: clamp(1.4rem, 4vw, 2.1rem);
font-weight: 700;
}
-.btn-delete {
- background: #dc3545;
- color: white;
- border: none;
- padding: 0.25rem 0.5rem;
- border-radius: 4px;
- cursor: pointer;
+.overlay-copy {
+ color: var(--muted);
+ max-width: 34rem;
}
-.btn-add {
- background: #212529;
- color: white;
- border: none;
- padding: 0.5rem 1rem;
- border-radius: 4px;
- cursor: pointer;
- margin-top: 1rem;
+.stats-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.9rem;
}
-.btn-save {
- background: #0088cc;
- color: white;
- border: none;
- padding: 0.8rem 1.5rem;
- border-radius: 12px;
- cursor: pointer;
- font-weight: 600;
- width: 100%;
- transition: all 0.3s ease;
+.stat-card,
+.history-detail,
+.preview-wrap {
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ background: rgba(14, 18, 24, 0.92);
}
-.webhook-url {
- font-size: 0.85em;
- color: #555;
- margin-top: 0.5rem;
-}
-
-.history-table-container {
- overflow-x: auto;
- background: rgba(255, 255, 255, 0.4);
+.stat-card {
padding: 1rem;
- border-radius: 12px;
- border: 1px solid rgba(255, 255, 255, 0.3);
}
-.history-table {
+.stat-label,
+.summary-label,
+.history-detail-header,
+.detail-grid dt {
+ display: block;
+ color: var(--muted);
+ font-size: 0.78rem;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+}
+
+.stat-value {
+ display: block;
+ margin-top: 0.25rem;
+ font-size: clamp(1.2rem, 2vw, 1.55rem);
+ font-weight: 700;
+}
+
+.preview-wrap {
+ padding: 0.75rem;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ min-height: 176px;
+}
+
+#next-piece {
+ width: 144px;
+ height: 144px;
+}
+
+.multiplayer-block .form-control {
+ background: var(--surface-soft);
+ border-color: var(--border);
+ color: var(--text);
+}
+
+.multiplayer-block .form-control:focus {
+ background: var(--surface-soft);
+ border-color: var(--accent);
+ box-shadow: 0 0 0 0.2rem rgba(210, 176, 98, 0.15);
+}
+
+.room-code-card {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+ padding: 0.75rem 0.9rem;
+ border: 1px dashed var(--border-strong);
+ border-radius: var(--radius-md);
+ background: rgba(14, 18, 24, 0.6);
+}
+
+.room-code-label {
+ display: block;
+ color: var(--muted);
+ font-size: 0.7rem;
+ text-transform: uppercase;
+ letter-spacing: 0.16em;
+}
+
+.room-code-value {
+ font-size: 1.1rem;
+ font-weight: 700;
+ letter-spacing: 0.2em;
+}
+
+.room-status .status-badge {
+ font-size: 0.75rem;
+}
+
+.opponent-wrap {
+ display: grid;
+ gap: 0.75rem;
+}
+
+#opponent-board {
width: 100%;
+ max-width: 240px;
+ height: auto;
+ margin: 0 auto;
+ aspect-ratio: 1 / 2;
+ border: 1px solid var(--border-strong);
+ border-radius: var(--radius-md);
+ background: #0e1218;
}
-.history-table-time {
- width: 15%;
- white-space: nowrap;
- font-size: 0.85em;
- color: #555;
+.opponent-name {
+ font-weight: 600;
}
-.history-table-user {
- width: 35%;
- background: rgba(255, 255, 255, 0.3);
- border-radius: 8px;
- padding: 8px;
+.detail-grid {
+ display: grid;
+ gap: 0.9rem;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
}
-.history-table-ai {
- width: 50%;
- background: rgba(255, 255, 255, 0.5);
- border-radius: 8px;
- padding: 8px;
+.detail-grid.compact {
+ gap: 0.75rem;
}
-.no-messages {
+.detail-grid .full {
+ grid-column: 1 / -1;
+}
+
+.detail-grid dd {
+ margin: 0.28rem 0 0;
+ font-size: 1rem;
+ font-weight: 600;
+}
+
+.controls-list {
+ display: grid;
+ gap: 0.7rem;
+}
+
+.controls-list li {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 0.75rem;
+ color: var(--text);
+ font-size: 0.92rem;
+ padding-bottom: 0.65rem;
+ border-bottom: 1px solid rgba(39, 48, 58, 0.7);
+}
+
+.controls-list li:last-child {
+ padding-bottom: 0;
+ border-bottom: 0;
+}
+
+kbd {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 2rem;
+ padding: 0.28rem 0.5rem;
+ margin-left: 0.35rem;
+ border-radius: var(--radius-sm);
+ border: 1px solid var(--border-strong);
+ background: var(--surface-soft);
+ color: var(--text);
+ font-size: 0.78rem;
+ font-family: inherit;
+ box-shadow: inset 0 -1px 0 rgba(255, 255, 255, 0.04);
+}
+
+.empty-state {
+ padding: 0.95rem 1rem;
+ border: 1px dashed var(--border-strong);
+ border-radius: var(--radius-md);
+ color: var(--muted);
+ background: rgba(14, 18, 24, 0.5);
+ font-size: 0.92rem;
+}
+
+.history-list {
+ display: grid;
+ gap: 0.75rem;
+}
+
+.history-item {
+ width: 100%;
+ text-align: left;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ background: rgba(14, 18, 24, 0.88);
+ padding: 0.9rem 1rem;
+ color: var(--text);
+ transition: border-color 140ms ease, transform 140ms ease, background-color 140ms ease;
+}
+
+.history-item:hover,
+.history-item:focus-visible {
+ border-color: var(--accent);
+ background: rgba(17, 22, 29, 1);
+ transform: translateY(-1px);
+ outline: none;
+}
+
+.history-item.active {
+ border-color: var(--accent);
+ background: rgba(210, 176, 98, 0.08);
+}
+
+.history-topline,
+.history-subline {
+ display: flex;
+ justify-content: space-between;
+ gap: 0.75rem;
+}
+
+.history-score {
+ font-weight: 700;
+}
+
+.history-meta,
+.history-date {
+ color: var(--muted);
+ font-size: 0.82rem;
+}
+
+.history-detail {
+ margin-top: 0.85rem;
+ padding: 0.95rem 1rem;
+}
+
+.summary-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 1rem;
+ margin-bottom: 1rem;
+}
+
+.summary-grid strong {
+ display: block;
+ margin-top: 0.25rem;
+ font-size: 1.3rem;
+}
+
+.modal-copy,
+.toast-body,
+.text-secondary {
+ color: var(--muted) !important;
+}
+
+.btn-light {
+ background: #f5f7f8;
+ border-color: #f5f7f8;
+}
+
+.btn-outline-light {
+ border-color: var(--border-strong);
+ color: var(--text);
+}
+
+.btn-outline-light:hover,
+.btn-outline-light:focus-visible {
+ background: var(--surface-strong);
+ border-color: var(--accent);
+ color: var(--text);
+}
+
+.btn:focus-visible,
+.history-item:focus-visible {
+ box-shadow: 0 0 0 0.2rem rgba(210, 176, 98, 0.18);
+}
+
+.toast,
+.modal-content {
+ border-radius: var(--radius-md);
+}
+
+@media (max-width: 991.98px) {
+ .app-header {
+ position: static;
+ }
+
+ .panel {
+ padding: 1rem;
+ }
+}
+
+@media (max-width: 575.98px) {
+ #tetris-board {
+ height: min(52vh, 460px);
+ }
+
+ .stats-grid,
+ .detail-grid,
+ .summary-grid {
+ grid-template-columns: 1fr 1fr;
+ }
+
+ .controls-list li {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+
+ .history-topline,
+ .history-subline {
+ flex-direction: column;
+ gap: 0.2rem;
+ }
+}
+
+
+.field-help {
+ color: var(--bs-secondary-color);
+ font-size: 0.78rem;
+ line-height: 1.4;
+}
+
+.scoreboard-list {
+ display: grid;
+ gap: 0.75rem;
+}
+
+.scoreboard-item {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ gap: 0.875rem;
+ align-items: start;
+ padding: 0.9rem 1rem;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 14px;
+ background: rgba(255, 255, 255, 0.03);
+}
+
+.scoreboard-rank {
+ min-width: 2.75rem;
+ padding: 0.35rem 0.55rem;
+ border-radius: 999px;
+ background: rgba(210, 176, 98, 0.14);
+ color: #d2b062;
+ font-size: 0.82rem;
+ font-weight: 700;
text-align: center;
- color: #777;
-}
\ No newline at end of file
+}
+
+.scoreboard-item-body {
+ min-width: 0;
+}
+
+.scoreboard-topline {
+ display: flex;
+ justify-content: space-between;
+ gap: 0.75rem;
+ align-items: center;
+ margin-bottom: 0.3rem;
+}
+
+.scoreboard-name,
+.scoreboard-score {
+ color: #f8f9fa;
+}
+
+.scoreboard-score {
+ white-space: nowrap;
+ font-weight: 700;
+}
+
+.scoreboard-meta {
+ color: var(--bs-secondary-color);
+ font-size: 0.82rem;
+ line-height: 1.45;
+}
+
+@media (max-width: 575.98px) {
+ .scoreboard-item {
+ grid-template-columns: 1fr;
+ }
+
+ .scoreboard-topline {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+}
diff --git a/assets/js/main.js b/assets/js/main.js
index d349598..718cc0a 100644
--- a/assets/js/main.js
+++ b/assets/js/main.js
@@ -1,39 +1,1126 @@
document.addEventListener('DOMContentLoaded', () => {
- const chatForm = document.getElementById('chat-form');
- const chatInput = document.getElementById('chat-input');
- const chatMessages = document.getElementById('chat-messages');
+ 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 appendMessage = (text, sender) => {
- const msgDiv = document.createElement('div');
- msgDiv.classList.add('message', sender);
- msgDiv.textContent = text;
- chatMessages.appendChild(msgDiv);
- chatMessages.scrollTop = chatMessages.scrollHeight;
+ 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'
+ }
};
- chatForm.addEventListener('submit', async (e) => {
- e.preventDefault();
- const message = chatInput.value.trim();
- if (!message) return;
+ const colorLookup = Object.entries(PIECE_DEFS).reduce((acc, [type, def], index) => {
+ acc[index + 1] = def.color;
+ return acc;
+ }, {});
- appendMessage(message, 'visitor');
- chatInput.value = '';
+ 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 response = await fetch('api/chat.php', {
+ const parsed = JSON.parse(localStorage.getItem(STORAGE_KEYS.history) || '[]');
+ return Array.isArray(parsed) ? parsed : [];
+ } catch (error) {
+ return [];
+ }
+ }
+
+ function saveHistory(run) {
+ history = [run, ...history].slice(0, 8);
+ localStorage.setItem(STORAGE_KEYS.history, JSON.stringify(history));
+ if (run.score > bestScore) {
+ bestScore = run.score;
+ localStorage.setItem(STORAGE_KEYS.best, String(bestScore));
+ showToast('New best score saved locally.');
+ }
+ selectedRunId = run.id;
+ renderHistory();
+ }
+
+ function renderHistory() {
+ ui.historyList.innerHTML = '';
+ ui.historyEmpty.classList.toggle('d-none', history.length > 0);
+
+ history.forEach((run) => {
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.className = `history-item${selectedRunId === run.id ? ' active' : ''}`;
+ button.innerHTML = `
+
+ ${run.score.toLocaleString()} pts
+ ${new Date(run.finishedAt).toLocaleDateString()}
+
+
+ Level ${run.level} · ${run.lines} line${run.lines === 1 ? '' : 's'}
+ ${run.duration}
+
+ `;
+ button.addEventListener('click', () => {
+ selectedRunId = run.id;
+ renderHistory();
+ });
+ ui.historyList.appendChild(button);
+ });
+
+ const selectedRun = history.find((item) => item.id === selectedRunId) || history[0] || null;
+ if (selectedRun) {
+ ui.historyDetail.classList.remove('d-none');
+ ui.selectedScore.textContent = selectedRun.score.toLocaleString();
+ ui.selectedLevel.textContent = String(selectedRun.level);
+ ui.selectedLines.textContent = String(selectedRun.lines);
+ ui.selectedDuration.textContent = selectedRun.duration;
+ ui.selectedFinished.textContent = new Date(selectedRun.finishedAt).toLocaleString();
+ } else {
+ ui.historyDetail.classList.add('d-none');
+ }
+ }
+
+
+ function getPreferredPlayerName() {
+ const typedName = (ui.playerNameInput?.value || '').trim();
+ if (typedName) return typedName;
+ if (multiplayer.displayName) return multiplayer.displayName;
+ return 'Player';
+ }
+
+ function formatDurationFromSeconds(totalSeconds) {
+ const safe = Math.max(0, Number(totalSeconds) || 0);
+ const minutes = Math.floor(safe / 60);
+ const seconds = safe % 60;
+ return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
+ }
+
+ function renderScoreboard() {
+ if (!ui.scoreboardList || !ui.scoreboardEmpty || !ui.scoreboardStatus) return;
+
+ const hasEntries = scoreboardEntries.length > 0;
+ ui.scoreboardEmpty.classList.toggle('d-none', hasEntries);
+ ui.scoreboardList.innerHTML = '';
+
+ if (!hasEntries) {
+ ui.scoreboardStatus.textContent = 'Waiting for scores';
+ return;
+ }
+
+ ui.scoreboardStatus.textContent = `Top ${scoreboardEntries.length}`;
+
+ scoreboardEntries.forEach((entry, index) => {
+ const item = document.createElement('article');
+ item.className = 'scoreboard-item';
+
+ const rank = document.createElement('div');
+ rank.className = 'scoreboard-rank';
+ rank.textContent = `#${index + 1}`;
+
+ const body = document.createElement('div');
+ body.className = 'scoreboard-item-body';
+
+ const topRow = document.createElement('div');
+ topRow.className = 'scoreboard-topline';
+
+ const name = document.createElement('strong');
+ name.className = 'scoreboard-name';
+ name.textContent = entry.player_name || 'Player';
+
+ const scoreValue = document.createElement('span');
+ scoreValue.className = 'scoreboard-score';
+ scoreValue.textContent = `${Number(entry.score || 0).toLocaleString()} pts`;
+
+ topRow.appendChild(name);
+ topRow.appendChild(scoreValue);
+
+ const meta = document.createElement('div');
+ meta.className = 'scoreboard-meta';
+ const linesLabel = Number(entry.lines || 0) === 1 ? 'line' : 'lines';
+ const dateValue = entry.created_at ? new Date(String(entry.created_at).replace(' ', 'T') + 'Z') : null;
+ const dateLabel = dateValue && !Number.isNaN(dateValue.getTime()) ? dateValue.toLocaleDateString() : 'Today';
+ const modeLabel = entry.mode === 'multiplayer' ? 'Multiplayer' : 'Solo';
+ meta.textContent = `Level ${entry.level} · ${entry.lines} ${linesLabel} · ${formatDurationFromSeconds(entry.duration_seconds)} · ${modeLabel} · ${dateLabel}`;
+
+ body.appendChild(topRow);
+ body.appendChild(meta);
+ item.appendChild(rank);
+ item.appendChild(body);
+ ui.scoreboardList.appendChild(item);
+ });
+ }
+
+ async function loadScoreboard() {
+ if (!ui.scoreboardStatus) return;
+ 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();
+ } catch (error) {
+ console.warn('Scoreboard load error', error);
+ scoreboardEntries = [];
+ renderScoreboard();
+ ui.scoreboardStatus.textContent = 'Offline';
+ } finally {
+ window.clearTimeout(timeoutId);
+ }
+ }
+
+ async function saveScoreToDatabase(run) {
+ try {
+ const response = await fetch(SCOREBOARD_API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ message })
+ body: JSON.stringify({
+ 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
+ })
});
- const data = await response.json();
-
- // Artificial delay for realism
- setTimeout(() => {
- appendMessage(data.reply, 'bot');
- }, 500);
+ const payload = await response.json();
+ if (!response.ok || !payload.success) {
+ throw new Error(payload.error || 'Unable to save scoreboard entry.');
+ }
+ await loadScoreboard();
+ if (payload.placement) {
+ showToast(`Run saved to the database scoreboard. Rank #${payload.placement}.`);
+ } else {
+ showToast('Run saved to the database scoreboard.');
+ }
} catch (error) {
- console.error('Error:', error);
- appendMessage("Sorry, something went wrong. Please try again.", 'bot');
+ console.warn('Scoreboard save error', error);
+ showToast('Run saved locally. Database scoreboard was unavailable.');
+ 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 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();
+ 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();
});
diff --git a/db/migrate.php b/db/migrate.php
new file mode 100644
index 0000000..bbbbd89
--- /dev/null
+++ b/db/migrate.php
@@ -0,0 +1,51 @@
+exec(
+ "CREATE TABLE IF NOT EXISTS schema_migrations (
+"
+ . " filename VARCHAR(190) NOT NULL PRIMARY KEY,
+"
+ . " applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
+"
+ . ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
+ );
+
+ $files = glob(__DIR__ . '/migrations/*.sql') ?: [];
+ sort($files, SORT_STRING);
+
+ $checkStmt = $pdo->prepare('SELECT 1 FROM schema_migrations WHERE filename = :filename LIMIT 1');
+ $insertStmt = $pdo->prepare('INSERT INTO schema_migrations (filename) VALUES (:filename)');
+
+ foreach ($files as $file) {
+ $filename = basename($file);
+ $checkStmt->bindValue(':filename', $filename, PDO::PARAM_STR);
+ $checkStmt->execute();
+
+ if ($checkStmt->fetchColumn()) {
+ continue;
+ }
+
+ $sql = trim((string) file_get_contents($file));
+
+ if ($sql !== '') {
+ $pdo->exec($sql);
+ }
+
+ $insertStmt->bindValue(':filename', $filename, PDO::PARAM_STR);
+ $insertStmt->execute();
+ }
+}
diff --git a/db/migrations/20260325_tetris_multiplayer_rooms.sql b/db/migrations/20260325_tetris_multiplayer_rooms.sql
new file mode 100644
index 0000000..4f4f130
--- /dev/null
+++ b/db/migrations/20260325_tetris_multiplayer_rooms.sql
@@ -0,0 +1,41 @@
+-- Multiplayer room support for the browser Tetris game.
+CREATE TABLE IF NOT EXISTS tetris_rooms (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ room_code VARCHAR(8) NOT NULL,
+ status ENUM('waiting', 'active', 'closed') NOT NULL DEFAULT 'waiting',
+ host_player_id BIGINT UNSIGNED NULL,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ expires_at DATETIME NULL,
+ PRIMARY KEY (id),
+ UNIQUE KEY uniq_tetris_rooms_code (room_code),
+ KEY idx_tetris_rooms_status (status),
+ KEY idx_tetris_rooms_updated (updated_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS tetris_room_players (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ room_id BIGINT UNSIGNED NOT NULL,
+ player_token CHAR(48) NOT NULL,
+ player_slot TINYINT UNSIGNED NOT NULL,
+ display_name VARCHAR(48) NOT NULL DEFAULT 'Player',
+ connection_status ENUM('connected', 'disconnected') NOT NULL DEFAULT 'connected',
+ game_status ENUM('ready', 'playing', 'paused', 'game_over') NOT NULL DEFAULT 'ready',
+ score INT UNSIGNED NOT NULL DEFAULT 0,
+ lines_cleared INT UNSIGNED NOT NULL DEFAULT 0,
+ level INT UNSIGNED NOT NULL DEFAULT 1,
+ board_state_json JSON NULL,
+ piece_state_json JSON NULL,
+ meta_json JSON NULL,
+ joined_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ last_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ UNIQUE KEY uniq_tetris_room_players_token (player_token),
+ UNIQUE KEY uniq_tetris_room_players_slot (room_id, player_slot),
+ KEY idx_tetris_room_players_room (room_id),
+ KEY idx_tetris_room_players_seen (last_seen_at),
+ CONSTRAINT fk_tetris_room_players_room
+ FOREIGN KEY (room_id) REFERENCES tetris_rooms(id)
+ ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
diff --git a/db/migrations/20260325_tetris_scoreboard.sql b/db/migrations/20260325_tetris_scoreboard.sql
new file mode 100644
index 0000000..2bc22d2
--- /dev/null
+++ b/db/migrations/20260325_tetris_scoreboard.sql
@@ -0,0 +1,18 @@
+-- Persistent scoreboard entries for completed Tetris runs.
+CREATE TABLE IF NOT EXISTS tetris_scores (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ player_name VARCHAR(48) NOT NULL DEFAULT 'Player',
+ score INT UNSIGNED NOT NULL DEFAULT 0,
+ lines_cleared INT UNSIGNED NOT NULL DEFAULT 0,
+ level INT UNSIGNED NOT NULL DEFAULT 1,
+ pieces_placed INT UNSIGNED NOT NULL DEFAULT 0,
+ duration_seconds INT UNSIGNED NOT NULL DEFAULT 0,
+ mode ENUM('solo', 'multiplayer') NOT NULL DEFAULT 'solo',
+ room_code VARCHAR(8) NULL,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ KEY idx_tetris_scores_rank (score, lines_cleared, level, duration_seconds),
+ KEY idx_tetris_scores_created (created_at),
+ KEY idx_tetris_scores_mode (mode)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
diff --git a/index.php b/index.php
index 7205f3d..1fb5c8d 100644
--- a/index.php
+++ b/index.php
@@ -1,150 +1,299 @@
-
-
- New Style
-
-
-
-
-
-
-
-
-
+
+
+ = htmlspecialchars($pageTitle) ?>
+
+
+
+
+
+
+
-
-
-
-
-
-
+
+
-
-
-
-
Analyzing your requirements and generating your website…
-
-
Loading…
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Press Enter to start
+
Stack clean. Clear fast.
+
Controls: move with arrow keys, rotate with ↑ or X, hard drop with Space.
+
+
+
+
+
+
+ Score
+ 0
+
+
+ Lines
+ 0
+
+
+ Level
+ 1
+
+
+ Drop speed
+ 0.90s
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+ Score
+ 0
+
+
+ Lines
+ 0
+
+
+ Level
+ 1
+
+
+ Duration
+ 00:00
+
+
+
Press restart or hit Enter to jump straight into a new run.
+
+
+
+
+
+
+
+