multiplayer / templates /index.html
triflix's picture
Create templates/index.html
3f5cc95 verified
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>🏎️ TypeRacer – Real-time Multiplayer</title>
<style>
/* ═══════════════════════════════════════
RESET & ROOT VARIABLES
═══════════════════════════════════════ */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0d0d1a;
--panel: #13132a;
--panel2: #1a1a35;
--accent: #7c3aed;
--accent2: #a855f7;
--green: #22c55e;
--red: #ef4444;
--yellow: #eab308;
--text: #e2e8f0;
--muted: #64748b;
--border: #2d2d5e;
--track-bg: #1e1e3f;
--track-line: #2d2d6e;
--road: #374151;
--road-mark: #4b5563;
--glow: 0 0 20px rgba(124,58,237,0.4);
--font: 'Segoe UI', system-ui, sans-serif;
}
body {
font-family: var(--font);
background: var(--bg);
color: var(--text);
min-height: 100vh;
overflow-x: hidden;
}
/* ═══════════════════════════════════════
SCROLLBAR
═══════════════════════════════════════ */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: var(--bg); }
::-webkit-scrollbar-thumb { background: var(--accent); border-radius: 3px; }
/* ═══════════════════════════════════════
HEADER
═══════════════════════════════════════ */
header {
background: linear-gradient(135deg, #1a0533 0%, #0d0d1a 100%);
border-bottom: 2px solid var(--border);
padding: 14px 24px;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: var(--glow);
position: sticky;
top: 0;
z-index: 100;
}
.logo {
display: flex;
align-items: center;
gap: 10px;
font-size: 1.5rem;
font-weight: 800;
background: linear-gradient(90deg, #a855f7, #7c3aed, #3b82f6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
letter-spacing: -0.5px;
}
.logo span { font-size: 1.8rem; }
.header-info {
display: flex;
align-items: center;
gap: 16px;
font-size: 0.85rem;
}
.badge {
background: var(--panel2);
border: 1px solid var(--border);
border-radius: 20px;
padding: 5px 14px;
font-weight: 600;
display: flex;
align-items: center;
gap: 6px;
}
.status-dot {
width: 8px; height: 8px;
border-radius: 50%;
background: var(--muted);
transition: background 0.3s;
}
.status-dot.connected { background: var(--green); box-shadow: 0 0 8px var(--green); }
.status-dot.error { background: var(--red); box-shadow: 0 0 8px var(--red); }
/* ═══════════════════════════════════════
MAIN LAYOUT
═══════════════════════════════════════ */
main {
max-width: 1100px;
margin: 0 auto;
padding: 24px 16px;
display: flex;
flex-direction: column;
gap: 20px;
}
/* ═══════════════════════════════════════
WAITING / COUNTDOWN OVERLAY
═══════════════════════════════════════ */
#overlay {
position: fixed;
inset: 0;
background: rgba(10, 10, 26, 0.92);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
flex-direction: column;
gap: 20px;
}
.overlay-card {
background: var(--panel);
border: 2px solid var(--border);
border-radius: 24px;
padding: 48px 56px;
text-align: center;
max-width: 480px;
width: 90%;
box-shadow: var(--glow), 0 24px 64px rgba(0,0,0,0.6);
}
.overlay-title {
font-size: 2.2rem;
font-weight: 900;
background: linear-gradient(90deg, #a855f7, #3b82f6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 8px;
}
.overlay-sub {
color: var(--muted);
font-size: 0.95rem;
margin-bottom: 28px;
}
/* Spinning loader */
.loader {
width: 56px; height: 56px;
border: 4px solid var(--border);
border-top-color: var(--accent2);
border-radius: 50%;
animation: spin 0.9s linear infinite;
margin: 0 auto 20px;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Countdown number */
.countdown-number {
font-size: 6rem;
font-weight: 900;
line-height: 1;
background: linear-gradient(135deg, #f59e0b, #ef4444);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
animation: pulse 1s ease-in-out infinite;
}
@keyframes pulse {
0%,100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.1); opacity: 0.8; }
}
.waiting-players {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 200px;
overflow-y: auto;
margin-top: 16px;
}
.waiting-player-chip {
background: var(--panel2);
border: 1px solid var(--border);
border-radius: 10px;
padding: 8px 14px;
font-size: 0.85rem;
display: flex;
align-items: center;
gap: 10px;
}
/* ═══════════════════════════════════════
PLAYERS INFO BAR
═══════════════════════════════════════ */
#players-bar {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 16px;
padding: 14px 20px;
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: center;
}
.player-chip {
display: flex;
align-items: center;
gap: 8px;
background: var(--panel2);
border: 1px solid var(--border);
border-radius: 20px;
padding: 6px 14px;
font-size: 0.82rem;
font-weight: 600;
transition: border-color 0.2s;
}
.player-chip.me { border-color: var(--accent2); }
.player-chip .dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
/* ═══════════════════════════════════════
RACE TRACK
═══════════════════════════════════════ */
#race-track {
background: var(--track-bg);
border: 2px solid var(--border);
border-radius: 20px;
padding: 20px 20px 20px 20px;
overflow: hidden;
display: flex;
flex-direction: column;
gap: 0;
box-shadow: inset 0 0 40px rgba(0,0,0,0.4);
}
.lane {
position: relative;
height: 76px;
display: flex;
align-items: center;
border-bottom: 2px dashed var(--track-line);
padding: 0 8px;
}
.lane:last-child { border-bottom: none; }
/* Road texture inside lane */
.lane::before {
content: '';
position: absolute;
inset: 0;
background: repeating-linear-gradient(
90deg,
transparent 0px,
transparent 40px,
rgba(255,255,255,0.025) 40px,
rgba(255,255,255,0.025) 41px
);
pointer-events: none;
}
/* Lane label */
.lane-label {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
font-size: 0.72rem;
color: var(--muted);
font-weight: 700;
letter-spacing: 0.5px;
white-space: nowrap;
z-index: 2;
width: 80px;
overflow: hidden;
text-overflow: ellipsis;
}
/* Progress bar inside lane */
.lane-progress-bg {
position: absolute;
left: 90px;
right: 10px;
height: 6px;
background: rgba(255,255,255,0.06);
border-radius: 3px;
bottom: 14px;
}
.lane-progress-fill {
height: 100%;
border-radius: 3px;
transition: width 0.3s ease;
width: 0%;
}
/* ── Car (pure SVG/CSS) ── */
.car-wrapper {
position: absolute;
left: 90px;
/* right boundary = track-width - car-width - right-padding */
/* We move it via JS: transform: translateX(px) */
top: 50%;
transform: translateY(-50%) translateX(0px);
transition: transform 0.35s cubic-bezier(0.25, 0.46, 0.45, 0.94);
z-index: 5;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
/* WPM badge above car */
.car-wpm {
font-size: 0.65rem;
font-weight: 800;
color: #fff;
background: rgba(0,0,0,0.6);
border-radius: 6px;
padding: 1px 5px;
white-space: nowrap;
}
/* SVG Car container */
.car-svg-wrap { line-height: 0; }
/* Finish flag */
.finish-flag {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
font-size: 1.6rem;
z-index: 3;
}
/* ═══════════════════════════════════════
TYPING SECTION
═══════════════════════════════════════ */
#typing-section {
background: var(--panel);
border: 2px solid var(--border);
border-radius: 20px;
padding: 28px;
display: flex;
flex-direction: column;
gap: 20px;
}
/* ── Stats row ── */
.stats-row {
display: flex;
gap: 14px;
flex-wrap: wrap;
}
.stat-box {
background: var(--panel2);
border: 1px solid var(--border);
border-radius: 14px;
padding: 12px 20px;
min-width: 90px;
text-align: center;
flex: 1;
}
.stat-value {
font-size: 2rem;
font-weight: 900;
line-height: 1;
background: linear-gradient(135deg, #a855f7, #3b82f6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.stat-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--muted);
margin-top: 4px;
}
/* ── Paragraph display ── */
#paragraph-display {
font-size: 1.25rem;
line-height: 1.9;
letter-spacing: 0.3px;
padding: 18px 20px;
background: var(--panel2);
border: 1px solid var(--border);
border-radius: 14px;
min-height: 90px;
user-select: none;
font-family: 'Courier New', monospace;
}
/* Word states */
.word { display: inline; }
.word .char { transition: color 0.1s; }
.char.correct { color: var(--green); }
.char.wrong { color: var(--red); background: rgba(239,68,68,0.15); border-radius: 2px; }
.char.current {
color: #fff;
background: var(--accent);
border-radius: 3px;
padding: 0 1px;
box-shadow: 0 0 8px var(--accent2);
}
.char.pending { color: var(--muted); }
/* Cursor blink on active char */
.char.current { animation: blink-bg 1s step-start infinite; }
@keyframes blink-bg {
50% { background: var(--accent2); }
}
/* ── Input ── */
#typing-input {
width: 100%;
padding: 16px 20px;
background: var(--panel2);
border: 2px solid var(--border);
border-radius: 14px;
color: var(--text);
font-size: 1.1rem;
font-family: 'Courier New', monospace;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
caret-color: var(--accent2);
}
#typing-input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(124,58,237,0.2);
}
#typing-input.correct-word { border-color: var(--green); }
#typing-input.wrong-word { border-color: var(--red); background: rgba(239,68,68,0.07); }
#typing-input:disabled { opacity: 0.4; cursor: not-allowed; }
/* ── Progress bar ── */
.progress-bar-wrap {
background: var(--panel2);
border: 1px solid var(--border);
border-radius: 10px;
height: 10px;
overflow: hidden;
}
#my-progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent), var(--accent2));
border-radius: 10px;
width: 0%;
transition: width 0.3s ease;
box-shadow: 0 0 10px var(--accent);
}
/* ═══════════════════════════════════════
RESULTS MODAL
═══════════════════════════════════════ */
#results-modal {
position: fixed;
inset: 0;
background: rgba(10, 10, 26, 0.92);
backdrop-filter: blur(8px);
z-index: 300;
display: flex;
align-items: center;
justify-content: center;
display: none;
}
.results-card {
background: var(--panel);
border: 2px solid var(--border);
border-radius: 24px;
padding: 40px;
max-width: 520px;
width: 90%;
box-shadow: var(--glow), 0 24px 64px rgba(0,0,0,0.6);
max-height: 90vh;
overflow-y: auto;
}
.results-title {
font-size: 1.8rem;
font-weight: 900;
text-align: center;
background: linear-gradient(90deg, #f59e0b, #ef4444, #a855f7);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 28px;
}
.result-row {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 16px;
background: var(--panel2);
border: 1px solid var(--border);
border-radius: 14px;
margin-bottom: 10px;
transition: border-color 0.2s;
}
.result-row.me { border-color: var(--accent2); box-shadow: 0 0 12px rgba(168,85,247,0.2); }
.result-pos {
font-size: 1.6rem;
font-weight: 900;
min-width: 40px;
text-align: center;
}
.result-pos.first { color: #fbbf24; }
.result-pos.second { color: #94a3b8; }
.result-pos.third { color: #b45309; }
.result-name { flex: 1; font-weight: 700; font-size: 1rem; }
.result-wpm { font-size: 0.9rem; color: var(--accent2); font-weight: 700; }
.result-car-dot {
width: 14px; height: 14px;
border-radius: 50%;
flex-shrink: 0;
}
.play-again-btn {
width: 100%;
margin-top: 24px;
padding: 16px;
background: linear-gradient(135deg, var(--accent), #3b82f6);
color: #fff;
font-size: 1.1rem;
font-weight: 800;
border: none;
border-radius: 14px;
cursor: pointer;
transition: opacity 0.2s, transform 0.1s;
letter-spacing: 0.5px;
}
.play-again-btn:hover { opacity: 0.9; transform: translateY(-1px); }
.play-again-btn:active { transform: translateY(0); }
/* ═══════════════════════════════════════
NOTIFICATIONS (toast)
═══════════════════════════════════════ */
#toast-container {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 400;
display: flex;
flex-direction: column;
gap: 8px;
max-width: 320px;
}
.toast {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 12px;
padding: 12px 18px;
font-size: 0.85rem;
animation: slide-in 0.3s ease;
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
}
.toast.info { border-left: 4px solid var(--accent2); }
.toast.success { border-left: 4px solid var(--green); }
.toast.warning { border-left: 4px solid var(--yellow); }
.toast.error { border-left: 4px solid var(--red); }
@keyframes slide-in {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
/* ═══════════════════════════════════════
CHAT (minimal side strip)
═══════════════════════════════════════ */
#chat-section {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 16px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
#chat-messages {
max-height: 100px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 4px;
}
.chat-msg {
font-size: 0.8rem;
color: var(--muted);
}
.chat-msg strong { color: var(--accent2); }
.chat-row {
display: flex;
gap: 8px;
}
#chat-input {
flex: 1;
background: var(--panel2);
border: 1px solid var(--border);
border-radius: 10px;
color: var(--text);
font-size: 0.85rem;
padding: 8px 12px;
outline: none;
}
#chat-input:focus { border-color: var(--accent); }
#chat-send {
background: var(--accent);
border: none;
border-radius: 10px;
color: #fff;
padding: 8px 14px;
cursor: pointer;
font-size: 0.85rem;
font-weight: 700;
}
/* ═══════════════════════════════════════
RESPONSIVE
═══════════════════════════════════════ */
@media (max-width: 600px) {
header { flex-direction: column; gap: 8px; }
.overlay-card { padding: 32px 20px; }
.countdown-number { font-size: 4rem; }
#paragraph-display { font-size: 1rem; }
}
</style>
</head>
<body>
<!-- ═══════════ HEADER ═══════════ -->
<header>
<div class="logo"><span>🏎️</span> TypeRacer</div>
<div class="header-info">
<div class="badge">
<div class="status-dot" id="conn-dot"></div>
<span id="conn-label">Connecting…</span>
</div>
<div class="badge" id="my-name-badge">⏳ Joining…</div>
<div class="badge" id="room-badge" style="display:none">πŸšͺ Room: β€”</div>
</div>
</header>
<!-- ═══════════ WAITING / COUNTDOWN OVERLAY ═══════════ -->
<div id="overlay">
<div class="overlay-card">
<div class="overlay-title">🏁 TypeRacer</div>
<div class="overlay-sub" id="overlay-sub">Connecting to server…</div>
<div class="loader" id="overlay-loader"></div>
<div class="countdown-number" id="overlay-countdown" style="display:none"></div>
<div class="waiting-players" id="waiting-players-list"></div>
</div>
</div>
<!-- ═══════════ MAIN ═══════════ -->
<main>
<!-- Players bar -->
<div id="players-bar"></div>
<!-- Race Track -->
<div id="race-track"></div>
<!-- Typing Section -->
<div id="typing-section">
<!-- Stats -->
<div class="stats-row">
<div class="stat-box">
<div class="stat-value" id="stat-wpm">0</div>
<div class="stat-label">WPM</div>
</div>
<div class="stat-box">
<div class="stat-value" id="stat-acc">100%</div>
<div class="stat-label">Accuracy</div>
</div>
<div class="stat-box">
<div class="stat-value" id="stat-time">0s</div>
<div class="stat-label">Time</div>
</div>
<div class="stat-box">
<div class="stat-value" id="stat-prog">0%</div>
<div class="stat-label">Progress</div>
</div>
</div>
<!-- Progress bar -->
<div class="progress-bar-wrap">
<div id="my-progress-fill"></div>
</div>
<!-- Paragraph -->
<div id="paragraph-display">Waiting for race to start…</div>
<!-- Input -->
<input
type="text"
id="typing-input"
placeholder="Race starts soon β€” get ready!"
disabled
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
/>
</div>
<!-- Chat -->
<div id="chat-section">
<div id="chat-messages"></div>
<div class="chat-row">
<input type="text" id="chat-input" placeholder="Say something… (Enter to send)" maxlength="200"/>
<button id="chat-send">Send</button>
</div>
</div>
</main>
<!-- ═══════════ RESULTS MODAL ═══════════ -->
<div id="results-modal">
<div class="results-card">
<div class="results-title">πŸ† Race Results</div>
<div id="results-list"></div>
<button class="play-again-btn" onclick="location.reload()">πŸ”„ Play Again</button>
</div>
</div>
<!-- ═══════════ TOAST ═══════════ -->
<div id="toast-container"></div>
<!-- ═══════════════════════════════════════
JAVASCRIPT
═══════════════════════════════════════ -->
<script>
/* ─────────────────────────────────────────
UTILITIES
───────────────────────────────────────── */
function toast(msg, type = 'info', duration = 3500) {
const el = document.createElement('div');
el.className = `toast ${type}`;
el.textContent = msg;
document.getElementById('toast-container').appendChild(el);
setTimeout(() => el.remove(), duration);
}
function $(id) { return document.getElementById(id); }
/* ─────────────────────────────────────────
CAR SVG FACTORY
Draws a side-view pixel-style car with
the player's assigned color.
───────────────────────────────────────── */
function makeCarSVG(color) {
const body = color.body || '#e74c3c';
const stripe = color.stripe || '#c0392b';
const window_ = color.window || '#85c1e9';
return `
<svg xmlns="http://www.w3.org/2000/svg" width="72" height="36" viewBox="0 0 72 36">
<!-- Shadow -->
<ellipse cx="36" cy="34" rx="28" ry="3" fill="rgba(0,0,0,0.35)"/>
<!-- Body -->
<rect x="4" y="16" width="64" height="14" rx="4" fill="${body}"/>
<!-- Hood slope -->
<polygon points="4,16 18,16 24,8 50,8 56,16 68,16" fill="${body}"/>
<!-- Roof -->
<rect x="22" y="6" width="28" height="12" rx="4" fill="${stripe}"/>
<!-- Windshield front -->
<polygon points="50,8 56,16 50,16" fill="${window_}" opacity="0.85"/>
<!-- Windshield rear -->
<polygon points="22,8 26,16 22,16" fill="${window_}" opacity="0.85"/>
<!-- Side windows -->
<rect x="27" y="9" width="10" height="7" rx="2" fill="${window_}" opacity="0.9"/>
<rect x="39" y="9" width="10" height="7" rx="2" fill="${window_}" opacity="0.9"/>
<!-- Stripe -->
<rect x="4" y="20" width="64" height="3" fill="${stripe}" opacity="0.5"/>
<!-- Wheels -->
<circle cx="17" cy="30" r="6" fill="#1a1a2e"/>
<circle cx="17" cy="30" r="3" fill="#4a4a6e"/>
<circle cx="55" cy="30" r="6" fill="#1a1a2e"/>
<circle cx="55" cy="30" r="3" fill="#4a4a6e"/>
<!-- Headlight -->
<rect x="64" y="17" width="5" height="4" rx="1" fill="#fef08a"/>
<!-- Tail light -->
<rect x="3" y="17" width="4" height="4" rx="1" fill="#fca5a5"/>
</svg>`;
}
/* ─────────────────────────────────────────
GAME STATE
───────────────────────────────────────── */
const state = {
myId: null,
myName: '',
myColor: {},
roomId: null,
raceText: '',
words: [], // array of word strings
wordIndex: 0, // current word index
charIndex: 0, // current char within current word
totalCharsTyped: 0,
errorCount: 0,
startTime: null,
raceActive: false,
raceEnded: false,
players: {}, // id β†’ { name, color, progress, wpm, finished, el:{lane,car,fill,wpm} }
timerInterval: null,
sendInterval: null,
lastWPM: 0,
lastProgress: 0,
};
/* ─────────────────────────────────────────
DOM REFERENCES
───────────────────────────────────────── */
const overlay = $('overlay');
const overlaySub = $('overlay-sub');
const overlayLoader = $('overlay-loader');
const overlayCD = $('overlay-countdown');
const waitingList = $('waiting-players-list');
const raceTrack = $('race-track');
const playersBar = $('players-bar');
const paraDisplay = $('paragraph-display');
const typingInput = $('typing-input');
const connDot = $('conn-dot');
const connLabel = $('conn-label');
const myNameBadge = $('my-name-badge');
const roomBadge = $('room-badge');
const statWPM = $('stat-wpm');
const statAcc = $('stat-acc');
const statTime = $('stat-time');
const statProg = $('stat-prog');
const myProgressFill = $('my-progress-fill');
const resultsModal = $('results-modal');
const resultsList = $('results-list');
const chatMessages = $('chat-messages');
const chatInput = $('chat-input');
/* ─────────────────────────────────────────
WEBSOCKET
───────────────────────────────────────── */
let ws = null;
function connectWS() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const url = `${proto}://${location.host}/ws`;
ws = new WebSocket(url);
ws.onopen = () => {
connDot.className = 'status-dot connected';
connLabel.textContent = 'Connected';
};
ws.onclose = () => {
connDot.className = 'status-dot error';
connLabel.textContent = 'Disconnected';
toast('Connection lost. Refresh to reconnect.', 'error', 8000);
};
ws.onerror = () => {
connDot.className = 'status-dot error';
connLabel.textContent = 'Error';
};
ws.onmessage = (evt) => {
let data;
try { data = JSON.parse(evt.data); } catch { return; }
handleMessage(data);
};
// Keep-alive ping every 25 s
setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' }));
}
}, 25000);
}
function sendWS(obj) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(obj));
}
}
/* ─────────────────────────────────────────
MESSAGE HANDLER
───────────────────────────────────────── */
function handleMessage(data) {
switch (data.type) {
case 'init':
onInit(data);
break;
case 'countdown':
onCountdown(data);
break;
case 'race_start':
onRaceStart(data);
break;
case 'player_joined':
onPlayerJoined(data.player);
break;
case 'player_left':
onPlayerLeft(data);
break;
case 'player_update':
onPlayerUpdate(data.player);
break;
case 'player_finished':
onPlayerFinished(data);
break;
case 'race_end':
onRaceEnd(data);
break;
case 'disqualified':
toast('⚠️ ' + data.message, 'error', 8000);
typingInput.disabled = true;
state.raceActive = false;
break;
case 'chat':
appendChat(data.name, data.message);
break;
case 'pong':
break; // silence
}
}
/* ─────────────────────────────────────────
INIT (joined room, told who I am)
───────────────────────────────────────── */
function onInit(data) {
state.myId = data.player_id;
state.myName = data.name;
state.myColor = data.color;
state.roomId = data.room_id;
myNameBadge.textContent = 'πŸ‘€ ' + state.myName;
roomBadge.textContent = 'πŸšͺ Room: ' + state.room_id;
roomBadge.style.display = 'flex';
overlaySub.textContent = `You joined as ${state.myName}. Waiting for race…`;
// Initialise existing players
data.players.forEach(p => ensurePlayer(p));
updatePlayersBar();
updateWaitingList();
if (data.room_state === 'racing') {
// We joined mid-race
if (data.text) onRaceStart({ text: data.text, players: data.players });
}
}
/* ─────────────────────────────────────────
COUNTDOWN
───────────────────────────────────────── */
function onCountdown(data) {
overlayLoader.style.display = 'none';
overlayCD.style.display = 'block';
overlaySub.textContent = data.message;
overlayCD.textContent = data.seconds;
}
/* ─────────────────────────────────────────
RACE START
───────────────────────────────────────── */
function onRaceStart(data) {
// Update players from payload
if (data.players) data.players.forEach(p => ensurePlayer(p));
state.raceText = data.text;
state.words = data.text.split(' ');
state.wordIndex = 0;
state.charIndex = 0;
state.totalCharsTyped = 0;
state.errorCount = 0;
state.startTime = null;
state.raceActive = true;
state.raceEnded = false;
// Hide overlay
overlay.style.display = 'none';
// Build paragraph DOM
buildParagraphDOM(state.words);
// Enable input
typingInput.disabled = false;
typingInput.value = '';
typingInput.placeholder = 'Start typing…';
typingInput.focus();
// Start client timer
state.timerInterval = setInterval(updateTimer, 500);
// Start sending progress every 500ms
state.sendInterval = setInterval(sendProgress, 500);
updatePlayersBar();
rebuildRaceTrack();
toast('🏁 Race started! Go go go!', 'success', 2500);
}
/* ─────────────────────────────────────────
PARAGRAPH DOM BUILDER
───────────────────────────────────────── */
function buildParagraphDOM(words) {
paraDisplay.innerHTML = '';
words.forEach((word, wi) => {
const wordSpan = document.createElement('span');
wordSpan.className = 'word';
wordSpan.dataset.wi = wi;
word.split('').forEach((ch, ci) => {
const s = document.createElement('span');
s.className = 'char pending';
s.dataset.ci = ci;
s.textContent = ch;
wordSpan.appendChild(s);
});
paraDisplay.appendChild(wordSpan);
// Space between words (except last)
if (wi < words.length - 1) {
const sp = document.createElement('span');
sp.className = 'char pending space-char';
sp.dataset.wi = wi;
sp.dataset.ci = word.length;
sp.textContent = ' ';
paraDisplay.appendChild(sp);
}
});
// Highlight first char
highlightCurrent();
}
function getCharEl(wi, ci) {
const wEl = paraDisplay.querySelector(`.word[data-wi="${wi}"]`);
if (!wEl) return null;
return wEl.querySelector(`[data-ci="${ci}"]`);
}
function getSpaceEl(wi) {
return paraDisplay.querySelector(`.space-char[data-wi="${wi}"]`);
}
function highlightCurrent() {
// Remove existing current highlights
paraDisplay.querySelectorAll('.char.current').forEach(el => {
el.classList.remove('current');
});
if (state.wordIndex >= state.words.length) return;
const word = state.words[state.wordIndex];
if (state.charIndex < word.length) {
const el = getCharEl(state.wordIndex, state.charIndex);
if (el) el.classList.add('current');
} else {
// Cursor on space
const sp = getSpaceEl(state.wordIndex);
if (sp) sp.classList.add('current');
}
}
/* ─────────────────────────────────────────
TYPING ENGINE
───────────────────────────────────────── */
typingInput.addEventListener('keydown', (e) => {
if (!state.raceActive || state.raceEnded) return;
// Start timer on first keystroke
if (!state.startTime) state.startTime = Date.now();
});
typingInput.addEventListener('input', () => {
if (!state.raceActive || state.raceEnded) return;
if (!state.startTime) state.startTime = Date.now();
const typed = typingInput.value;
const word = state.words[state.wordIndex];
// ── Space key pressed β†’ advance to next word ──
if (typed.endsWith(' ')) {
const attempt = typed.trimEnd();
if (attempt === word) {
// Correct word: mark all chars green + space green
word.split('').forEach((_, ci) => {
const el = getCharEl(state.wordIndex, ci);
if (el) { el.classList.remove('pending','wrong','current'); el.classList.add('correct'); }
});
const sp = getSpaceEl(state.wordIndex);
if (sp) { sp.classList.remove('pending','current'); sp.classList.add('correct'); }
state.totalCharsTyped += word.length + 1;
state.wordIndex++;
state.charIndex = 0;
typingInput.value = '';
typingInput.className = '';
if (state.wordIndex >= state.words.length) {
finishRace();
return;
}
highlightCurrent();
updateStats();
} else {
// Wrong word on space β†’ shake, keep in place
typingInput.classList.add('wrong-word');
state.errorCount++;
}
return;
}
// ── Character-level highlight ──
typingInput.classList.remove('wrong-word');
const word2 = state.words[state.wordIndex];
let allCorrect = true;
for (let ci = 0; ci < typed.length; ci++) {
const charEl = getCharEl(state.wordIndex, ci);
if (!charEl) continue;
charEl.classList.remove('pending','correct','wrong','current');
if (ci < word2.length && typed[ci] === word2[ci]) {
charEl.classList.add('correct');
} else {
charEl.classList.add('wrong');
allCorrect = false;
}
}
// Remaining chars in this word β†’ pending
for (let ci = typed.length; ci < word2.length; ci++) {
const charEl = getCharEl(state.wordIndex, ci);
if (charEl) { charEl.classList.remove('correct','wrong','current'); charEl.classList.add('pending'); }
}
state.charIndex = typed.length;
typingInput.className = typed.length > 0 ? (allCorrect ? 'correct-word' : 'wrong-word') : '';
highlightCurrent();
updateStats();
});
/* ─────────────────────────────────────────
STATS UPDATE
───────────────────────────────────────── */
function updateStats() {
const elapsed = state.startTime ? (Date.now() - state.startTime) / 60000 : 0.0001;
const charsTyped = state.totalCharsTyped;
const wpm = elapsed > 0 ? Math.round((charsTyped / 5) / elapsed) : 0;
const totalChars = state.raceText.length;
const progress = Math.min((state.totalCharsTyped / totalChars) * 100, 100);
const totalTyped = charsTyped + state.errorCount;
const accuracy = totalTyped > 0 ? Math.round((charsTyped / totalTyped) * 100) : 100;
state.lastWPM = wpm;
state.lastProgress = progress;
statWPM.textContent = wpm;
statAcc.textContent = accuracy + '%';
statProg.textContent = Math.round(progress) + '%';
myProgressFill.style.width = progress + '%';
// Update my car position
updateCarPosition(state.myId, progress);
}
function updateTimer() {
if (!state.startTime || !state.raceActive) return;
const secs = Math.floor((Date.now() - state.startTime) / 1000);
statTime.textContent = secs + 's';
}
/* ─────────────────────────────────────────
SEND PROGRESS TO SERVER
───────────────────────────────────────── */
function sendProgress() {
if (!state.raceActive) return;
sendWS({
type: 'progress',
progress: state.lastProgress,
wpm: state.lastWPM,
});
}
/* ─────────────────────────────────────────
FINISH
───────────────────────────────────────── */
function finishRace() {
state.raceActive = false;
typingInput.disabled = true;
typingInput.placeholder = 'You finished! 🏁';
clearInterval(state.timerInterval);
clearInterval(state.sendInterval);
// Send final 100% immediately
sendWS({ type: 'progress', progress: 100, wpm: state.lastWPM });
updateCarPosition(state.myId, 100);
toast('πŸŽ‰ You finished the race!', 'success', 5000);
}
/* ─────────────────────────────────────────
PLAYER MANAGEMENT
───────────────────────────────────────── */
function ensurePlayer(p) {
if (!state.players[p.id]) {
state.players[p.id] = {
name: p.name,
color: p.color,
progress: p.progress || 0,
wpm: p.wpm || 0,
finished: p.finished || false,
finish_pos: p.finish_pos || 0,
el: null,
};
} else {
// Merge updates
Object.assign(state.players[p.id], {
name: p.name,
color: p.color,
progress: p.progress || state.players[p.id].progress,
wpm: p.wpm || state.players[p.id].wpm,
});
}
}
function onPlayerJoined(p) {
ensurePlayer(p);
updatePlayersBar();
updateWaitingList();
rebuildRaceTrack();
toast(`πŸ‘€ ${p.name} joined the lobby`, 'info');
}
function onPlayerLeft(data) {
const p = state.players[data.player_id];
if (p) {
// Remove lane if exists
if (p.el && p.el.lane) p.el.lane.remove();
delete state.players[data.player_id];
}
updatePlayersBar();
toast(`❌ ${data.name} left the race`, 'warning');
}
function onPlayerUpdate(player) {
if (!state.players[player.id]) return;
state.players[player.id].progress = player.progress;
state.players[player.id].wpm = player.wpm;
state.players[player.id].finished = player.finished;
// Update car on track
updateCarPosition(player.id, player.progress);
// Update WPM badge above car
const p = state.players[player.id];
if (p.el && p.el.wpmEl) {
p.el.wpmEl.textContent = player.wpm + ' WPM';
}
// Update players bar chip
const chip = playersBar.querySelector(`[data-pid="${player.id}"]`);
if (chip) {
const wpmEl = chip.querySelector('.chip-wpm');
if (wpmEl) wpmEl.textContent = player.wpm + ' WPM';
}
}
function onPlayerFinished(data) {
if (state.players[data.player_id]) {
state.players[data.player_id].finished = true;
state.players[data.player_id].finish_pos = data.finish_pos;
}
const medals = { 1: 'πŸ₯‡', 2: 'πŸ₯ˆ', 3: 'πŸ₯‰' };
const medal = medals[data.finish_pos] || `${data.finish_pos}${data.suffix}`;
toast(`${medal} ${data.player_name} finished! (${data.wpm} WPM)`, 'success', 4000);
}
function onRaceEnd(data) {
state.raceEnded = true;
state.raceActive = false;
clearInterval(state.timerInterval);
clearInterval(state.sendInterval);
typingInput.disabled = true;
// Build results
resultsList.innerHTML = '';
data.results.forEach((p, i) => {
const isMe = p.id === state.myId;
const row = document.createElement('div');
row.className = `result-row${isMe ? ' me' : ''}`;
let posText = p.finish_pos;
let posCls = '';
if (p.disqualified) { posText = '🚫'; }
else if (!p.finished) { posText = 'DNF'; }
else if (i === 0) { posCls = 'first'; }
else if (i === 1) { posCls = 'second'; }
else if (i === 2) { posCls = 'third'; }
row.innerHTML = `
<div class="result-pos ${posCls}">${posText}</div>
<div class="result-car-dot" style="background:${p.color.body}"></div>
<div class="result-name">${p.name}${isMe ? ' <em>(you)</em>' : ''}</div>
<div class="result-wpm">${p.wpm} WPM</div>
`;
resultsList.appendChild(row);
});
setTimeout(() => { resultsModal.style.display = 'flex'; }, 1200);
}
/* ─────────────────────────────────────────
RACE TRACK (build lanes)
───────────────────────────────────────── */
function rebuildRaceTrack() {
raceTrack.innerHTML = '';
Object.entries(state.players).forEach(([pid, p]) => {
const lane = document.createElement('div');
lane.className = 'lane';
lane.dataset.pid = pid;
// Label
const label = document.createElement('div');
label.className = 'lane-label';
label.textContent = pid === state.myId ? '⭐ ' + p.name : p.name;
lane.appendChild(label);
// Finish flag
const flag = document.createElement('div');
flag.className = 'finish-flag';
flag.textContent = '🏁';
lane.appendChild(flag);
// Progress background
const progBg = document.createElement('div');
progBg.className = 'lane-progress-bg';
const progFill = document.createElement('div');
progFill.className = 'lane-progress-fill';
progFill.style.background = `linear-gradient(90deg, ${p.color.body}, ${p.color.stripe})`;
progFill.style.width = (p.progress || 0) + '%';
progBg.appendChild(progFill);
lane.appendChild(progBg);
// Car wrapper
const carWrap = document.createElement('div');
carWrap.className = 'car-wrapper';
const wpmBadge = document.createElement('div');
wpmBadge.className = 'car-wpm';
wpmBadge.textContent = (p.wpm || 0) + ' WPM';
const carSVGWrap = document.createElement('div');
carSVGWrap.className = 'car-svg-wrap';
carSVGWrap.innerHTML = makeCarSVG(p.color);
carWrap.appendChild(wpmBadge);
carWrap.appendChild(carSVGWrap);
lane.appendChild(carWrap);
// Store DOM refs
p.el = { lane, fill: progFill, car: carWrap, wpmEl: wpmBadge };
raceTrack.appendChild(lane);
});
}
/* ─────────────────────────────────────────
UPDATE CAR POSITION
───────────────────────────────────────── */
function updateCarPosition(pid, progress) {
const p = state.players[pid];
if (!p || !p.el) return;
// Track available width for car travel
const trackWidth = raceTrack.offsetWidth || 800;
const labelWidth = 90; // left reserved
const rightPad = 52; // flag area
const carWidth = 72;
const travelW = trackWidth - labelWidth - rightPad - carWidth;
const px = (Math.min(progress, 100) / 100) * travelW;
p.el.car.style.transform = `translateY(-50%) translateX(${px}px)`;
p.el.fill.style.width = Math.min(progress, 100) + '%';
}
/* ─────────────────────────────────────────
PLAYERS BAR
───────────────────────────────────────── */
function updatePlayersBar() {
playersBar.innerHTML = '';
Object.entries(state.players).forEach(([pid, p]) => {
const chip = document.createElement('div');
chip.className = `player-chip${pid === state.myId ? ' me' : ''}`;
chip.dataset.pid = pid;
const dot = document.createElement('div');
dot.className = 'dot';
dot.style.background = p.color.body || '#aaa';
chip.innerHTML = `
<div class="dot" style="background:${p.color.body}"></div>
<span>${pid === state.myId ? '⭐ ' : ''}${p.name}</span>
<span class="chip-wpm" style="color:${p.color.body};font-size:0.75rem">${p.wpm || 0} WPM</span>
`;
playersBar.appendChild(chip);
});
}
/* ─────────────────────────────────────────
WAITING LIST (overlay)
───────────────────────────────────────── */
function updateWaitingList() {
waitingList.innerHTML = '';
Object.entries(state.players).forEach(([pid, p]) => {
const chip = document.createElement('div');
chip.className = 'waiting-player-chip';
chip.innerHTML = `
<div style="width:10px;height:10px;border-radius:50%;background:${p.color.body}"></div>
<span>${p.name}${pid === state.myId ? ' (you)' : ''}</span>
`;
waitingList.appendChild(chip);
});
}
/* ─────────────────────────────────────────
CHAT
───────────────────────────────────────── */
function appendChat(name, msg) {
const el = document.createElement('div');
el.className = 'chat-msg';
el.innerHTML = `<strong>${escapeHTML(name)}:</strong> ${escapeHTML(msg)}`;
chatMessages.appendChild(el);
chatMessages.scrollTop = chatMessages.scrollHeight;
}
function escapeHTML(str) {
return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
$('chat-send').addEventListener('click', sendChat);
chatInput.addEventListener('keydown', e => { if (e.key === 'Enter') sendChat(); });
function sendChat() {
const msg = chatInput.value.trim();
if (!msg) return;
sendWS({ type: 'chat', message: msg });
appendChat(state.myName || 'You', msg);
chatInput.value = '';
}
/* ─────────────────────────────────────────
WINDOW RESIZE β†’ reposition cars
───────────────────────────────────────── */
window.addEventListener('resize', () => {
Object.entries(state.players).forEach(([pid, p]) => {
updateCarPosition(pid, p.progress || 0);
});
});
/* ─────────────────────────────────────────
START
───────────────────────────────────────── */
connectWS();
</script>
</body>
</html>