"use strict"; const HEARTBEAT_MS = 1000; const TIMEOUT_MS = 4000; // no message from the server for this long -> reconnect const RECONNECT_MS = 1000; const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no 0/O, 1/I lookalikes const CODE_LENGTH = 5; // Identifies this page load, so a reconnect can reclaim its own seat before the // server notices the old connection died. A refresh gets a new id on purpose. const clientId = Array.from(crypto.getRandomValues(new Uint8Array(8)), b => b.toString(16).padStart(2, "0")).join(""); const $ = id => document.getElementById(id); const board = createBoard($("board"), cell => send({ type: "move", cell })); let token = null; let ws = null; let lastMessage = 0; let reconnectTimer = null; let state = null; let flashTimer = null; // ---------- lobby ---------- // Any string is a valid code, and the server creates the game on first join. // This just picks a random one that's easy to read out loud. $("create").addEventListener("click", () => { const code = Array.from(crypto.getRandomValues(new Uint8Array(CODE_LENGTH)), b => CODE_ALPHABET[b % CODE_ALPHABET.length]).join(""); enterGame(code); }); $("join-form").addEventListener("submit", e => { e.preventDefault(); const code = $("join-code").value.trim(); if (code) enterGame(code); }); function showLobby(error = "") { leaveGame(); $("game").hidden = true; $("lobby").hidden = false; $("lobby-error").textContent = error; history.replaceState(null, "", "/"); } // ---------- game ---------- function enterGame(code) { token = code; state = null; board.reset(); $("lobby").hidden = true; $("game").hidden = false; $("code").textContent = code; $("status").textContent = "Connecting…"; history.replaceState(null, "", "/?game=" + encodeURIComponent(code)); connect(); } function leaveGame() { token = null; clearTimeout(reconnectTimer); if (ws) { const old = ws; ws = null; old.close(); } } $("leave").addEventListener("click", () => showLobby()); $("copy").addEventListener("click", async () => { const link = location.origin + "/?game=" + token; try { await navigator.clipboard.writeText(link); flash("Link copied"); } catch { prompt("Copy this link:", link); // clipboard API needs https or localhost } }); $("rematch").addEventListener("click", () => send({ type: "rematch" })); // ---------- connection ---------- function connect() { const proto = location.protocol === "https:" ? "wss:" : "ws:"; const url = `${proto}//${location.host}/ws?game=${encodeURIComponent(token)}&client=${clientId}`; const sock = new WebSocket(url); ws = sock; lastMessage = Date.now(); sock.onmessage = e => { if (sock !== ws) return; lastMessage = Date.now(); const msg = JSON.parse(e.data); if (msg.type === "state") { render(msg); } else if (msg.type === "error") { flash(msg.message); } }; sock.onclose = e => { if (sock !== ws) return; ws = null; if (e.code >= 4000) { // the server refused this code; retrying won't help showLobby(e.reason || "Could not join that game."); return; } scheduleReconnect(); }; } function scheduleReconnect() { if (!token) return; $("game").classList.add("offline"); $("status").textContent = "Disconnected. Reconnecting…"; clearTimeout(reconnectTimer); reconnectTimer = setTimeout(connect, RECONNECT_MS); } function send(msg) { if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg)); } setInterval(() => { if (!ws) return; if (Date.now() - lastMessage > TIMEOUT_MS) { // The socket is probably dead; don't wait for the browser to agree. const old = ws; ws = null; old.close(); scheduleReconnect(); return; } send({ type: "ping" }); }, HEARTBEAT_MS); // ---------- rendering ---------- function render(s) { state = s; const { seat, turn, winner, players } = s; $("code").textContent = s.token; const bothHere = players[0] !== "empty" && players[1] !== "empty"; $("game").classList.remove("offline"); const myTurn = seat === turn && winner === null && bothHere; board.render(s, myTurn); for (const i of [0, 1]) { const el = $("player-" + i); el.querySelector(".who").textContent = players[i] === "empty" ? "Empty seat" : players[i] === "disconnected" ? "Disconnected" : seat === i ? "You" : seat === null ? "Player" : "Opponent"; el.classList.toggle("empty", players[i] === "empty"); el.classList.toggle("gone", players[i] === "disconnected"); el.classList.toggle("active", winner === null ? turn === i : winner === i); } $("status").textContent = statusText(s, bothHere); const rematch = $("rematch"); rematch.hidden = winner === null || seat === null; if (!rematch.hidden) { const mine = s.rematch[seat]; const theirs = s.rematch[1 - seat]; rematch.disabled = mine; rematch.textContent = mine ? "Waiting for opponent…" : theirs ? "Opponent wants a rematch. Play again?" : "Rematch"; } if (!flashTimer) $("meta").textContent = metaText(s); } function statusText({ seat, turn, winner, players }, bothHere) { if (winner !== null) { if (seat === null) return `${MARKS[winner]} wins`; return seat === winner ? "You win!" : "You lose"; } if (!bothHere) return seat === null ? "Waiting for players…" : "Waiting for an opponent…"; if (seat !== turn && players[turn] === "disconnected") return `${MARKS[turn]} disconnected…`; if (seat === null) return `${MARKS[turn]} to move`; return seat === turn ? "Your turn" : `Waiting for ${MARKS[turn]}…`; } function metaText({ seat, spectators }) { const role = seat === null ? "Spectating" : `You are ${MARKS[seat]}`; return spectators ? `${role} · ${spectators} watching` : role; } function flash(text) { $("meta").textContent = text; clearTimeout(flashTimer); flashTimer = setTimeout(() => { flashTimer = null; if (state) $("meta").textContent = metaText(state); }, 2000); } // ---------- start ---------- const initial = new URLSearchParams(location.search).get("game"); if (initial) enterGame(initial);