const crypto = require("crypto"); const fs = require("fs"); const http = require("http"); const path = require("path"); const port = Number(process.env.PORT || 3000); const root = __dirname; const clients = new Map(); const rooms = new Map(); let waitingClientId = ""; let nextClientId = 1; let nextRoomId = 1; const mimeTypes = { ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".json": "application/json; charset=utf-8", ".svg": "image/svg+xml", ".ico": "image/x-icon", }; const server = http.createServer((request, response) => { const requestUrl = new URL(request.url, `http://${request.headers.host}`); const pathname = requestUrl.pathname === "/" ? "/index.html" : requestUrl.pathname; const filePath = path.normalize(path.join(root, pathname)); if (!filePath.startsWith(root)) { response.writeHead(403); response.end("Forbidden"); return; } fs.readFile(filePath, (error, content) => { if (error) { response.writeHead(404); response.end("Not found"); return; } const type = mimeTypes[path.extname(filePath)] || "application/octet-stream"; response.writeHead(200, { "Content-Type": type }); response.end(content); }); }); server.on("upgrade", (request, socket) => { if (request.url !== "/ws") { socket.destroy(); return; } const key = request.headers["sec-websocket-key"]; if (!key) { socket.destroy(); return; } const accept = crypto .createHash("sha1") .update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`) .digest("base64"); socket.write([ "HTTP/1.1 101 Switching Protocols", "Upgrade: websocket", "Connection: Upgrade", `Sec-WebSocket-Accept: ${accept}`, "", "", ].join("\r\n")); addClient(socket); }); function addClient(socket) { const id = String(nextClientId); nextClientId += 1; const client = { id, socket, roomId: "", player: "" }; clients.set(id, client); socket.on("data", (buffer) => handleSocketData(client, buffer)); socket.on("close", () => removeClient(client)); socket.on("error", () => removeClient(client)); if (waitingClientId && clients.has(waitingClientId)) { pairClients(clients.get(waitingClientId), client); waitingClientId = ""; return; } waitingClientId = id; send(client, { type: "waiting" }); } function pairClients(xClient, oClient) { const roomId = String(nextRoomId); nextRoomId += 1; xClient.roomId = roomId; xClient.player = "X"; oClient.roomId = roomId; oClient.player = "O"; rooms.set(roomId, { id: roomId, players: [xClient.id, oClient.id] }); send(xClient, { type: "paired", player: "X", roomId }); send(oClient, { type: "paired", player: "O", roomId }); } function handleSocketData(client, buffer) { const opcode = buffer[0] & 0x0f; if (opcode === 0x8) { client.socket.write(Buffer.from([0x88, 0x00])); client.socket.end(); removeClient(client); return; } if (opcode === 0x9) { client.socket.write(Buffer.from([0x8a, 0x00])); return; } const message = decodeFrame(buffer); if (!message) { return; } let payload; try { payload = JSON.parse(message); } catch { send(client, { type: "error", message: "Invalid message." }); return; } if (!client.roomId || payload.roomId !== client.roomId) { send(client, { type: "error", message: "You are not currently paired." }); return; } if (payload.type === "move") { if (payload.player !== client.player) { send(client, { type: "error", message: "Move rejected for wrong player." }); return; } relayToOpponent(client, { type: "move", row: payload.row, col: payload.col, player: payload.player, }); return; } if (payload.type === "reset") { relayToOpponent(client, { type: "reset" }); } } function removeClient(client) { if (!clients.has(client.id)) { return; } clients.delete(client.id); if (waitingClientId === client.id) { waitingClientId = ""; } if (!client.roomId) { return; } const room = rooms.get(client.roomId); rooms.delete(client.roomId); if (!room) { return; } for (const playerId of room.players) { if (playerId === client.id || !clients.has(playerId)) { continue; } const opponent = clients.get(playerId); opponent.roomId = ""; opponent.player = ""; send(opponent, { type: "opponent-left", message: "Opponent disconnected. Waiting for a new player...", }); waitingClientId = opponent.id; } } function relayToOpponent(client, payload) { const room = rooms.get(client.roomId); if (!room) { send(client, { type: "error", message: "Room no longer exists." }); return; } for (const playerId of room.players) { if (playerId !== client.id && clients.has(playerId)) { send(clients.get(playerId), payload); } } } function send(client, payload) { if (client.socket.destroyed) { return; } client.socket.write(encodeFrame(JSON.stringify(payload))); } function decodeFrame(buffer) { const firstByte = buffer[0]; const opcode = firstByte & 0x0f; const secondByte = buffer[1]; const masked = (secondByte & 0x80) === 0x80; let length = secondByte & 0x7f; let offset = 2; if (length === 126) { length = buffer.readUInt16BE(offset); offset += 2; } else if (length === 127) { length = Number(buffer.readBigUInt64BE(offset)); offset += 8; } let mask; if (masked) { mask = buffer.subarray(offset, offset + 4); offset += 4; } const payload = buffer.subarray(offset, offset + length); if (!masked) { return payload.toString("utf8"); } const decoded = Buffer.alloc(payload.length); for (let index = 0; index < payload.length; index += 1) { decoded[index] = payload[index] ^ mask[index % 4]; } return decoded.toString("utf8"); } function encodeFrame(message) { const payload = Buffer.from(message); const length = payload.length; if (length < 126) { return Buffer.concat([Buffer.from([0x81, length]), payload]); } if (length < 65536) { const header = Buffer.alloc(4); header[0] = 0x81; header[1] = 126; header.writeUInt16BE(length, 2); return Buffer.concat([header, payload]); } const header = Buffer.alloc(10); header[0] = 0x81; header[1] = 127; header.writeBigUInt64BE(BigInt(length), 2); return Buffer.concat([header, payload]); } server.listen(port, () => { console.log(`Tictactics multiplayer server running at http://localhost:${port}`); });