Files
moonchess/static/app.js
T
tukaunuandClaude Opus 5.5 c52f30dc59 Moonchess: vanishing tic-tac-toe, online and practice modes
Online play over WebSockets with any-string game codes, seats and spectators,
and a 1-second heartbeat. Practice mode against Columbina, backed by a full
solver, with an accuracy slider, spoilers, undo and piece freshness.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 03:36:46 +02:00

208 lines
6.0 KiB
JavaScript

"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);