import { randomInt, randomUUID } from "node:crypto"; import { WebSocket, WebSocketServer } from "ws"; const ROOM_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"; const ROOM_TTL_MS = 12 * 60 * 60 * 1000; const MAX_ROOMS = 200; const MAX_ROLLS = 120; const MIN_ROLL_INTERVAL_MS = 350; const DEFAULT_LEVEL_SETTINGS = Object.freeze({ gridSize: 5, undoLimit: 3, rerollLimit: 3, }); const NAME_ADJECTIVES = [ "Bouncy", "Brave", "Curious", "Dapper", "Dizzy", "Gentle", "Jolly", "Merry", "Mighty", "Nimble", "Peppy", "Quiet", "Silly", "Sunny", "Wiggly", "Wobbly", ]; const NAME_NOUNS = [ "Badger", "Biscuit", "Crumpet", "Doodle", "Kettle", "Mango", "Marble", "Otter", "Pancake", "Penguin", "Pickle", "Puffin", "Rocket", "Teacup", "Turnip", "Wombat", ]; const makeRoomId = () => Array.from( { length: 6 }, () => ROOM_ALPHABET[randomInt(ROOM_ALPHABET.length)], ).join(""); const parseLevelSettings = (candidate) => { const settings = candidate ?? DEFAULT_LEVEL_SETTINGS; if (!settings || typeof settings !== "object") return null; const { gridSize, undoLimit, rerollLimit } = settings; if ( !Number.isInteger(gridSize) || gridSize < 5 || gridSize > 9 || !Number.isInteger(undoLimit) || undoLimit < 0 || undoLimit > 9 || !Number.isInteger(rerollLimit) || rerollLimit < 0 || rerollLimit > 9 ) { return null; } return { gridSize, undoLimit, rerollLimit }; }; const makePlayerName = (room) => { const usedNames = new Set( [...room.players.values()].map((player) => player.name), ); for (let attempt = 0; attempt < 100; attempt += 1) { const name = `${NAME_ADJECTIVES[randomInt(NAME_ADJECTIVES.length)]} ${NAME_NOUNS[randomInt(NAME_NOUNS.length)]}`; if (!usedNames.has(name)) return name; } return `Jolly Marble ${room.players.size + 1}`; }; const send = (socket, message) => { if (socket.readyState === WebSocket.OPEN) { socket.send(JSON.stringify(message)); } }; const leaderboardFor = (room) => [...room.players.values()] .map(({ token: _token, ...player }) => player) .sort( (left, right) => right.score - left.score || right.placed - left.placed || left.name.localeCompare(right.name), ); const roomSnapshot = (room, socket, hostToken) => ({ type: "room-state", roomId: room.id, roundId: room.roundId, isHost: hostToken === room.hostToken, participants: room.clients.size, history: room.history, playerId: socket.playerToken, playerName: room.players.get(socket.playerToken)?.name, leaderboard: leaderboardFor(room), settings: room.settings, rerollsUsed: room.rerollsUsed, }); export const attachDiceSyncServer = (httpServer) => { const rooms = new Map(); const wss = new WebSocketServer({ server: httpServer, path: "/sync", maxPayload: 2048, }); const broadcast = (room, message) => { for (const client of room.clients) send(client, message); }; const broadcastPresence = (room) => { broadcast(room, { type: "presence", participants: room.clients.size }); }; const broadcastLeaderboard = (room) => { broadcast(room, { type: "leaderboard", leaderboard: leaderboardFor(room) }); }; const leaveCurrentRoom = (socket) => { const roomId = socket.roomId; if (!roomId) return; const room = rooms.get(roomId); socket.roomId = undefined; socket.hostToken = undefined; if (!room) return; room.clients.delete(socket); const player = room.players.get(socket.playerToken); if (player) { player.connected = [...room.clients].some( (client) => client.playerToken === socket.playerToken, ); } room.lastActiveAt = Date.now(); broadcastPresence(room); broadcastLeaderboard(room); }; const joinRoom = (socket, room, hostToken, requestedPlayerToken) => { leaveCurrentRoom(socket); let player = requestedPlayerToken ? room.players.get(requestedPlayerToken) : null; if (!player) { const token = randomUUID(); player = { id: token, token, name: makePlayerName(room), score: 0, placed: 0, finished: false, connected: true, }; room.players.set(token, player); } player.connected = true; socket.roomId = room.id; socket.hostToken = hostToken; socket.playerToken = player.token; room.clients.add(socket); room.lastActiveAt = Date.now(); send(socket, roomSnapshot(room, socket, hostToken)); send(socket, { type: "player-token", roomId: room.id, playerToken: player.token, }); broadcastPresence(room); broadcastLeaderboard(room); }; const createRoom = (socket, requestedSettings) => { if (rooms.size >= MAX_ROOMS) { send(socket, { type: "error", message: "The classroom server is full. Please try again shortly.", }); return; } const settings = parseLevelSettings(requestedSettings); if (!settings) { send(socket, { type: "error", message: "Those level settings are not valid.", }); return; } let id = makeRoomId(); while (rooms.has(id)) id = makeRoomId(); const hostToken = randomUUID(); const room = { id, hostToken, roundId: randomUUID(), clients: new Set(), players: new Map(), history: [], settings, rerollsUsed: 0, createdAt: Date.now(), lastActiveAt: Date.now(), lastRollAt: 0, }; rooms.set(id, room); joinRoom(socket, room, hostToken); send(socket, { type: "host-token", roomId: id, hostToken }); }; const requireHost = (socket) => { const room = rooms.get(socket.roomId); if (!room) { send(socket, { type: "error", message: "This classroom room is no longer available.", }); return null; } if (socket.hostToken !== room.hostToken) { send(socket, { type: "error", message: "Only the room host can change the classroom game.", }); return null; } return room; }; wss.on("connection", (socket) => { socket.on("message", (raw) => { let message; try { message = JSON.parse(raw.toString()); } catch { send(socket, { type: "error", message: "That classroom message could not be read.", }); return; } if (message.type === "create") { createRoom(socket, message.settings); return; } if (message.type === "configure") { const room = requireHost(socket); if (!room) return; if (room.history.length > 0) { send(socket, { type: "error", message: "Level settings can only be changed before the first roll.", }); return; } const settings = parseLevelSettings(message.settings); if (!settings) { send(socket, { type: "error", message: "Those level settings are not valid.", }); return; } room.settings = settings; room.rerollsUsed = 0; room.lastActiveAt = Date.now(); broadcast(room, { type: "settings", settings: room.settings, rerollsUsed: room.rerollsUsed, }); return; } if (message.type === "join") { const roomId = String(message.roomId ?? "") .trim() .toUpperCase(); const room = rooms.get(roomId); if (!room) { send(socket, { type: "error", message: `No active classroom game was found for ${roomId || "that ID"}.`, }); return; } const hostToken = typeof message.hostToken === "string" ? message.hostToken : undefined; const playerToken = typeof message.playerToken === "string" ? message.playerToken : undefined; joinRoom(socket, room, hostToken, playerToken); return; } if (message.type === "progress") { const room = rooms.get(socket.roomId); const player = room?.players.get(socket.playerToken); if (!room || !player) return; player.score = Math.max( 0, Math.min(10000, Math.round(Number(message.score) || 0)), ); player.placed = Math.max( 0, Math.min(MAX_ROLLS, Math.round(Number(message.placed) || 0)), ); player.finished = Boolean(message.finished); room.lastActiveAt = Date.now(); broadcastLeaderboard(room); return; } if (message.type === "roll") { const room = requireHost(socket); if (!room) return; const now = Date.now(); if (now - room.lastRollAt < MIN_ROLL_INTERVAL_MS) return; if (room.history.length >= MAX_ROLLS) { send(socket, { type: "error", message: "This round has reached its roll limit. Start a new round to continue.", }); return; } const dice = [randomInt(1, 7), randomInt(1, 7)]; const roll = { id: room.history.length + 1, dice, sum: dice[0] + dice[1], rolledAt: now, }; room.history.push(roll); room.lastRollAt = now; room.lastActiveAt = now; broadcast(room, { type: "rolled", roundId: room.roundId, roll }); return; } if (message.type === "reroll") { const room = requireHost(socket); if (!room) return; if (room.history.length === 0) { send(socket, { type: "error", message: "Roll the dice before using a re-roll.", }); return; } if (room.rerollsUsed >= room.settings.rerollLimit) { send(socket, { type: "error", message: "This round has no re-rolls remaining.", }); return; } if ( [...room.players.values()].some( (player) => player.placed >= room.history.length, ) ) { send(socket, { type: "error", message: "That roll has already been placed by a player.", }); return; } const now = Date.now(); const dice = [randomInt(1, 7), randomInt(1, 7)]; const previousRoll = room.history.at(-1); const roll = { id: previousRoll.id, dice, sum: dice[0] + dice[1], rolledAt: now, }; room.history[room.history.length - 1] = roll; room.rerollsUsed += 1; room.lastActiveAt = now; broadcast(room, { type: "rerolled", roundId: room.roundId, roll, rerollsUsed: room.rerollsUsed, }); return; } if (message.type === "reset") { const room = requireHost(socket); if (!room) return; room.roundId = randomUUID(); room.history = []; room.rerollsUsed = 0; for (const player of room.players.values()) { player.score = 0; player.placed = 0; player.finished = false; } room.lastRollAt = 0; room.lastActiveAt = Date.now(); broadcast(room, { type: "round-reset", roundId: room.roundId, rerollsUsed: room.rerollsUsed, }); broadcastLeaderboard(room); } }); socket.on("close", () => leaveCurrentRoom(socket)); }); const pruneTimer = setInterval( () => { const cutoff = Date.now() - ROOM_TTL_MS; for (const [id, room] of rooms) { if (room.clients.size === 0 && room.lastActiveAt < cutoff) rooms.delete(id); } }, 30 * 60 * 1000, ); pruneTimer.unref(); wss.on("close", () => clearInterval(pruneTimer)); return { wss, rooms }; };