diff --git a/api/leaderboard.php b/api/leaderboard.php
new file mode 100644
index 0000000..9380886
--- /dev/null
+++ b/api/leaderboard.php
@@ -0,0 +1,42 @@
+ (int) ($run['id'] ?? 0),
+ 'player_name' => (string) ($run['player_name'] ?? ''),
+ 'score' => (int) ($run['score'] ?? 0),
+ 'lines_cleared' => (int) ($run['lines_cleared'] ?? 0),
+ 'level_reached' => (int) ($run['level_reached'] ?? 1),
+ 'duration_seconds' => (int) ($run['duration_seconds'] ?? 0),
+ 'created_at_iso' => $timestamp ? gmdate(DATE_ATOM, $timestamp) : null,
+ ];
+}
+
+try {
+ $leaderboard = array_map('tetrisApiScoreRow', tetrisFetchLeaderboard(8));
+ $recentRuns = array_map('tetrisApiScoreRow', tetrisFetchRecent(5));
+ $bestRun = tetrisFetchBestScore();
+
+ echo json_encode([
+ 'success' => true,
+ 'updated_at' => gmdate(DATE_ATOM),
+ 'best_run' => $bestRun ? tetrisApiScoreRow($bestRun) : null,
+ 'leaderboard' => $leaderboard,
+ 'recent_runs' => $recentRuns,
+ ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+} catch (Throwable $e) {
+ http_response_code(500);
+ echo json_encode([
+ 'success' => false,
+ 'error' => 'Unable to load leaderboard right now.',
+ ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+}
diff --git a/api/rooms.php b/api/rooms.php
new file mode 100644
index 0000000..9291c7e
--- /dev/null
+++ b/api/rooms.php
@@ -0,0 +1,78 @@
+ false,
+ 'error' => 'Room not found or expired.',
+ ], 404);
+ }
+
+ roomsApiRespond([
+ 'success' => true,
+ 'updated_at' => gmdate(DATE_ATOM),
+ 'room' => $room,
+ ]);
+ }
+
+ if ($method !== 'POST') {
+ roomsApiRespond([
+ 'success' => false,
+ 'error' => 'Method not allowed.',
+ ], 405);
+ }
+
+ $action = strtolower(trim((string) ($_POST['action'] ?? '')));
+ if ($action === 'create') {
+ $room = multiplayerCreateRoom((string) ($_POST['player_name'] ?? ''));
+ roomsApiRespond([
+ 'success' => true,
+ 'message' => 'Room created.',
+ 'updated_at' => gmdate(DATE_ATOM),
+ 'room' => $room,
+ ], 201);
+ }
+
+ if ($action === 'join') {
+ $room = multiplayerJoinRoom((string) ($_POST['room_code'] ?? ''), (string) ($_POST['player_name'] ?? ''));
+ roomsApiRespond([
+ 'success' => true,
+ 'message' => 'Joined room.',
+ 'updated_at' => gmdate(DATE_ATOM),
+ 'room' => $room,
+ ]);
+ }
+
+ roomsApiRespond([
+ 'success' => false,
+ 'error' => 'Unknown room action.',
+ ], 422);
+} catch (InvalidArgumentException $e) {
+ roomsApiRespond([
+ 'success' => false,
+ 'error' => $e->getMessage(),
+ ], 422);
+} catch (Throwable $e) {
+ roomsApiRespond([
+ 'success' => false,
+ 'error' => 'Unable to process the room request right now.',
+ ], 500);
+}
diff --git a/assets/css/custom.css b/assets/css/custom.css
index 789132e..3565890 100644
--- a/assets/css/custom.css
+++ b/assets/css/custom.css
@@ -1,403 +1,1073 @@
-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;
- margin: 0;
- min-height: 100vh;
+ :root {
+ color-scheme: dark;
+ --bg: #060b16;
+ --surface: rgba(11, 20, 34, 0.84);
+ --surface-2: rgba(16, 30, 49, 0.92);
+ --surface-3: rgba(21, 44, 72, 0.96);
+ --border: rgba(120, 214, 255, 0.14);
+ --border-strong: rgba(133, 242, 255, 0.34);
+ --text: #f3f8ff;
+ --muted: #97add0;
+ --accent: #7cf6ff;
+ --accent-strong: #ffffff;
+ --success: #73f0a6;
+ --danger: #ff7d8f;
+ --shadow: 0 30px 80px rgba(1, 6, 16, 0.52);
+ --radius-sm: 10px;
+ --radius-md: 14px;
+ --radius-lg: 18px;
}
-.main-wrapper {
+* {
+ box-sizing: border-box;
+}
+
+html {
+ scroll-behavior: smooth;
+}
+
+body.tetris-app {
+ margin: 0;
+ min-height: 100vh;
+ background: var(--bg);
+ color: var(--text);
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ letter-spacing: -0.01em;
+}
+
+body.tetris-app::selection {
+ background: rgba(219, 228, 240, 0.22);
+}
+
+.site-header,
+.hero-section,
+footer {
+ background: rgba(8, 10, 13, 0.92);
+}
+
+.py-lg-6 {
+ padding-top: 5rem !important;
+ padding-bottom: 5rem !important;
+}
+
+.navbar-brand,
+.letter-spacing-1 {
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ font-size: 0.9rem;
+}
+
+.navbar-dark,
+.navbar-dark .nav-link,
+.footer-link {
+ color: var(--text);
+}
+
+.nav-link,
+.footer-link {
+ text-decoration: none;
+ opacity: 0.78;
+}
+
+.nav-link:hover,
+.nav-link:focus,
+.footer-link:hover,
+.footer-link:focus,
+.table-link:hover,
+.table-link:focus {
+ opacity: 1;
+ color: var(--accent-strong);
+}
+
+.btn {
+ border-radius: var(--radius-sm);
+ padding: 0.7rem 1rem;
+ font-weight: 600;
+ box-shadow: none !important;
+}
+
+.btn-light {
+ background: var(--accent);
+ color: #0e1013;
+ border-color: var(--accent);
+}
+
+.btn-light:hover,
+.btn-light:focus {
+ background: var(--accent-strong);
+ color: #080a0d;
+}
+
+.btn-outline-light {
+ border-color: var(--border-strong);
+ color: var(--text);
+}
+
+.btn-outline-light:hover,
+.btn-outline-light:focus {
+ background: rgba(255, 255, 255, 0.08);
+ border-color: rgba(255, 255, 255, 0.22);
+ color: var(--text);
+}
+
+.hero-section .lead,
+.text-secondary,
+.form-text,
+.tiny-muted,
+.recent-run .tiny-muted,
+.table .tiny-muted {
+ color: var(--muted) !important;
+}
+
+.eyebrow {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ text-transform: uppercase;
+ font-size: 0.72rem;
+ letter-spacing: 0.12em;
+ color: #c4ccd5;
+}
+
+.soft-panel,
+.metric-card,
+.recent-run,
+.empty-state,
+.board-shell,
+.run-summary,
+.toast,
+.control-btn,
+.score-chip {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ box-shadow: var(--shadow);
+}
+
+.hero-panel,
+.soft-panel {
+ background: rgba(18, 22, 27, 0.94);
+}
+
+.metric-card {
+ padding: 1rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+ min-height: 100%;
+}
+
+.metric-card.compact {
+ padding: 0.9rem;
+}
+
+.metric-label {
+ color: var(--muted);
+ font-size: 0.76rem;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.metric-value {
+ font-size: clamp(1.2rem, 2vw, 1.6rem);
+ font-weight: 700;
+}
+
+.status-text {
+ font-size: 1rem;
+}
+
+.score-chip {
+ padding: 0.9rem 1rem;
+ min-width: 112px;
+}
+
+.score-chip-lg {
+ min-width: 160px;
+}
+
+.score-chip-label {
+ display: block;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ font-size: 0.72rem;
+ color: var(--muted);
+ margin-bottom: 0.35rem;
+}
+
+.score-chip strong {
+ font-size: 1.45rem;
+}
+
+.game-layout {
+ display: grid;
+ grid-template-columns: minmax(0, 300px) minmax(0, 1fr);
+ gap: 1rem;
+ align-items: start;
+}
+
+.board-shell {
+ padding: 0.85rem;
+ width: fit-content;
+ background: #0f1318;
+}
+
+.board-shell {
+ position: relative;
+ overflow: hidden;
+}
+
+.board-start-overlay {
+ position: absolute;
+ inset: 0.85rem;
display: flex;
align-items: center;
justify-content: center;
- min-height: 100vh;
+ background: rgba(9, 11, 13, 0.72);
+ backdrop-filter: blur(3px);
+ border-radius: 10px;
+ transition: opacity 0.2s ease, visibility 0.2s ease;
+}
+
+.board-start-overlay.is-hidden {
+ opacity: 0;
+ visibility: hidden;
+ pointer-events: none;
+}
+
+.board-start-card {
+ display: grid;
+ gap: 0.75rem;
+ text-align: center;
+ justify-items: center;
+ padding: 1.25rem;
+ min-width: 200px;
+ background: rgba(18, 22, 27, 0.92);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ box-shadow: var(--shadow);
+}
+
+#tetris-board,
+#next-piece {
+ display: block;
+ background: #090b0d;
+ border-radius: 8px;
+}
+
+#tetris-board {
+ width: min(100%, 300px);
+ height: auto;
+ border: 1px solid rgba(255, 255, 255, 0.06);
+}
+
+#next-piece {
width: 100%;
- padding: 20px;
- box-sizing: border-box;
+ max-width: 120px;
+ height: auto;
+ margin: 0 auto;
+ border: 1px solid rgba(255, 255, 255, 0.06);
+}
+
+.board-sidebar {
+ display: grid;
+ gap: 0.9rem;
+}
+
+.next-preview-card {
+ align-items: stretch;
+}
+
+.keycaps {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+}
+
+.keycaps span {
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ padding: 0.4rem 0.75rem;
+ font-size: 0.8rem;
+ color: var(--muted);
+ background: rgba(255, 255, 255, 0.03);
+}
+
+.mobile-controls {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 0.75rem;
+}
+
+.control-btn {
+ min-height: 56px;
+ color: var(--text);
+ font-weight: 700;
+ font-size: 1rem;
+ padding: 0.75rem 1rem;
+ border: 1px solid var(--border-strong);
+ touch-action: manipulation;
+}
+
+.control-btn:hover,
+.control-btn:focus-visible {
+ background: var(--surface-2);
+ outline: none;
+}
+
+.control-btn:active {
+ transform: translateY(1px);
+}
+
+.control-btn-wide {
+ grid-column: span 3;
+}
+
+.control-btn.accent {
+ background: var(--accent);
+ color: #0b0d10;
+}
+
+.control-btn.accent:hover,
+.control-btn.accent:focus-visible {
+ background: var(--accent-strong);
+}
+
+.run-summary {
+ padding: 1rem;
+ color: var(--text);
+}
+
+.form-control-dark {
+ background: var(--surface-2);
+ border-color: var(--border-strong);
+ color: var(--text);
+ border-radius: 10px;
+ padding: 0.8rem 0.9rem;
+}
+
+.form-control-dark:focus {
+ background: var(--surface-2);
+ border-color: rgba(255, 255, 255, 0.28);
+ color: var(--text);
+}
+
+.form-control-dark::placeholder {
+ color: #728093;
+}
+
+.rule-list,
+.detail-list {
+ display: grid;
+ gap: 0.8rem;
+}
+
+.rule-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ padding: 0.8rem 0;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.06);
+ color: var(--muted);
+}
+
+.rule-item:last-child {
+ border-bottom: 0;
+ padding-bottom: 0;
+}
+
+.rule-item strong {
+ color: var(--text);
+}
+
+.leaderboard-table {
+ --bs-table-bg: transparent;
+ --bs-table-color: var(--text);
+ --bs-table-border-color: rgba(255, 255, 255, 0.08);
+ --bs-table-hover-bg: rgba(255, 255, 255, 0.03);
+ margin-bottom: 0;
+}
+
+.table-link {
+ color: var(--accent);
+ text-decoration: none;
+}
+
+.recent-runs {
+ min-height: 100%;
+}
+
+.recent-run {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ padding: 1rem;
+ text-decoration: none;
+ color: inherit;
+ transition: border-color 0.2s ease, transform 0.2s ease;
+}
+
+.recent-run:hover,
+.recent-run:focus {
+ border-color: rgba(255, 255, 255, 0.2);
+ transform: translateY(-1px);
+}
+
+.empty-state {
+ padding: 1.25rem;
+}
+
+.empty-state.compact {
+ min-height: 132px;
+ display: flex;
+ align-items: center;
+}
+
+.soft-alert,
+.toast {
+ color: var(--text);
+ background: rgba(24, 29, 36, 0.96);
+ border-color: var(--border-strong);
+}
+
+.toast {
+ min-width: 280px;
+}
+
+.toast .toast-header {
+ background: rgba(255, 255, 255, 0.03);
+ color: var(--text);
+ border-bottom: 1px solid rgba(255, 255, 255, 0.05);
+}
+
+.toast .btn-close {
+ filter: invert(1) grayscale(1);
+}
+
+.detail-list {
+ margin: 0;
+ padding-left: 1.25rem;
+ color: var(--muted);
+}
+
+.detail-list li + li {
+ margin-top: 0.65rem;
+}
+
+@media (max-width: 1199.98px) {
+ .game-layout {
+ grid-template-columns: 1fr;
+ }
+
+ .board-shell {
+ width: 100%;
+ display: flex;
+ justify-content: center;
+ }
+}
+
+@media (max-width: 767.98px) {
+ .py-lg-6 {
+ padding-top: 3.75rem !important;
+ padding-bottom: 3.75rem !important;
+ }
+
+ .display-5 {
+ font-size: 2.3rem;
+ }
+
+ .mobile-controls {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .control-btn-wide {
+ grid-column: span 2;
+ }
+
+ .score-chip,
+ .score-chip-lg {
+ min-width: 0;
+ }
+}
+
+
+.page-shell {
+ width: min(100% - 1.5rem, 1440px);
+ margin: 0 auto;
+}
+
+.game-topbar {
+ position: sticky;
+ top: 0;
+ z-index: 20;
+ background: rgba(8, 10, 13, 0.92);
+ backdrop-filter: blur(14px);
+}
+
+.game-page {
+ min-height: calc(100vh - 72px);
+}
+
+.board-panel {
+ background: linear-gradient(180deg, rgba(24, 29, 36, 0.98) 0%, rgba(14, 18, 22, 0.98) 100%);
+}
+
+.focus-layout {
+ grid-template-columns: minmax(0, 1fr) 260px;
+ gap: 1.25rem;
+ align-items: start;
+}
+
+.board-stage {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.board-shell-large {
+ width: 100%;
+ max-width: 480px;
+ padding: 1rem;
+}
+
+#tetris-board {
+ width: min(100%, 420px);
+ max-height: calc(100vh - 220px);
+}
+
+.sidebar-stack {
+ display: grid;
+ gap: 0.9rem;
+}
+
+.quick-stats {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.9rem;
+}
+
+.soft-subpanel {
+ background: rgba(255, 255, 255, 0.03);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+}
+
+.compact-keycaps {
+ gap: 0.45rem;
+}
+
+.compact-keycaps span {
+ font-size: 0.76rem;
+ padding: 0.38rem 0.65rem;
+}
+
+.leaderboard-compact .table th,
+.leaderboard-compact .table td {
+ padding-top: 0.65rem;
+ padding-bottom: 0.65rem;
+}
+
+.sticky-column {
+ position: sticky;
+ top: 88px;
+}
+
+@media (max-width: 1199.98px) {
+ .sticky-column {
+ position: static;
+ }
+
+ .focus-layout {
+ grid-template-columns: 1fr;
+ }
+
+ .board-shell-large {
+ max-width: 420px;
+ }
+}
+
+@media (max-width: 767.98px) {
+ .page-shell {
+ width: min(100% - 1rem, 1440px);
+ }
+
+ .board-shell-large {
+ padding: 0.7rem;
+ }
+
+ #tetris-board {
+ width: min(100%, 340px);
+ max-height: none;
+ }
+
+ .quick-stats {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+/* vivid arcade refresh */
+body.tetris-app {
+ position: relative;
+ background:
+ radial-gradient(circle at top left, rgba(34, 211, 238, 0.16), transparent 26%),
+ radial-gradient(circle at 82% 18%, rgba(59, 130, 246, 0.16), transparent 22%),
+ radial-gradient(circle at 50% 100%, rgba(249, 115, 22, 0.12), transparent 30%),
+ linear-gradient(180deg, #071120 0%, #050913 52%, #04070f 100%);
+}
+
+body.tetris-app::before,
+body.tetris-app::after {
+ content: '';
+ position: fixed;
+ width: 26rem;
+ height: 26rem;
+ border-radius: 999px;
+ filter: blur(80px);
+ pointer-events: none;
+ opacity: 0.32;
+ z-index: 0;
+}
+
+body.tetris-app::before {
+ top: -9rem;
+ left: -8rem;
+ background: rgba(34, 211, 238, 0.28);
+}
+
+body.tetris-app::after {
+ right: -10rem;
+ bottom: -10rem;
+ background: rgba(249, 115, 22, 0.22);
+}
+
+.game-topbar,
+.game-page {
position: relative;
z-index: 1;
}
-@keyframes gradient {
- 0% {
- background-position: 0% 50%;
- }
- 50% {
- background-position: 100% 50%;
- }
- 100% {
- background-position: 0% 50%;
- }
+.game-topbar {
+ background: rgba(6, 11, 21, 0.76);
+ backdrop-filter: blur(18px);
+ border-color: rgba(124, 246, 255, 0.12) !important;
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.28);
}
-.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);
+.navbar-brand {
+ background: linear-gradient(90deg, #ffffff 0%, #7cf6ff 45%, #60a5fa 100%);
+ -webkit-background-clip: text;
+ background-clip: text;
+ color: transparent !important;
+ text-shadow: 0 0 22px rgba(124, 246, 255, 0.18);
+}
+
+.btn-light {
+ background: linear-gradient(135deg, #7cf6ff 0%, #60a5fa 100%);
+ border-color: rgba(124, 246, 255, 0.55);
+ color: #04111c;
+ box-shadow: 0 14px 30px rgba(64, 179, 255, 0.28) !important;
+}
+
+.btn-light:hover,
+.btn-light:focus {
+ background: linear-gradient(135deg, #b4fbff 0%, #7cc7ff 100%);
+ border-color: rgba(180, 251, 255, 0.75);
+ color: #03101a;
+ transform: translateY(-1px);
+}
+
+.btn-outline-light {
+ background: rgba(255, 255, 255, 0.03);
+ border-color: rgba(124, 246, 255, 0.2);
+}
+
+.btn-outline-light:hover,
+.btn-outline-light:focus {
+ background: rgba(124, 246, 255, 0.1);
+ border-color: rgba(124, 246, 255, 0.42);
+}
+
+.soft-panel,
+.metric-card,
+.recent-run,
+.empty-state,
+.board-shell,
+.run-summary,
+.toast,
+.control-btn,
+.score-chip {
+ background:
+ linear-gradient(180deg, rgba(13, 24, 40, 0.92) 0%, rgba(8, 15, 27, 0.92) 100%),
+ rgba(8, 15, 27, 0.92);
+ border-color: rgba(124, 246, 255, 0.12);
+ box-shadow: 0 22px 60px rgba(2, 9, 20, 0.46);
+}
+
+.board-panel {
+ position: relative;
overflow: hidden;
+ background:
+ radial-gradient(circle at top right, rgba(124, 246, 255, 0.1), transparent 24%),
+ radial-gradient(circle at bottom left, rgba(249, 115, 22, 0.08), transparent 20%),
+ linear-gradient(180deg, rgba(10, 20, 37, 0.96) 0%, rgba(5, 11, 20, 0.96) 100%);
}
-.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;
+.board-panel::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ background: linear-gradient(135deg, rgba(124, 246, 255, 0.05), transparent 38%, rgba(96, 165, 250, 0.06) 100%);
+}
+
+.board-shell,
+.board-shell-large {
+ background:
+ linear-gradient(180deg, rgba(7, 15, 28, 0.98) 0%, rgba(5, 10, 20, 0.98) 100%);
+ border: 1px solid rgba(124, 246, 255, 0.18);
+ box-shadow:
+ inset 0 1px 0 rgba(255, 255, 255, 0.06),
+ 0 0 0 1px rgba(124, 246, 255, 0.08),
+ 0 30px 80px rgba(3, 10, 22, 0.55);
+}
+
+.board-shell::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ border-radius: inherit;
+ pointer-events: none;
+ background: linear-gradient(180deg, rgba(124, 246, 255, 0.06), transparent 22%, transparent 80%, rgba(249, 115, 22, 0.05));
+}
+
+#tetris-board,
+#next-piece {
+ border: 1px solid rgba(124, 246, 255, 0.18);
+ box-shadow:
+ inset 0 0 0 1px rgba(255, 255, 255, 0.03),
+ 0 0 24px rgba(56, 189, 248, 0.1);
+}
+
+.board-start-overlay {
+ background: linear-gradient(180deg, rgba(4, 9, 18, 0.5) 0%, rgba(4, 9, 18, 0.78) 100%);
+ backdrop-filter: blur(10px);
+}
+
+.board-start-card {
+ min-width: 220px;
+ background: linear-gradient(180deg, rgba(16, 29, 48, 0.96) 0%, rgba(8, 17, 31, 0.98) 100%);
+ border-color: rgba(124, 246, 255, 0.24);
+ box-shadow: 0 20px 45px rgba(2, 10, 22, 0.52), 0 0 24px rgba(56, 189, 248, 0.14);
+}
+
+.metric-card {
+ position: relative;
+ overflow: hidden;
+ background: linear-gradient(180deg, rgba(13, 25, 42, 0.96) 0%, rgba(8, 17, 30, 0.96) 100%);
+}
+
+.metric-card::after {
+ content: '';
+ position: absolute;
+ inset: 0 auto auto 0;
+ width: 100%;
+ height: 2px;
+ background: linear-gradient(90deg, rgba(124, 246, 255, 0.75), rgba(96, 165, 250, 0.12));
+ opacity: 0.65;
+}
+
+.next-preview-card {
+ border-color: rgba(124, 246, 255, 0.26);
+}
+
+.quick-stats .metric-card:nth-child(1) {
+ border-color: rgba(34, 211, 238, 0.3);
+ box-shadow: 0 20px 50px rgba(34, 211, 238, 0.1);
+}
+
+.quick-stats .metric-card:nth-child(2) {
+ border-color: rgba(96, 165, 250, 0.3);
+ box-shadow: 0 20px 50px rgba(96, 165, 250, 0.1);
+}
+
+.quick-stats .metric-card:nth-child(3) {
+ border-color: rgba(52, 211, 153, 0.28);
+ box-shadow: 0 20px 50px rgba(52, 211, 153, 0.1);
+}
+
+.quick-stats .metric-card:nth-child(4) {
+ border-color: rgba(249, 115, 22, 0.28);
+ box-shadow: 0 20px 50px rgba(249, 115, 22, 0.1);
+}
+
+.metric-value {
+ color: #f8fbff;
+ text-shadow: 0 0 20px rgba(124, 246, 255, 0.08);
+}
+
+.status-text {
+ color: #ffd39d;
+}
+
+.score-chip {
+ background: linear-gradient(180deg, rgba(12, 24, 40, 0.96) 0%, rgba(8, 17, 30, 0.96) 100%);
+ border-color: rgba(124, 246, 255, 0.22);
+}
+
+.soft-subpanel {
+ background: linear-gradient(180deg, rgba(10, 19, 34, 0.86) 0%, rgba(7, 14, 26, 0.9) 100%);
+ border-color: rgba(124, 246, 255, 0.12);
+}
+
+.keycaps span {
+ background: rgba(124, 246, 255, 0.06);
+ border-color: rgba(124, 246, 255, 0.16);
+ color: #d7ecff;
+}
+
+.mobile-controls {
+ gap: 0.85rem;
+}
+
+.control-btn {
+ background: linear-gradient(180deg, rgba(16, 31, 50, 0.96) 0%, rgba(10, 20, 35, 0.96) 100%);
+ border-color: rgba(124, 246, 255, 0.18);
+ box-shadow: 0 12px 30px rgba(2, 10, 22, 0.42), inset 0 1px 0 rgba(255, 255, 255, 0.05);
+}
+
+.control-btn:hover,
+.control-btn:focus-visible {
+ background: linear-gradient(180deg, rgba(23, 45, 74, 1) 0%, rgba(12, 24, 42, 1) 100%);
+ border-color: rgba(124, 246, 255, 0.34);
+ transform: translateY(-2px);
+}
+
+.control-btn:active {
+ transform: translateY(1px);
+}
+
+.control-btn.accent {
+ background: linear-gradient(135deg, rgba(249, 115, 22, 0.96) 0%, rgba(251, 191, 36, 0.96) 100%);
+ border-color: rgba(251, 191, 36, 0.45);
+ color: #1e1204;
+ box-shadow: 0 16px 30px rgba(249, 115, 22, 0.22);
+}
+
+.control-btn.accent:hover,
+.control-btn.accent:focus-visible {
+ background: linear-gradient(135deg, rgba(251, 146, 60, 1) 0%, rgba(253, 224, 71, 1) 100%);
+}
+
+.soft-alert {
+ background: linear-gradient(180deg, rgba(16, 29, 48, 0.94) 0%, rgba(8, 18, 31, 0.96) 100%);
+ border-color: rgba(124, 246, 255, 0.16);
+}
+
+.badge.text-bg-dark {
+ background: rgba(124, 246, 255, 0.08) !important;
+ color: #dff9ff !important;
+}
+
+.form-control-dark,
+.form-control {
+ background: rgba(7, 14, 26, 0.92);
+ border-color: rgba(124, 246, 255, 0.14);
+ color: var(--text);
+}
+
+.form-control-dark:focus,
+.form-control:focus {
+ background: rgba(9, 18, 31, 0.96);
+ border-color: rgba(124, 246, 255, 0.4);
+ color: var(--text);
+ box-shadow: 0 0 0 0.2rem rgba(56, 189, 248, 0.12);
+}
+
+.table {
+ --bs-table-bg: transparent;
+ --bs-table-color: var(--text);
+ --bs-table-border-color: rgba(124, 246, 255, 0.1);
+}
+
+@media (max-width: 767.98px) {
+ body.tetris-app::before,
+ body.tetris-app::after {
+ width: 16rem;
+ height: 16rem;
+ filter: blur(56px);
+ opacity: 0.22;
+ }
+}
+
+
+
+.leaderboard-meta {
display: flex;
- justify-content: space-between;
align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
}
-.chat-messages {
- flex: 1;
- overflow-y: auto;
- padding: 1.5rem;
+.online-status {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.45rem;
+}
+
+.online-dot {
+ width: 0.55rem;
+ height: 0.55rem;
+ border-radius: 999px;
+ background: #4ade80;
+ box-shadow: 0 0 0.8rem rgba(74, 222, 128, 0.65);
+ flex: 0 0 auto;
+}
+
+.recent-feed {
+ display: grid;
+ gap: 0.75rem;
+}
+
+.recent-item {
display: flex;
- flex-direction: column;
- gap: 1.25rem;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ padding: 0.85rem 0.95rem;
+ border-radius: 12px;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ background: rgba(255, 255, 255, 0.03);
+ color: inherit;
+ text-decoration: none;
+ transition: transform 0.2s ease, border-color 0.2s ease, background 0.2s ease;
}
-/* Custom Scrollbar */
-::-webkit-scrollbar {
- width: 6px;
+.recent-item:hover,
+.recent-item:focus {
+ transform: translateY(-1px);
+ border-color: rgba(255, 255, 255, 0.18);
+ background: rgba(255, 255, 255, 0.05);
}
-::-webkit-scrollbar-track {
- background: transparent;
+.recent-item strong {
+ display: block;
+ color: var(--text);
}
-::-webkit-scrollbar-thumb {
- background: rgba(255, 255, 255, 0.3);
- border-radius: 10px;
+.recent-item-meta {
+ color: var(--muted);
+ font-size: 0.84rem;
+ margin-top: 0.2rem;
}
-::-webkit-scrollbar-thumb:hover {
- background: rgba(255, 255, 255, 0.5);
+
+/* multiplayer lobby */
+.room-intro {
+ line-height: 1.55;
}
-.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 {
+.room-actions {
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;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- z-index: 0;
- overflow: hidden;
- pointer-events: none;
-}
-
-.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;
+.room-code-input {
text-transform: uppercase;
- font-size: 0.75rem;
- letter-spacing: 1px;
+ letter-spacing: 0.12em;
}
-.table td {
- background: #fff;
+.room-empty-state {
+ margin-bottom: 0;
+}
+
+.room-state-card {
padding: 1rem;
- border: none;
+ border: 1px solid rgba(124, 246, 255, 0.16);
+ border-radius: var(--radius-md);
+ background: linear-gradient(180deg, rgba(12, 24, 41, 0.94) 0%, rgba(8, 16, 29, 0.96) 100%);
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
-.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;
+.room-code-display {
+ margin-top: 0.35rem;
+ font-size: 1.7rem;
+ font-weight: 800;
+ line-height: 1;
+ letter-spacing: 0.18em;
+ color: var(--accent);
+ text-transform: uppercase;
}
-.form-group label {
- display: block;
- margin-bottom: 0.5rem;
- 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;
-}
-
-.form-control:focus {
- outline: none;
- border-color: #23a6d5;
- box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.1);
-}
-
-.header-container {
- display: flex;
- justify-content: space-between;
+.room-status-badge {
+ display: inline-flex;
align-items: center;
+ justify-content: center;
+ min-width: 96px;
+ padding: 0.45rem 0.7rem;
+ border-radius: 999px;
+ font-size: 0.76rem;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ border: 1px solid rgba(255, 255, 255, 0.1);
}
-.header-links {
+.room-status-waiting {
+ color: #ffe4a8;
+ background: rgba(250, 204, 21, 0.12);
+ border-color: rgba(250, 204, 21, 0.26);
+}
+
+.room-status-ready {
+ color: #c9ffe0;
+ background: rgba(34, 197, 94, 0.14);
+ border-color: rgba(34, 197, 94, 0.28);
+}
+
+.room-player-list {
+ display: grid;
+ gap: 0.7rem;
+}
+
+.room-player-line {
display: flex;
+ align-items: center;
+ justify-content: space-between;
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);
-}
-
-.admin-card h3 {
- margin-top: 0;
- margin-bottom: 1.5rem;
- font-weight: 700;
-}
-
-.btn-delete {
- background: #dc3545;
- color: white;
- border: none;
- padding: 0.25rem 0.5rem;
- border-radius: 4px;
- cursor: pointer;
-}
-
-.btn-add {
- background: #212529;
- color: white;
- border: none;
- padding: 0.5rem 1rem;
- border-radius: 4px;
- cursor: pointer;
- margin-top: 1rem;
-}
-
-.btn-save {
- background: #0088cc;
- color: white;
- border: none;
- padding: 0.8rem 1.5rem;
+ padding: 0.75rem 0.9rem;
border-radius: 12px;
- cursor: pointer;
- font-weight: 600;
- width: 100%;
- transition: all 0.3s ease;
+ background: rgba(255, 255, 255, 0.04);
+ border: 1px solid rgba(124, 246, 255, 0.08);
}
-.webhook-url {
- font-size: 0.85em;
- color: #555;
- margin-top: 0.5rem;
+.room-player-line strong {
+ text-align: right;
}
-.history-table-container {
- overflow-x: auto;
- background: rgba(255, 255, 255, 0.4);
- padding: 1rem;
- border-radius: 12px;
- border: 1px solid rgba(255, 255, 255, 0.3);
+.room-meta-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.75rem;
}
-.history-table {
- width: 100%;
+.room-meta-value {
+ font-size: 1rem;
}
-.history-table-time {
- width: 15%;
- white-space: nowrap;
- font-size: 0.85em;
- color: #555;
+.room-message {
+ margin: 0;
+ color: var(--muted);
+ line-height: 1.55;
}
-.history-table-user {
- width: 35%;
- background: rgba(255, 255, 255, 0.3);
- border-radius: 8px;
- padding: 8px;
-}
+@media (max-width: 575.98px) {
+ .room-meta-grid {
+ grid-template-columns: 1fr;
+ }
-.history-table-ai {
- width: 50%;
- background: rgba(255, 255, 255, 0.5);
- border-radius: 8px;
- padding: 8px;
+ .room-code-display {
+ font-size: 1.35rem;
+ letter-spacing: 0.14em;
+ }
}
-
-.no-messages {
- text-align: center;
- color: #777;
-}
\ No newline at end of file
diff --git a/assets/js/main.js b/assets/js/main.js
index d349598..6e664eb 100644
--- a/assets/js/main.js
+++ b/assets/js/main.js
@@ -1,39 +1,1062 @@
document.addEventListener('DOMContentLoaded', () => {
- const chatForm = document.getElementById('chat-form');
- const chatInput = document.getElementById('chat-input');
- const chatMessages = document.getElementById('chat-messages');
+ const boardCanvas = document.getElementById('tetris-board');
+ const nextCanvas = document.getElementById('next-piece');
+ if (!boardCanvas || !nextCanvas) {
+ return;
+ }
- 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 boardCtx = boardCanvas.getContext('2d');
+ const nextCtx = nextCanvas.getContext('2d');
+
+ const scoreValue = document.getElementById('score-value');
+ const levelValue = document.getElementById('level-value');
+ const linesValue = document.getElementById('lines-value');
+ const statusValue = document.getElementById('status-value');
+ const savePlaceholder = document.getElementById('save-placeholder');
+ const saveForm = document.getElementById('save-form');
+ const runSummary = document.getElementById('run-summary');
+ const finalScore = document.getElementById('final_score');
+ const finalLines = document.getElementById('final_lines');
+ const finalLevel = document.getElementById('final_level');
+ const finalDuration = document.getElementById('final_duration');
+ const scoreInput = document.getElementById('score-input');
+ const linesInput = document.getElementById('lines-input');
+ const levelInput = document.getElementById('level-input');
+ const durationInput = document.getElementById('duration-input');
+ const playerNameInput = document.getElementById('player_name');
+ const roomNicknameInput = document.getElementById('room_player_name');
+ const roomCodeInput = document.getElementById('room_code_input');
+ const createRoomButton = document.getElementById('create-room-btn');
+ const joinRoomButton = document.getElementById('join-room-btn');
+ const roomEmptyState = document.getElementById('room-empty-state');
+ const roomStateCard = document.getElementById('room-state-card');
+ const roomStatusBadge = document.getElementById('room-status-badge');
+ const currentRoomCodeLabel = document.getElementById('current-room-code');
+ const roomHostName = document.getElementById('room-host-name');
+ const roomGuestName = document.getElementById('room-guest-name');
+ const roomPlayerCount = document.getElementById('room-player-count');
+ const roomUpdatedAt = document.getElementById('room-updated-at');
+ const roomMessage = document.getElementById('room-message');
+ const refreshRoomStatusButton = document.getElementById('refresh-room-status-btn');
+ const copyRoomCodeButton = document.getElementById('copy-room-code-btn');
+ const leaderboardTableWrap = document.getElementById('leaderboard-table-wrap');
+ const leaderboardBody = document.getElementById('leaderboard-body');
+ const leaderboardEmptyState = document.getElementById('leaderboard-empty-state');
+ const recentRunsList = document.getElementById('recent-runs-list');
+ const recentEmptyState = document.getElementById('recent-empty-state');
+ const bestPlayerLabel = document.getElementById('best-player-label');
+ const leaderboardRefreshLabel = document.getElementById('leaderboard-refresh-label');
+ const restartButtons = [
+ document.getElementById('restart-top'),
+ document.getElementById('restart-inline'),
+ document.getElementById('restart-after-game')
+ ].filter(Boolean);
+ const startButtons = [
+ document.getElementById('start-top'),
+ document.getElementById('start-game')
+ ].filter(Boolean);
+ const startOverlay = document.getElementById('board-start-overlay');
+ const focusButton = document.getElementById('focus-game');
+ const controlButtons = Array.from(document.querySelectorAll('[data-action]'));
+
+ const pageConfig = window.TETRIS_PAGE || {};
+ const playerNameStorageKey = 'midnight-blocks-player-name';
+ const roomCodeStorageKey = 'midnight-blocks-room-code';
+ let currentRoomCode = '';
+ let roomPollHandle = null;
+ const boardScale = 30;
+ const nextScale = 30;
+ const rows = 20;
+ const cols = 10;
+ const lineScores = [0, 100, 300, 500, 800];
+
+ boardCtx.scale(boardScale, boardScale);
+ nextCtx.scale(nextScale, nextScale);
+
+ const colors = {
+ I: { light: '#c9fbff', base: '#5ee8ff', dark: '#0891b2', glow: 'rgba(94, 232, 255, 0.42)' },
+ J: { light: '#c7dbff', base: '#60a5fa', dark: '#1d4ed8', glow: 'rgba(96, 165, 250, 0.42)' },
+ L: { light: '#ffe1b8', base: '#fb923c', dark: '#c2410c', glow: 'rgba(251, 146, 60, 0.4)' },
+ O: { light: '#fff4b8', base: '#facc15', dark: '#ca8a04', glow: 'rgba(250, 204, 21, 0.36)' },
+ S: { light: '#d1ffdd', base: '#4ade80', dark: '#15803d', glow: 'rgba(74, 222, 128, 0.4)' },
+ T: { light: '#ffd4dd', base: '#fb7185', dark: '#be123c', glow: 'rgba(251, 113, 133, 0.4)' },
+ Z: { light: '#ffd0bf', base: '#f97316', dark: '#c2410c', glow: 'rgba(249, 115, 22, 0.38)' }
};
- chatForm.addEventListener('submit', async (e) => {
- e.preventDefault();
- const message = chatInput.value.trim();
- if (!message) return;
+ const pieces = {
+ I: [[0, 0, 0, 0], [1, 1, 1, 1], [0, 0, 0, 0], [0, 0, 0, 0]],
+ J: [[1, 0, 0], [1, 1, 1], [0, 0, 0]],
+ L: [[0, 0, 1], [1, 1, 1], [0, 0, 0]],
+ O: [[1, 1], [1, 1]],
+ S: [[0, 1, 1], [1, 1, 0], [0, 0, 0]],
+ T: [[0, 1, 0], [1, 1, 1], [0, 0, 0]],
+ Z: [[1, 1, 0], [0, 1, 1], [0, 0, 0]]
+ };
- appendMessage(message, 'visitor');
- chatInput.value = '';
+ const state = {
+ board: createMatrix(cols, rows),
+ piece: null,
+ nextQueue: [],
+ dropCounter: 0,
+ dropInterval: 900,
+ lastTime: 0,
+ score: 0,
+ lines: 0,
+ level: 1,
+ isGameOver: false,
+ isStarted: false,
+ startedAt: null
+ };
+
+ function escapeHtml(value) {
+ return String(value ?? '').replace(/[&<>"']/g, (char) => ({
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ "'": '''
+ }[char]));
+ }
+
+ function sanitizePlayerName(value) {
+ return String(value ?? '')
+ .replace(/[^\p{L}\p{N} _.-]+/gu, '')
+ .trim()
+ .slice(0, 24);
+ }
+
+ function rememberPlayerName(value) {
+ const clean = sanitizePlayerName(value);
+ if (!clean) {
+ return '';
+ }
+ try {
+ window.localStorage.setItem(playerNameStorageKey, clean);
+ } catch (error) {
+ // Ignore storage errors silently.
+ }
+ return clean;
+ }
+
+ function loadRememberedPlayerName() {
+ try {
+ return sanitizePlayerName(window.localStorage.getItem(playerNameStorageKey) || '');
+ } catch (error) {
+ return '';
+ }
+ }
+
+ function sanitizeRoomCode(value) {
+ return String(value ?? '')
+ .replace(/[^A-Za-z0-9]+/g, '')
+ .toUpperCase()
+ .slice(0, 6);
+ }
+
+ function rememberRoomCode(value) {
+ const clean = sanitizeRoomCode(value);
+ try {
+ if (clean) {
+ window.localStorage.setItem(roomCodeStorageKey, clean);
+ } else {
+ window.localStorage.removeItem(roomCodeStorageKey);
+ }
+ } catch (error) {
+ // Ignore storage errors silently.
+ }
+ return clean;
+ }
+
+ function loadRememberedRoomCode() {
+ try {
+ return sanitizeRoomCode(window.localStorage.getItem(roomCodeStorageKey) || '');
+ } catch (error) {
+ return '';
+ }
+ }
+
+ function applyNicknameToInputs(value, sourceInput = null) {
+ const clean = sanitizePlayerName(value);
+ [playerNameInput, roomNicknameInput].forEach((input) => {
+ if (input && input !== sourceInput && input.value !== clean) {
+ input.value = clean;
+ }
+ });
+ return clean;
+ }
+
+ function syncNicknameFromInput(input) {
+ if (!input) {
+ return '';
+ }
+ const clean = sanitizePlayerName(input.value);
+ if (input.value !== clean) {
+ input.value = clean;
+ }
+ applyNicknameToInputs(clean, input);
+ rememberPlayerName(clean);
+ return clean;
+ }
+
+ function getActiveNickname() {
+ const value = sanitizePlayerName(roomNicknameInput?.value || playerNameInput?.value || loadRememberedPlayerName());
+ if (!value) {
+ showToast('Nickname needed', 'Enter your nickname before creating or joining a room.');
+ roomNicknameInput?.focus({ preventScroll: false });
+ playerNameInput?.focus({ preventScroll: false });
+ return '';
+ }
+ applyNicknameToInputs(value);
+ rememberPlayerName(value);
+ return value;
+ }
+
+ function formatRelativeTime(value) {
+ const timestamp = Date.parse(value || '');
+ if (!Number.isFinite(timestamp)) {
+ return 'Live sync';
+ }
+ const diffSeconds = Math.max(0, Math.round((Date.now() - timestamp) / 1000));
+ if (diffSeconds < 10) return 'Updated just now';
+ if (diffSeconds < 60) return `Updated ${diffSeconds}s ago`;
+ if (diffSeconds < 3600) return `Updated ${Math.floor(diffSeconds / 60)}m ago`;
+ return `Updated ${Math.floor(diffSeconds / 3600)}h ago`;
+ }
+
+ function formatRecentTime(value) {
+ const timestamp = Date.parse(value || '');
+ if (!Number.isFinite(timestamp)) {
+ return 'now';
+ }
+ const diffSeconds = Math.max(0, Math.round((Date.now() - timestamp) / 1000));
+ if (diffSeconds < 60) return 'now';
+ if (diffSeconds < 3600) return `${Math.floor(diffSeconds / 60)}m`;
+ if (diffSeconds < 86400) return `${Math.floor(diffSeconds / 3600)}h`;
+ return `${Math.floor(diffSeconds / 86400)}d`;
+ }
+
+ function renderLeaderboard(leaderboard) {
+ if (!leaderboardBody || !leaderboardTableWrap || !leaderboardEmptyState) {
+ return;
+ }
+ if (!Array.isArray(leaderboard) || leaderboard.length === 0) {
+ leaderboardBody.innerHTML = '';
+ leaderboardTableWrap.classList.add('d-none');
+ leaderboardEmptyState.classList.remove('d-none');
+ return;
+ }
+
+ leaderboardBody.innerHTML = leaderboard.map((run, index) => `
+
+ | ${index + 1} |
+ ${escapeHtml(run.player_name || 'Anonymous')} |
+ ${Number(run.score || 0).toLocaleString()} |
+
+ `).join('');
+ leaderboardTableWrap.classList.remove('d-none');
+ leaderboardEmptyState.classList.add('d-none');
+ }
+
+ function renderRecentRuns(recentRuns) {
+ if (!recentRunsList || !recentEmptyState) {
+ return;
+ }
+ if (!Array.isArray(recentRuns) || recentRuns.length === 0) {
+ recentRunsList.innerHTML = '';
+ recentRunsList.classList.add('d-none');
+ recentEmptyState.classList.remove('d-none');
+ return;
+ }
+
+ recentRunsList.innerHTML = recentRuns.map((run) => `
+
+
+
${escapeHtml(run.player_name || 'Anonymous')}
+
${Number(run.score || 0).toLocaleString()} pts · ${Number(run.lines_cleared || 0).toLocaleString()} lines
+
+ ${formatRecentTime(run.created_at_iso)}
+
+ `).join('');
+ recentRunsList.classList.remove('d-none');
+ recentEmptyState.classList.add('d-none');
+ }
+
+ async function syncLeaderboard() {
+ if (!pageConfig.leaderboardApi) {
+ return;
+ }
+ try {
+ const response = await fetch(`${pageConfig.leaderboardApi}?_=${Date.now()}`, {
+ headers: { Accept: 'application/json' },
+ cache: 'no-store'
+ });
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}`);
+ }
+ const payload = await response.json();
+ if (!payload || payload.success !== true) {
+ throw new Error('Invalid leaderboard payload');
+ }
+ renderLeaderboard(payload.leaderboard || []);
+ renderRecentRuns(payload.recent_runs || []);
+
+ if (bestPlayerLabel) {
+ if (payload.best_run && payload.best_run.player_name) {
+ bestPlayerLabel.textContent = `Best: ${payload.best_run.player_name}`;
+ } else {
+ bestPlayerLabel.textContent = 'Waiting for first score';
+ }
+ }
+
+ if (leaderboardRefreshLabel) {
+ leaderboardRefreshLabel.textContent = formatRelativeTime(payload.updated_at || '');
+ }
+ } catch (error) {
+ if (leaderboardRefreshLabel) {
+ leaderboardRefreshLabel.textContent = 'Sync paused';
+ }
+ }
+ }
+
+ function renderRoomState(room) {
+ if (!roomStateCard || !roomEmptyState || !roomStatusBadge || !currentRoomCodeLabel || !roomHostName || !roomGuestName || !roomPlayerCount || !roomUpdatedAt || !roomMessage) {
+ return;
+ }
+
+ currentRoomCode = sanitizeRoomCode(room?.room_code || '');
+ rememberRoomCode(currentRoomCode);
+
+ roomEmptyState.classList.add('d-none');
+ roomStateCard.classList.remove('d-none');
+ currentRoomCodeLabel.textContent = currentRoomCode || '------';
+ roomHostName.textContent = room?.host_name || '—';
+ roomGuestName.textContent = room?.guest_name || 'Waiting for friend…';
+ roomPlayerCount.textContent = `${Number(room?.player_count || (room?.guest_name ? 2 : 1))} / 2`;
+ roomUpdatedAt.textContent = formatRelativeTime(room?.updated_at_iso || '').replace(/^Updated\s+/i, '');
+
+ if (roomCodeInput && currentRoomCode) {
+ roomCodeInput.value = currentRoomCode;
+ }
+
+ const ready = room?.is_ready || room?.status === 'ready';
+ roomStatusBadge.textContent = ready ? 'Ready' : 'Waiting';
+ roomStatusBadge.className = `room-status-badge ${ready ? 'room-status-ready' : 'room-status-waiting'}`;
+ roomMessage.textContent = ready
+ ? `${room?.guest_name || 'Your friend'} joined. The lobby is ready. Next step: sync the live head-to-head match.`
+ : `Share code ${currentRoomCode}. The lobby will switch to ready as soon as your friend joins.`;
+ }
+
+ function clearRoomState(message = 'No active room yet. Create one or paste a code from your friend.') {
+ currentRoomCode = '';
+ rememberRoomCode('');
+ if (roomPollHandle) {
+ window.clearInterval(roomPollHandle);
+ roomPollHandle = null;
+ }
+ if (roomStateCard) {
+ roomStateCard.classList.add('d-none');
+ }
+ if (roomEmptyState) {
+ roomEmptyState.classList.remove('d-none');
+ roomEmptyState.innerHTML = `${escapeHtml(message)}
`;
+ }
+ }
+
+ function startRoomPolling() {
+ if (roomPollHandle) {
+ window.clearInterval(roomPollHandle);
+ }
+ if (!currentRoomCode || !pageConfig.roomsApi) {
+ roomPollHandle = null;
+ return;
+ }
+ roomPollHandle = window.setInterval(() => {
+ syncRoomStatus(currentRoomCode, { silent: true });
+ }, 5000);
+ }
+
+ async function sendRoomRequest(action, payload = {}) {
+ if (!pageConfig.roomsApi) {
+ throw new Error('Rooms API is not available.');
+ }
+
+ const body = new URLSearchParams({ action, ...payload });
+ const response = await fetch(pageConfig.roomsApi, {
+ method: 'POST',
+ headers: {
+ Accept: 'application/json',
+ 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
+ },
+ body: body.toString(),
+ cache: 'no-store'
+ });
+
+ const payloadJson = await response.json().catch(() => null);
+ if (!response.ok || !payloadJson || payloadJson.success !== true) {
+ throw new Error(payloadJson?.error || 'Unable to update the room.');
+ }
+
+ return payloadJson;
+ }
+
+ async function syncRoomStatus(roomCode, options = {}) {
+ const code = sanitizeRoomCode(roomCode);
+ if (!code || !pageConfig.roomsApi) {
+ return null;
+ }
try {
- const response = await fetch('api/chat.php', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ message })
+ const response = await fetch(`${pageConfig.roomsApi}?room_code=${encodeURIComponent(code)}&_=${Date.now()}`, {
+ headers: { Accept: 'application/json' },
+ cache: 'no-store'
});
- const data = await response.json();
-
- // Artificial delay for realism
- setTimeout(() => {
- appendMessage(data.reply, 'bot');
- }, 500);
+ const payload = await response.json().catch(() => null);
+ if (!response.ok || !payload || payload.success !== true || !payload.room) {
+ throw new Error(payload?.error || 'Room not found.');
+ }
+ renderRoomState(payload.room);
+ startRoomPolling();
+ return payload.room;
} catch (error) {
- console.error('Error:', error);
- appendMessage("Sorry, something went wrong. Please try again.", 'bot');
+ if (!options.silent) {
+ showToast('Room sync failed', error.message || 'Unable to refresh the room right now.');
+ }
+ if (!options.keepState) {
+ clearRoomState(error.message || 'Room not found.');
+ }
+ return null;
+ }
+ }
+
+ function createMatrix(width, height) {
+ return Array.from({ length: height }, () => Array(width).fill(0));
+ }
+
+ function cloneMatrix(matrix) {
+ return matrix.map((row) => row.slice());
+ }
+
+ function randomBag() {
+ const bag = Object.keys(pieces);
+ for (let i = bag.length - 1; i > 0; i -= 1) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [bag[i], bag[j]] = [bag[j], bag[i]];
+ }
+ return bag;
+ }
+
+ function ensureQueue() {
+ while (state.nextQueue.length < 7) {
+ state.nextQueue.push(...randomBag());
+ }
+ }
+
+ function createPiece(type) {
+ return {
+ type,
+ matrix: cloneMatrix(pieces[type]),
+ pos: { x: 0, y: 0 }
+ };
+ }
+
+ function spawnPiece() {
+ ensureQueue();
+ const type = state.nextQueue.shift();
+ state.piece = createPiece(type);
+ state.piece.pos.y = 0;
+ state.piece.pos.x = Math.floor(cols / 2) - Math.ceil(state.piece.matrix[0].length / 2);
+ drawNextPiece();
+
+ if (collides(state.board, state.piece)) {
+ endGame();
+ }
+ }
+
+ function collides(board, player) {
+ const { matrix, pos } = player;
+ for (let y = 0; y < matrix.length; y += 1) {
+ for (let x = 0; x < matrix[y].length; x += 1) {
+ if (matrix[y][x] !== 0 && (board[y + pos.y] && board[y + pos.y][x + pos.x]) !== 0) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ function merge(board, player) {
+ player.matrix.forEach((row, y) => {
+ row.forEach((value, x) => {
+ if (value !== 0) {
+ board[y + player.pos.y][x + player.pos.x] = player.type;
+ }
+ });
+ });
+ }
+
+ 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 = 1) {
+ if (state.isGameOver || !state.isStarted || !state.piece) {
+ return;
+ }
+ const originalX = state.piece.pos.x;
+ const rotated = rotate(state.piece.matrix, direction);
+ state.piece.matrix = rotated;
+ const kicks = [0, -1, 1, -2, 2];
+ for (const offset of kicks) {
+ state.piece.pos.x = originalX + offset;
+ if (!collides(state.board, state.piece)) {
+ announce('Piece rotated');
+ draw();
+ return;
+ }
+ }
+ state.piece.matrix = rotate(rotated, -direction);
+ state.piece.pos.x = originalX;
+ }
+
+ function movePlayer(direction) {
+ if (state.isGameOver || !state.isStarted || !state.piece) {
+ return;
+ }
+ state.piece.pos.x += direction;
+ if (collides(state.board, state.piece)) {
+ state.piece.pos.x -= direction;
+ return;
+ }
+ draw();
+ }
+
+ function playerDrop(addSoftDropPoint = false) {
+ if (state.isGameOver || !state.piece) {
+ return false;
+ }
+ state.piece.pos.y += 1;
+ if (collides(state.board, state.piece)) {
+ state.piece.pos.y -= 1;
+ merge(state.board, state.piece);
+ clearLines();
+ spawnPiece();
+ updateStats();
+ state.dropCounter = 0;
+ return false;
+ }
+ if (addSoftDropPoint) {
+ state.score += 1;
+ }
+ state.dropCounter = 0;
+ updateStats();
+ draw();
+ return true;
+ }
+
+ function hardDrop() {
+ if (state.isGameOver || !state.piece) {
+ return;
+ }
+ let rowsDropped = 0;
+ while (playerDrop(false)) {
+ rowsDropped += 1;
+ }
+ if (rowsDropped > 0) {
+ state.score += rowsDropped * 2;
+ updateStats();
+ draw();
+ announce(`Hard drop +${rowsDropped * 2}`);
+ }
+ }
+
+ function clearLines() {
+ let linesCleared = 0;
+ outer: for (let y = state.board.length - 1; y >= 0; y -= 1) {
+ for (let x = 0; x < state.board[y].length; x += 1) {
+ if (state.board[y][x] === 0) {
+ continue outer;
+ }
+ }
+ const row = state.board.splice(y, 1)[0].fill(0);
+ state.board.unshift(row);
+ linesCleared += 1;
+ y += 1;
+ }
+
+ if (linesCleared > 0) {
+ state.lines += linesCleared;
+ state.score += lineScores[linesCleared] * state.level;
+ const newLevel = Math.floor(state.lines / 10) + 1;
+ if (newLevel !== state.level) {
+ state.level = newLevel;
+ state.dropInterval = Math.max(140, 900 - (state.level - 1) * 70);
+ announce(`Level ${state.level}`);
+ } else {
+ announce(`${linesCleared} line${linesCleared > 1 ? 's' : ''} cleared`);
+ }
+ }
+ }
+
+ function drawCell(x, y, type, context, size = 1) {
+ const palette = colors[type] || { light: '#ffffff', base: '#dbeafe', dark: '#64748b', glow: 'rgba(255,255,255,0.28)' };
+ const inset = 0.06 * size;
+ const width = size - inset * 2;
+ const height = size - inset * 2;
+ const gradient = context.createLinearGradient(x, y, x + size, y + size);
+ gradient.addColorStop(0, palette.light);
+ gradient.addColorStop(0.42, palette.base);
+ gradient.addColorStop(1, palette.dark);
+
+ context.save();
+ context.shadowColor = palette.glow;
+ context.shadowBlur = 0.42 * size;
+ context.fillStyle = gradient;
+ context.fillRect(x + inset, y + inset, width, height);
+
+ context.shadowBlur = 0;
+ context.fillStyle = 'rgba(255,255,255,0.22)';
+ context.fillRect(x + inset + 0.07 * size, y + inset + 0.08 * size, width - 0.14 * size, 0.12 * size);
+
+ context.strokeStyle = 'rgba(255,255,255,0.28)';
+ context.lineWidth = 0.05 * size;
+ context.strokeRect(x + inset, y + inset, width, height);
+
+ context.strokeStyle = 'rgba(4, 10, 18, 0.42)';
+ context.lineWidth = 0.04 * size;
+ context.strokeRect(x + inset + 0.08 * size, y + inset + 0.08 * size, width - 0.16 * size, height - 0.16 * size);
+ context.restore();
+ }
+
+ function drawMatrix(matrix, offset, type, context) {
+ matrix.forEach((row, y) => {
+ row.forEach((value, x) => {
+ if (value !== 0) {
+ drawCell(x + offset.x, y + offset.y, type, context);
+ }
+ });
+ });
+ }
+
+ function paintArenaBackground(context, width, height) {
+ context.save();
+ const gradient = context.createLinearGradient(0, 0, width, height);
+ gradient.addColorStop(0, '#08111f');
+ gradient.addColorStop(0.55, '#0b1730');
+ gradient.addColorStop(1, '#040913');
+ context.fillStyle = gradient;
+ context.fillRect(0, 0, width, height);
+
+ const glow = context.createRadialGradient(width / 2, height * 0.18, 0, width / 2, height * 0.18, width * 0.95);
+ glow.addColorStop(0, 'rgba(124, 246, 255, 0.09)');
+ glow.addColorStop(1, 'rgba(124, 246, 255, 0)');
+ context.fillStyle = glow;
+ context.fillRect(0, 0, width, height);
+ context.restore();
+ }
+
+ function drawGrid(context, width, height) {
+ context.save();
+ context.strokeStyle = 'rgba(159, 214, 255, 0.09)';
+ context.lineWidth = 0.04;
+ for (let x = 0; x <= width; x += 1) {
+ context.beginPath();
+ context.moveTo(x, 0);
+ context.lineTo(x, height);
+ context.stroke();
+ }
+ for (let y = 0; y <= height; y += 1) {
+ context.beginPath();
+ context.moveTo(0, y);
+ context.lineTo(width, y);
+ context.stroke();
+ }
+ context.restore();
+ }
+
+ function drawGhostPiece() {
+ if (!state.piece || state.isGameOver) {
+ return;
+ }
+ const ghost = {
+ matrix: state.piece.matrix,
+ pos: { x: state.piece.pos.x, y: state.piece.pos.y },
+ type: state.piece.type
+ };
+ while (!collides(state.board, ghost)) {
+ ghost.pos.y += 1;
+ }
+ ghost.pos.y -= 1;
+ boardCtx.save();
+ boardCtx.globalAlpha = 0.22;
+ drawMatrix(ghost.matrix, ghost.pos, ghost.type, boardCtx);
+ boardCtx.restore();
+ }
+
+ function drawBoard() {
+ paintArenaBackground(boardCtx, cols, rows);
+ drawGrid(boardCtx, cols, rows);
+ state.board.forEach((row, y) => {
+ row.forEach((value, x) => {
+ if (value !== 0) {
+ drawCell(x, y, value, boardCtx);
+ }
+ });
+ });
+ drawGhostPiece();
+ if (state.piece) {
+ drawMatrix(state.piece.matrix, state.piece.pos, state.piece.type, boardCtx);
+ }
+ }
+
+ function drawNextPiece() {
+ nextCtx.clearRect(0, 0, 4, 4);
+ paintArenaBackground(nextCtx, 4, 4);
+ drawGrid(nextCtx, 4, 4);
+ const nextType = state.nextQueue[0];
+ if (!nextType) {
+ return;
+ }
+ const preview = pieces[nextType];
+ const width = preview[0].length;
+ const height = preview.length;
+ const offset = { x: (4 - width) / 2, y: (4 - height) / 2 };
+ drawMatrix(preview, offset, nextType, nextCtx);
+ }
+
+ function draw() {
+ drawBoard();
+ drawNextPiece();
+ }
+
+ function formatDuration(seconds) {
+ return `${seconds}s`;
+ }
+
+ function updateStats() {
+ scoreValue.textContent = state.score.toLocaleString();
+ levelValue.textContent = state.level.toLocaleString();
+ linesValue.textContent = state.lines.toLocaleString();
+ if (!state.isGameOver && state.isStarted && (statusValue.textContent === 'Ready' || statusValue.textContent === 'Press Start')) {
+ statusValue.textContent = 'Running';
+ }
+ }
+
+ function openSaveForm() {
+ const durationSeconds = Math.max(1, Math.round((Date.now() - state.startedAt) / 1000));
+ savePlaceholder?.classList.add('d-none');
+ saveForm?.classList.remove('d-none');
+ if (runSummary) {
+ runSummary.textContent = `Run complete · ${state.score.toLocaleString()} points, ${state.lines} lines, level ${state.level}, ${durationSeconds}s.`;
+ }
+ if (finalScore) finalScore.value = state.score.toLocaleString();
+ if (finalLines) finalLines.value = String(state.lines);
+ if (finalLevel) finalLevel.value = String(state.level);
+ if (finalDuration) finalDuration.value = formatDuration(durationSeconds);
+ if (scoreInput) scoreInput.value = String(state.score);
+ if (linesInput) linesInput.value = String(state.lines);
+ if (levelInput) levelInput.value = String(state.level);
+ if (durationInput) durationInput.value = String(durationSeconds);
+ if (playerNameInput) {
+ playerNameInput.value = sanitizePlayerName(playerNameInput.value || loadRememberedPlayerName());
+ playerNameInput.focus({ preventScroll: false });
+ }
+ }
+
+ function hideSaveForm() {
+ savePlaceholder?.classList.remove('d-none');
+ saveForm?.classList.add('d-none');
+ }
+
+ function endGame() {
+ state.isGameOver = true;
+ state.isStarted = false;
+ statusValue.textContent = 'Game over';
+ draw();
+ openSaveForm();
+ showToast('Game over', 'Save your run or restart immediately.');
+ }
+
+ function updateStartUi() {
+ if (startOverlay) {
+ startOverlay.classList.toggle('is-hidden', state.isStarted || state.isGameOver);
+ }
+ startButtons.forEach((button) => {
+ button.disabled = state.isStarted || state.isGameOver;
+ });
+ }
+
+ function startGame() {
+ if (state.isStarted || state.isGameOver) {
+ return;
+ }
+ state.isStarted = true;
+ state.startedAt = Date.now();
+ state.lastTime = performance.now();
+ state.dropCounter = 0;
+ statusValue.textContent = 'Running';
+ updateStartUi();
+ boardCanvas.focus?.();
+ showToast('Game started', 'Good luck.');
+ }
+
+ function resetGame(showNotice = true) {
+ state.board = createMatrix(cols, rows);
+ state.nextQueue = [];
+ state.score = 0;
+ state.lines = 0;
+ state.level = 1;
+ state.dropInterval = 900;
+ state.dropCounter = 0;
+ state.lastTime = 0;
+ state.isGameOver = false;
+ state.isStarted = false;
+ state.startedAt = null;
+ statusValue.textContent = 'Press Start';
+ hideSaveForm();
+ ensureQueue();
+ spawnPiece();
+ updateStats();
+ draw();
+ updateStartUi();
+ if (showNotice) {
+ showToast('Board reset', 'Press Start when you are ready.');
+ }
+ }
+
+ function announce(message) {
+ if (!statusValue || state.isGameOver || !state.isStarted) {
+ return;
+ }
+ statusValue.textContent = message;
+ clearTimeout(announce.timer);
+ announce.timer = setTimeout(() => {
+ if (!state.isGameOver && state.isStarted) {
+ statusValue.textContent = 'Running';
+ }
+ }, 900);
+ }
+
+ function showToast(title, message) {
+ const container = document.getElementById('toast-container');
+ if (!container || typeof bootstrap === 'undefined') {
+ return;
+ }
+ const wrapper = document.createElement('div');
+ wrapper.className = 'toast align-items-center text-bg-dark border-0';
+ wrapper.role = 'status';
+ wrapper.ariaLive = 'polite';
+ wrapper.ariaAtomic = 'true';
+ wrapper.innerHTML = `
+
+ ${message}
+ `;
+ container.appendChild(wrapper);
+ const toast = new bootstrap.Toast(wrapper, { delay: 2400 });
+ wrapper.addEventListener('hidden.bs.toast', () => wrapper.remove());
+ toast.show();
+ }
+
+ function update(time = 0) {
+ const deltaTime = time - state.lastTime;
+ state.lastTime = time;
+ if (state.isStarted) {
+ state.dropCounter += deltaTime;
+ }
+ if (!state.isGameOver && state.isStarted && state.dropCounter > state.dropInterval) {
+ playerDrop(false);
+ }
+ draw();
+ requestAnimationFrame(update);
+ }
+
+ function handleAction(action) {
+ if (!state.isStarted || state.isGameOver) {
+ return;
+ }
+ switch (action) {
+ case 'left':
+ movePlayer(-1);
+ break;
+ case 'right':
+ movePlayer(1);
+ break;
+ case 'down':
+ playerDrop(true);
+ break;
+ case 'rotate':
+ attemptRotate(1);
+ break;
+ case 'drop':
+ hardDrop();
+ break;
+ default:
+ break;
+ }
+ }
+
+ document.addEventListener('keydown', (event) => {
+ if ([37, 38, 39, 40, 32].includes(event.keyCode)) {
+ event.preventDefault();
+ }
+ if (event.key === 'ArrowLeft') handleAction('left');
+ if (event.key === 'ArrowRight') handleAction('right');
+ if (event.key === 'ArrowDown') handleAction('down');
+ if (event.key === 'ArrowUp') handleAction('rotate');
+ if (event.code === 'Space') handleAction('drop');
+ }, { passive: false });
+
+ controlButtons.forEach((button) => {
+ const runAction = (event) => {
+ event.preventDefault();
+ handleAction(button.dataset.action);
+ };
+ button.addEventListener('click', runAction);
+ button.addEventListener('touchstart', runAction, { passive: false });
+ });
+
+ restartButtons.forEach((button) => {
+ button.addEventListener('click', () => resetGame());
+ });
+
+ startButtons.forEach((button) => {
+ button.addEventListener('click', () => startGame());
+ });
+
+ focusButton?.addEventListener('click', () => {
+ boardCanvas.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ boardCanvas.focus?.();
+ });
+
+ [playerNameInput, roomNicknameInput].filter(Boolean).forEach((input) => {
+ input.addEventListener('input', () => {
+ syncNicknameFromInput(input);
+ });
+ });
+
+ roomCodeInput?.addEventListener('input', () => {
+ const clean = sanitizeRoomCode(roomCodeInput.value);
+ if (roomCodeInput.value !== clean) {
+ roomCodeInput.value = clean;
}
});
+
+ createRoomButton?.addEventListener('click', async () => {
+ const playerName = getActiveNickname();
+ if (!playerName) {
+ return;
+ }
+
+ createRoomButton.disabled = true;
+ joinRoomButton && (joinRoomButton.disabled = true);
+ try {
+ const payload = await sendRoomRequest('create', { player_name: playerName });
+ renderRoomState(payload.room);
+ startRoomPolling();
+ showToast('Room created', `Share code ${payload.room.room_code} with your friend.`);
+ } catch (error) {
+ showToast('Room error', error.message || 'Unable to create a room right now.');
+ } finally {
+ createRoomButton.disabled = false;
+ joinRoomButton && (joinRoomButton.disabled = false);
+ }
+ });
+
+ joinRoomButton?.addEventListener('click', async () => {
+ const playerName = getActiveNickname();
+ const roomCode = sanitizeRoomCode(roomCodeInput?.value || '');
+ if (!playerName) {
+ return;
+ }
+ if (!roomCode || roomCode.length !== 6) {
+ showToast('Room code needed', 'Enter the 6-character room code from your friend.');
+ roomCodeInput?.focus({ preventScroll: false });
+ return;
+ }
+
+ createRoomButton && (createRoomButton.disabled = true);
+ joinRoomButton.disabled = true;
+ try {
+ const payload = await sendRoomRequest('join', { player_name: playerName, room_code: roomCode });
+ renderRoomState(payload.room);
+ startRoomPolling();
+ showToast('Room joined', `You joined room ${payload.room.room_code}.`);
+ } catch (error) {
+ showToast('Join failed', error.message || 'Unable to join the room right now.');
+ } finally {
+ createRoomButton && (createRoomButton.disabled = false);
+ joinRoomButton.disabled = false;
+ }
+ });
+
+ refreshRoomStatusButton?.addEventListener('click', () => {
+ if (!currentRoomCode) {
+ showToast('No room yet', 'Create a room or join one first.');
+ return;
+ }
+ syncRoomStatus(currentRoomCode);
+ });
+
+ copyRoomCodeButton?.addEventListener('click', async () => {
+ if (!currentRoomCode) {
+ showToast('No room yet', 'Create a room first to copy its code.');
+ return;
+ }
+ try {
+ if (navigator.clipboard?.writeText) {
+ await navigator.clipboard.writeText(currentRoomCode);
+ showToast('Code copied', `${currentRoomCode} copied to clipboard.`);
+ return;
+ }
+ } catch (error) {
+ // Fallback below.
+ }
+ if (roomCodeInput) {
+ roomCodeInput.value = currentRoomCode;
+ roomCodeInput.focus({ preventScroll: false });
+ roomCodeInput.select();
+ }
+ showToast('Copy ready', `Room code ${currentRoomCode} is selected.`);
+ });
+
+ const rememberedName = loadRememberedPlayerName();
+ if (rememberedName) {
+ applyNicknameToInputs(rememberedName);
+ }
+
+ const rememberedRoomCode = loadRememberedRoomCode();
+ if (roomCodeInput && rememberedRoomCode && !roomCodeInput.value.trim()) {
+ roomCodeInput.value = rememberedRoomCode;
+ }
+
+ saveForm?.addEventListener('submit', () => {
+ if (playerNameInput) {
+ playerNameInput.value = rememberPlayerName(playerNameInput.value);
+ }
+ });
+
+ if (pageConfig.saved) {
+ showToast('Score saved', pageConfig.scoreId ? `Your run was added to the leaderboard. #${pageConfig.scoreId}` : 'Your run was added to the leaderboard.');
+ }
+ if (pageConfig.saveError) {
+ showToast('Save failed', pageConfig.saveError);
+ }
+
+ resetGame(false);
+ syncLeaderboard();
+ window.setInterval(syncLeaderboard, 15000);
+ if (rememberedRoomCode) {
+ currentRoomCode = rememberedRoomCode;
+ syncRoomStatus(rememberedRoomCode, { silent: true });
+ }
+ requestAnimationFrame(update);
});
diff --git a/db/migrations/20260325_create_tetris_rooms.sql b/db/migrations/20260325_create_tetris_rooms.sql
new file mode 100644
index 0000000..61b5116
--- /dev/null
+++ b/db/migrations/20260325_create_tetris_rooms.sql
@@ -0,0 +1,15 @@
+-- Step 1 of Tetris 1v1 multiplayer: room lobby storage
+CREATE TABLE IF NOT EXISTS tetris_rooms (
+ id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ room_code VARCHAR(6) NOT NULL,
+ host_name VARCHAR(24) NOT NULL,
+ guest_name VARCHAR(24) DEFAULT NULL,
+ status ENUM('waiting', 'ready', 'closed') NOT NULL DEFAULT 'waiting',
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ expires_at TIMESTAMP NULL DEFAULT NULL,
+ UNIQUE KEY uniq_room_code (room_code),
+ KEY idx_status (status),
+ KEY idx_expires_at (expires_at),
+ KEY idx_updated_at (updated_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
diff --git a/index.php b/index.php
index 7205f3d..dfb755d 100644
--- a/index.php
+++ b/index.php
@@ -1,150 +1,346 @@
-
-
- New Style
-
-
-
-
-
-
-
-
-
+
+
+ = htmlspecialchars($title) ?>
+
+
+
+
+
-
-
-
-
+
+
-
-
-
-
+
+
-
-
-
-
Analyzing your requirements and generating your website…
-
-
Loading…
+
+
+
+
+
Midnight Blocks
+
+
+
+ Best
+ = number_format((int) $bestRun['score']) ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
= htmlspecialchars($dbError) ?>
+
+
+
+
Score saved= $scoreId > 0 ? ' — run #' . (int) $scoreId : '' ?>.
+
+
+
+
= htmlspecialchars($saveError) ?>
+
+
+
+
+
+
+
+
+
+
+
+ Ready when you are
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 1v1 rooms beta
+ Step 1
+
+
+ Create a room, share the code with a friend, and wait for them to join. This step builds the multiplayer lobby and ready-state.
+
+
+
+
+
We will reuse this nickname for room invites and score publishing.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
No active room yet. Create one or paste a code from your friend.
+
+
+
+
+
+
Current room
+
------
+
+
Waiting
+
+
+
+
+ Host
+ —
+
+
+ Guest
+ Waiting for friend…
+
+
+
+
+
+ Share the code with your friend. The lobby will switch to ready once they join.
+
+
+
+
+
+
+
+
+
+
+ Online leaderboard
+ Public nickname
+
+
+
+
Finish a run to publish your score online.
+
+
+
+
+
+
+
+ Online top scores
+ Live sync
+
+
+ Best: = htmlspecialchars($bestRun['player_name']) ?>Waiting for first score
+
+
+
+
+
+
No online scores yet.
+
+
+
+
+
+ Recent players
+ Latest runs
+
+
+
+
+
+
No recent online runs yet.
+
+
+
+
-
= ($_SERVER['HTTP_HOST'] ?? '') === 'appwizzy.com' ? 'AppWizzy' : 'Flatlogic' ?> AI is collecting your requirements and applying the first changes.
-
This page will update automatically as the plan is implemented.
-
Runtime: PHP = htmlspecialchars($phpVersion) ?> — UTC = htmlspecialchars($now) ?>
-
+
+
+
+
+
+
diff --git a/multiplayer_data.php b/multiplayer_data.php
new file mode 100644
index 0000000..0b8e1ea
--- /dev/null
+++ b/multiplayer_data.php
@@ -0,0 +1,217 @@
+exec(
+ "CREATE TABLE IF NOT EXISTS tetris_rooms (
+ id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ room_code VARCHAR(6) NOT NULL,
+ host_name VARCHAR(24) NOT NULL,
+ guest_name VARCHAR(24) DEFAULT NULL,
+ status ENUM('waiting', 'ready', 'closed') NOT NULL DEFAULT 'waiting',
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ expires_at TIMESTAMP NULL DEFAULT NULL,
+ UNIQUE KEY uniq_room_code (room_code),
+ KEY idx_status (status),
+ KEY idx_expires_at (expires_at),
+ KEY idx_updated_at (updated_at)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
+ );
+
+ $ready = true;
+}
+
+function multiplayerSanitizePlayerName(string $value): string
+{
+ $value = trim($value);
+ if ($value === '') {
+ throw new InvalidArgumentException('Enter your nickname first.');
+ }
+
+ if (!preg_match('/^[\p{L}\p{N} _.-]+$/u', $value)) {
+ throw new InvalidArgumentException('Nickname can use letters, numbers, spaces, dots, dashes, and underscores only.');
+ }
+
+ $length = function_exists('mb_strlen') ? mb_strlen($value) : strlen($value);
+ if ($length > 24) {
+ throw new InvalidArgumentException('Nickname must be 24 characters or fewer.');
+ }
+
+ return $value;
+}
+
+function multiplayerNormalizeRoomCode(string $value): string
+{
+ $value = strtoupper(preg_replace('/[^A-Z0-9]+/', '', $value) ?? '');
+ if ($value === '') {
+ throw new InvalidArgumentException('Enter a room code.');
+ }
+ if (strlen($value) !== TETRIS_ROOM_CODE_LENGTH) {
+ throw new InvalidArgumentException('Room code must be exactly 6 characters.');
+ }
+ return $value;
+}
+
+function multiplayerFormatRoom(array $room): array
+{
+ $createdAt = strtotime((string) ($room['created_at'] ?? ''));
+ $updatedAt = strtotime((string) ($room['updated_at'] ?? ''));
+ $expiresAt = strtotime((string) ($room['expires_at'] ?? ''));
+ $guestName = isset($room['guest_name']) ? trim((string) $room['guest_name']) : '';
+ $status = (string) ($room['status'] ?? 'waiting');
+ $playerCount = $guestName !== '' ? 2 : 1;
+
+ return [
+ 'id' => (int) ($room['id'] ?? 0),
+ 'room_code' => (string) ($room['room_code'] ?? ''),
+ 'host_name' => (string) ($room['host_name'] ?? ''),
+ 'guest_name' => $guestName !== '' ? $guestName : null,
+ 'status' => $status,
+ 'player_count' => $playerCount,
+ 'is_ready' => $status === 'ready' && $playerCount >= 2,
+ 'created_at_iso' => $createdAt ? gmdate(DATE_ATOM, $createdAt) : null,
+ 'updated_at_iso' => $updatedAt ? gmdate(DATE_ATOM, $updatedAt) : null,
+ 'expires_at_iso' => $expiresAt ? gmdate(DATE_ATOM, $expiresAt) : null,
+ ];
+}
+
+function multiplayerFetchRoomByCode(string $roomCode): ?array
+{
+ multiplayerEnsureSchema();
+ $roomCode = multiplayerNormalizeRoomCode($roomCode);
+
+ $stmt = db()->prepare(
+ 'SELECT id, room_code, host_name, guest_name, status, created_at, updated_at, expires_at
+ FROM tetris_rooms
+ WHERE room_code = :room_code
+ AND (expires_at IS NULL OR expires_at >= UTC_TIMESTAMP())
+ LIMIT 1'
+ );
+ $stmt->bindValue(':room_code', $roomCode, PDO::PARAM_STR);
+ $stmt->execute();
+ $room = $stmt->fetch();
+
+ return $room ? multiplayerFormatRoom($room) : null;
+}
+
+function multiplayerGenerateRoomCode(): string
+{
+ $alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
+ $maxIndex = strlen($alphabet) - 1;
+ $roomCode = '';
+
+ for ($i = 0; $i < TETRIS_ROOM_CODE_LENGTH; $i += 1) {
+ $roomCode .= $alphabet[random_int(0, $maxIndex)];
+ }
+
+ return $roomCode;
+}
+
+function multiplayerCreateRoom(string $hostName): array
+{
+ multiplayerEnsureSchema();
+ $hostName = multiplayerSanitizePlayerName($hostName);
+
+ $expiresAt = gmdate('Y-m-d H:i:s', time() + (TETRIS_ROOM_TTL_HOURS * 3600));
+
+ for ($attempt = 0; $attempt < 12; $attempt += 1) {
+ $roomCode = multiplayerGenerateRoomCode();
+ try {
+ $stmt = db()->prepare(
+ 'INSERT INTO tetris_rooms (room_code, host_name, status, expires_at)
+ VALUES (:room_code, :host_name, :status, :expires_at)'
+ );
+ $stmt->bindValue(':room_code', $roomCode, PDO::PARAM_STR);
+ $stmt->bindValue(':host_name', $hostName, PDO::PARAM_STR);
+ $stmt->bindValue(':status', 'waiting', PDO::PARAM_STR);
+ $stmt->bindValue(':expires_at', $expiresAt, PDO::PARAM_STR);
+ $stmt->execute();
+
+ $room = multiplayerFetchRoomByCode($roomCode);
+ if ($room) {
+ return $room;
+ }
+ } catch (PDOException $e) {
+ if ((string) $e->getCode() !== '23000') {
+ throw $e;
+ }
+ }
+ }
+
+ throw new RuntimeException('Unable to create a room right now. Please try again.');
+}
+
+function multiplayerJoinRoom(string $roomCode, string $guestName): array
+{
+ multiplayerEnsureSchema();
+ $roomCode = multiplayerNormalizeRoomCode($roomCode);
+ $guestName = multiplayerSanitizePlayerName($guestName);
+
+ $pdo = db();
+ $pdo->beginTransaction();
+
+ try {
+ $stmt = $pdo->prepare(
+ 'SELECT id, room_code, host_name, guest_name, status, created_at, updated_at, expires_at
+ FROM tetris_rooms
+ WHERE room_code = :room_code
+ AND (expires_at IS NULL OR expires_at >= UTC_TIMESTAMP())
+ LIMIT 1
+ FOR UPDATE'
+ );
+ $stmt->bindValue(':room_code', $roomCode, PDO::PARAM_STR);
+ $stmt->execute();
+ $room = $stmt->fetch();
+
+ if (!$room) {
+ throw new InvalidArgumentException('Room not found or already expired.');
+ }
+
+ $existingGuest = trim((string) ($room['guest_name'] ?? ''));
+ if ($existingGuest !== '' && strcasecmp($existingGuest, $guestName) !== 0) {
+ throw new InvalidArgumentException('This room already has two players.');
+ }
+
+ if ($existingGuest === '') {
+ $expiresAt = gmdate('Y-m-d H:i:s', time() + (TETRIS_ROOM_TTL_HOURS * 3600));
+ $update = $pdo->prepare(
+ 'UPDATE tetris_rooms
+ SET guest_name = :guest_name,
+ status = :status,
+ expires_at = :expires_at
+ WHERE id = :id'
+ );
+ $update->bindValue(':guest_name', $guestName, PDO::PARAM_STR);
+ $update->bindValue(':status', 'ready', PDO::PARAM_STR);
+ $update->bindValue(':expires_at', $expiresAt, PDO::PARAM_STR);
+ $update->bindValue(':id', (int) $room['id'], PDO::PARAM_INT);
+ $update->execute();
+ }
+
+ $pdo->commit();
+ } catch (Throwable $e) {
+ if ($pdo->inTransaction()) {
+ $pdo->rollBack();
+ }
+ throw $e;
+ }
+
+ $freshRoom = multiplayerFetchRoomByCode($roomCode);
+ if (!$freshRoom) {
+ throw new RuntimeException('Room state could not be loaded.');
+ }
+
+ return $freshRoom;
+}
diff --git a/save_score.php b/save_score.php
new file mode 100644
index 0000000..da1eccb
--- /dev/null
+++ b/save_score.php
@@ -0,0 +1,26 @@
+ $_POST['player_name'] ?? '',
+ 'score' => $_POST['score'] ?? 0,
+ 'lines_cleared' => $_POST['lines_cleared'] ?? 0,
+ 'level_reached' => $_POST['level_reached'] ?? 1,
+ 'duration_seconds' => $_POST['duration_seconds'] ?? 0,
+ ]);
+
+ header('Location: /?saved=1&score_id=' . $scoreId . '#leaderboard');
+ exit;
+} catch (Throwable $e) {
+ $message = rawurlencode($e instanceof InvalidArgumentException ? $e->getMessage() : 'Unable to save the score right now.');
+ header('Location: /?save_error=' . $message . '#save-score');
+ exit;
+}
diff --git a/score.php b/score.php
new file mode 100644
index 0000000..dc86a4a
--- /dev/null
+++ b/score.php
@@ -0,0 +1,139 @@
+ 0) {
+ $score = tetrisFetchScoreById($scoreId);
+ }
+} catch (Throwable $e) {
+ $dbError = 'Leaderboard data is temporarily unavailable.';
+}
+
+$title = $score ? $score['player_name'] . ' run · ' . $projectName : 'Run not found · ' . $projectName;
+$metaDescription = $score
+ ? sprintf('%s scored %d points with %d cleared lines in Midnight Blocks.', $score['player_name'], $score['score'], $score['lines_cleared'])
+ : $projectDescription;
+?>
+
+
+
+
+
+
= htmlspecialchars($title) ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
= htmlspecialchars($dbError) ?>
+
+
+
+
+ Run detail
+ That score could not be found.
+ Try another run from the leaderboard or start a fresh game.
+
+
+
+
+
+
+
Saved run
+
= htmlspecialchars($score['player_name']) ?>’s session
+
Recorded = htmlspecialchars(date('M j, Y H:i', strtotime((string) $score['created_at']))) ?> UTC.
+
+
+ Score
+ = number_format((int) $score['score']) ?>
+
+
+
+
+
+ Lines cleared
+ = number_format((int) $score['lines_cleared']) ?>
+
+
+
+
+ Level reached
+ = number_format((int) $score['level_reached']) ?>
+
+
+
+
+ Session length
+ = number_format((int) $score['duration_seconds']) ?>s
+
+
+
+
+
+
+ Breakdown
+
+
+
What this run means
+
+ - High score value reflects line clears plus soft and hard drop bonuses.
+ - Levels increase every 10 cleared lines, which accelerates the drop speed.
+ - Each saved run can be revisited from the leaderboard for quick comparison.
+
+
+
+
+
Next challenge
+
Beat this run by improving stacking efficiency and chaining doubles or tetrises.
+
Start a new run
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tetris_data.php b/tetris_data.php
new file mode 100644
index 0000000..d22b81f
--- /dev/null
+++ b/tetris_data.php
@@ -0,0 +1,119 @@
+exec(
+ "CREATE TABLE IF NOT EXISTS tetris_scores (
+ id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ player_name VARCHAR(24) NOT NULL,
+ score INT UNSIGNED NOT NULL DEFAULT 0,
+ lines_cleared INT UNSIGNED NOT NULL DEFAULT 0,
+ level_reached INT UNSIGNED NOT NULL DEFAULT 1,
+ duration_seconds INT UNSIGNED NOT NULL DEFAULT 0,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ INDEX idx_score (score DESC, lines_cleared DESC),
+ INDEX idx_created_at (created_at DESC)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
+ );
+
+ $ready = true;
+}
+
+function tetrisFetchLeaderboard(int $limit = 10): array
+{
+ tetrisEnsureSchema();
+ $stmt = db()->prepare(
+ 'SELECT id, player_name, score, lines_cleared, level_reached, duration_seconds, created_at
+ FROM tetris_scores
+ ORDER BY score DESC, lines_cleared DESC, duration_seconds ASC, id ASC
+ LIMIT :limit'
+ );
+ $stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
+ $stmt->execute();
+
+ return $stmt->fetchAll();
+}
+
+function tetrisFetchRecent(int $limit = 8): array
+{
+ tetrisEnsureSchema();
+ $stmt = db()->prepare(
+ 'SELECT id, player_name, score, lines_cleared, level_reached, duration_seconds, created_at
+ FROM tetris_scores
+ ORDER BY created_at DESC, id DESC
+ LIMIT :limit'
+ );
+ $stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
+ $stmt->execute();
+
+ return $stmt->fetchAll();
+}
+
+function tetrisFetchScoreById(int $id): ?array
+{
+ tetrisEnsureSchema();
+ $stmt = db()->prepare(
+ 'SELECT id, player_name, score, lines_cleared, level_reached, duration_seconds, created_at
+ FROM tetris_scores
+ WHERE id = :id
+ LIMIT 1'
+ );
+ $stmt->bindValue(':id', $id, PDO::PARAM_INT);
+ $stmt->execute();
+ $score = $stmt->fetch();
+
+ return $score ?: null;
+}
+
+function tetrisFetchBestScore(): ?array
+{
+ $scores = tetrisFetchLeaderboard(1);
+ return $scores[0] ?? null;
+}
+
+function tetrisSaveScore(array $input): int
+{
+ tetrisEnsureSchema();
+
+ $name = trim((string) ($input['player_name'] ?? ''));
+ if ($name === '') {
+ throw new InvalidArgumentException('Enter your name to save the run.');
+ }
+ $nameLength = function_exists('mb_strlen') ? mb_strlen($name) : strlen($name);
+ if ($nameLength > 24) {
+ throw new InvalidArgumentException('Name must be 24 characters or fewer.');
+ }
+ if (!preg_match('/^[\p{L}\p{N} _.-]+$/u', $name)) {
+ throw new InvalidArgumentException('Use letters, numbers, spaces, dots, dashes, or underscores only.');
+ }
+
+ $score = max(0, min(999999, (int) ($input['score'] ?? 0)));
+ $lines = max(0, min(9999, (int) ($input['lines_cleared'] ?? 0)));
+ $level = max(1, min(999, (int) ($input['level_reached'] ?? 1)));
+ $duration = max(0, min(86400, (int) ($input['duration_seconds'] ?? 0)));
+
+ if ($score === 0 && $lines === 0) {
+ throw new InvalidArgumentException('Finish a run before saving to the leaderboard.');
+ }
+
+ $stmt = db()->prepare(
+ 'INSERT INTO tetris_scores (player_name, score, lines_cleared, level_reached, duration_seconds)
+ VALUES (:player_name, :score, :lines_cleared, :level_reached, :duration_seconds)'
+ );
+ $stmt->bindValue(':player_name', $name, PDO::PARAM_STR);
+ $stmt->bindValue(':score', $score, PDO::PARAM_INT);
+ $stmt->bindValue(':lines_cleared', $lines, PDO::PARAM_INT);
+ $stmt->bindValue(':level_reached', $level, PDO::PARAM_INT);
+ $stmt->bindValue(':duration_seconds', $duration, PDO::PARAM_INT);
+ $stmt->execute();
+
+ return (int) db()->lastInsertId();
+}