diff --git a/fission/.gitignore b/fission/.gitignore index a8a3e12932..79e2b4a5ba 100644 --- a/fission/.gitignore +++ b/fission/.gitignore @@ -28,3 +28,5 @@ dist-ssr *.sw? yarn.lock + +__screenshots__ \ No newline at end of file diff --git a/multiplayer-spike/.gitignore b/multiplayer-spike/.gitignore new file mode 100644 index 0000000000..a8a3e12932 --- /dev/null +++ b/multiplayer-spike/.gitignore @@ -0,0 +1,30 @@ +public/Downloadables +package-lock.json +bun.lockb + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +yarn.lock diff --git a/multiplayer-spike/client/client.js b/multiplayer-spike/client/client.js new file mode 100644 index 0000000000..f5bbde6a5b --- /dev/null +++ b/multiplayer-spike/client/client.js @@ -0,0 +1,252 @@ +class MultiplayerRobotClient { + constructor() { + this.ws = null; + this.clientId = null; + this.robotId = null; + this.connected = false; + + this.robots = new Map(); + this.worldSize = { width: 1000, height: 1000 }; + this.inputs = { + forward: false, + backward: false, + left: false, + right: false, + }; + + this.canvas = document.getElementById("gameCanvas"); + this.ctx = this.canvas.getContext("2d"); + + this.statusEl = document.getElementById("status"); + this.playerCountEl = document.getElementById("playerCount"); + this.latencyEl = document.getElementById("latency"); + + this.setupCanvas(); + this.setupInputHandlers(); + this.connect(); + this.startRenderLoop(); + } + + connect() { + const protocol = location.protocol === "https:" ? "wss:" : "ws:"; + const wsUrl = `${protocol}//${location.host}/game`; + + console.log(`Connecting to ${wsUrl}...`); + + try { + this.ws = new WebSocket(wsUrl); + + this.ws.onopen = () => { + this.connected = true; + this.statusEl.textContent = "Connected"; + console.log("Connected to game server"); + }; + + this.ws.onmessage = (event) => { + this.handleServerMessage(event.data); + }; + + this.ws.onclose = () => { + this.connected = false; + this.statusEl.textContent = "Disconnected"; + console.log("Disconnected from server"); + + setTimeout(() => { + if (!this.connected) { + console.log("Attempting to reconnect..."); + this.connect(); + } + }, 3000); + }; + + this.ws.onerror = (error) => { + console.error("WebSocket error:", error); + this.statusEl.textContent = "Error"; + }; + } catch (error) { + console.error("Failed to connect:", error); + this.statusEl.textContent = "Failed"; + } + } + + handleServerMessage(data) { + try { + const message = JSON.parse(data); + + switch (message.type) { + case "init": + this.handleInit(message.data); + break; + case "worldState": + this.handleWorldState(message.data); + break; + case "robotJoined": + this.handleRobotJoined(message.data); + break; + case "robotLeft": + this.handleRobotLeft(message.data); + break; + case "ping": + this.handlePing(message.data); + break; + } + } catch (error) { + console.error("Failed to parse server message:", error); + } + } + + handleInit(data) { + this.clientId = data.clientId; + this.robotId = data.robotId; + this.worldSize = data.worldSize; + + data.robots.forEach((robot) => { + this.robots.set(robot.id, robot); + }); + + this.playerCountEl.textContent = data.robots.length; + console.log(`Initialized as robot ${this.robotId.substring(0, 8)}`); + } + + handleWorldState(data) { + data.robots.forEach((robotData) => { + if (this.robots.has(robotData.id)) { + const robot = this.robots.get(robotData.id); + robot.position = robotData.position; + robot.velocity = robotData.velocity; + robot.rotation = robotData.rotation; + } + }); + } + + handleRobotJoined(data) { + this.robots.set(data.id, data); + this.playerCountEl.textContent = this.robots.size; + console.log(`Robot ${data.id.substring(0, 8)} joined`); + } + + handleRobotLeft(data) { + this.robots.delete(data.robotId); + this.playerCountEl.textContent = this.robots.size; + console.log(`Robot ${data.robotId.substring(0, 8)} left`); + } + + handlePing(data) { + this.send({ + type: "pong", + data: { timestamp: data.timestamp }, + }); + + const latency = Date.now() - data.timestamp; + this.latencyEl.textContent = latency; + } + + setupInputHandlers() { + const keys = { + KeyW: "forward", + KeyS: "backward", + KeyA: "left", + KeyD: "right", + }; + + document.addEventListener("keydown", (e) => { + const action = keys[e.code]; + if (action && !this.inputs[action]) { + this.inputs[action] = true; + this.sendInputs(); + } + }); + + document.addEventListener("keyup", (e) => { + const action = keys[e.code]; + if (action && this.inputs[action]) { + this.inputs[action] = false; + this.sendInputs(); + } + }); + + window.addEventListener("resize", () => { + this.setupCanvas(); + }); + } + + sendInputs() { + this.send({ + type: "input", + data: { ...this.inputs }, + }); + } + + setupCanvas() { + this.canvas.width = window.innerWidth; + this.canvas.height = window.innerHeight; + } + + startRenderLoop() { + const render = () => { + this.render(); + requestAnimationFrame(render); + }; + requestAnimationFrame(render); + } + + render() { + this.ctx.fillStyle = "#000"; + this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); + + const myRobot = this.robots.get(this.robotId); + let offsetX = 0; + let offsetY = 0; + + if (myRobot) { + offsetX = this.canvas.width / 2 - myRobot.position.x; + offsetY = this.canvas.height / 2 - myRobot.position.y; + } + + this.ctx.strokeStyle = "#333"; + this.ctx.lineWidth = 2; + this.ctx.strokeRect( + offsetX, + offsetY, + this.worldSize.width, + this.worldSize.height + ); + + for (const robot of this.robots.values()) { + this.drawRobot(robot, offsetX, offsetY); + } + } + + drawRobot(robot, offsetX, offsetY) { + const isOwnRobot = robot.id === this.robotId; + const x = robot.position.x + offsetX; + const y = robot.position.y + offsetY; + + this.ctx.save(); + this.ctx.translate(x + 25, y + 25); + this.ctx.rotate((robot.rotation * Math.PI) / 180); + + this.ctx.fillStyle = isOwnRobot ? "#ff0000" : "#00ff00"; + this.ctx.fillRect(-25, -25, 50, 50); + + this.ctx.fillStyle = "#fff"; + this.ctx.beginPath(); + this.ctx.moveTo(15, 0); + this.ctx.lineTo(-5, -8); + this.ctx.lineTo(-5, 8); + this.ctx.closePath(); + this.ctx.fill(); + + this.ctx.restore(); + } + + send(message) { + if (this.connected && this.ws.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(message)); + } + } +} + +document.addEventListener("DOMContentLoaded", () => { + window.gameClient = new MultiplayerRobotClient(); +}); diff --git a/multiplayer-spike/client/index.html b/multiplayer-spike/client/index.html new file mode 100644 index 0000000000..1cb2372557 --- /dev/null +++ b/multiplayer-spike/client/index.html @@ -0,0 +1,59 @@ + + + + + + Multiplayer Robot Simulator + + + + + +
+
s: Connecting...
+
count: 0
+
ping: -ms
+
+ + + + diff --git a/multiplayer-spike/package.json b/multiplayer-spike/package.json new file mode 100644 index 0000000000..a7da34724e --- /dev/null +++ b/multiplayer-spike/package.json @@ -0,0 +1,26 @@ +{ + "name": "synthesis-multiplayer-spike", + "version": "1.0.0", + "description": "Real-time multiplayer robot simulator spike using WebSockets", + "type": "module", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "node --watch server.js", + "client": "npx http-server client -p 8080 -o" + }, + "dependencies": { + "express": "^4.18.2", + "helmet": "^8.1.0", + "uuid": "^9.0.1", + "ws": "^8.18.0" + }, + "devDependencies": { + "@types/node": "^20.10.0", + "@types/uuid": "^9.0.7", + "@types/ws": "^8.5.10" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/multiplayer-spike/server.js b/multiplayer-spike/server.js new file mode 100644 index 0000000000..cae6db0fcf --- /dev/null +++ b/multiplayer-spike/server.js @@ -0,0 +1,380 @@ +import { WebSocketServer } from "ws"; +import express from "express"; +import { v4 as uuidv4 } from "uuid"; +import path from "path"; +import { fileURLToPath } from "url"; +import helmet from "helmet"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const GAME_CONFIG = { + TICK_RATE: 60, // Hz - Server simulation tick rate + WORLD_SIZE: { width: 1000, height: 1000 }, // World boundaries + ROBOT_SIZE: { width: 50, height: 50 }, + ROBOT_SPEED: 200, // Units per second + PHYSICS_TIMESTEP: 1 / 60, // Fixed timestep for deterministic physics +}; + +class GameServer { + constructor() { + this.clients = new Map(); + this.robots = new Map(); + this.lastTick = Date.now(); + this.tickCount = 0; + + this.app = express(); + this.httpServer = null; + this.wss = null; + + this.setupExpress(); + this.setupWebSocket(); + } + + setupExpress() { + this.app.use(express.static(path.join(__dirname, "client"))); + + this.app.use(helmet()); + + this.app.get("/api/info", (req, res) => { + res.json({ + connectedClients: this.clients.size, + activeRobots: this.robots.size, + tickRate: GAME_CONFIG.TICK_RATE, + worldSize: GAME_CONFIG.WORLD_SIZE, + }); + }); + } + + setupWebSocket() { + this.httpServer = this.app.listen(3000, () => { + console.log("server running on http://localhost:3000"); + }); + + this.wss = new WebSocketServer({ + server: this.httpServer, + path: "/game", + }); + + this.wss.on("connection", (ws, req) => { + this.handleClientConnection(ws, req); + }); + + console.log("webSocket Game Server initialized"); + } + + handleClientConnection(ws, req) { + const clientId = uuidv4(); + const robotId = uuidv4(); + + const robot = { + id: robotId, + clientId: clientId, + position: { + x: + Math.random() * + (GAME_CONFIG.WORLD_SIZE.width - + GAME_CONFIG.ROBOT_SIZE.width), + y: + Math.random() * + (GAME_CONFIG.WORLD_SIZE.height - + GAME_CONFIG.ROBOT_SIZE.height), + }, + velocity: { x: 0, y: 0 }, + rotation: 0, + lastInputTime: Date.now(), + inputs: { + forward: false, + backward: false, + left: false, + right: false, + }, + }; + + this.clients.set(clientId, { + id: clientId, + ws: ws, + robotId: robotId, + lastPing: Date.now(), + latency: 0, + }); + + this.robots.set(robotId, robot); + + console.log( + `client ${clientId} connected with robot ${robotId} (count: ${this.clients.size})` + ); + + this.sendToClient(clientId, { + type: "init", + data: { + clientId: clientId, + robotId: robotId, + worldSize: GAME_CONFIG.WORLD_SIZE, + tickRate: GAME_CONFIG.TICK_RATE, + robots: Array.from(this.robots.values()), + }, + }); + + this.broadcastToOthers(clientId, { + type: "robotJoined", + data: robot, + }); + + ws.on("message", (data) => { + this.handleClientMessage(clientId, data); + }); + + ws.on("close", () => { + this.handleClientDisconnection(clientId); + }); + + ws.on("error", (error) => { + console.error(`ws error on ${clientId}:`, error); + this.handleClientDisconnection(clientId); + }); + + this.sendPing(clientId); + } + + handleClientMessage(clientId, data) { + const client = this.clients.get(clientId); + if (!client) return; + + try { + const message = JSON.parse(data.toString()); + + switch (message.type) { + case "input": + this.handleInputMessage(clientId, message.data); + break; + case "pong": + this.handlePongMessage(clientId, message.data); + break; + default: + console.warn(`unknown msg type: ${message.type}`); + } + } catch (error) { + console.error(`parse error from client ${clientId}:`, error); + } + } + + handleInputMessage(clientId, inputData) { + const client = this.clients.get(clientId); + if (!client) return; + + const robot = this.robots.get(client.robotId); + if (!robot) return; + + robot.inputs = { ...inputData }; + robot.lastInputTime = Date.now(); + } + + handlePongMessage(clientId, data) { + const client = this.clients.get(clientId); + if (!client) return; + + const now = Date.now(); + client.latency = now - data.timestamp; + client.lastPing = now; + } + + handleClientDisconnection(clientId) { + const client = this.clients.get(clientId); + if (!client) return; + + console.log(`client ${clientId} disconnected`); + + if (client.robotId) { + this.robots.delete(client.robotId); + this.broadcastToOthers(clientId, { + type: "robotLeft", + data: { robotId: client.robotId }, + }); + } + + this.clients.delete(clientId); + + console.log(`clients remaining: ${this.clients.size}`); + } + + startGameLoop() { + console.log(`starting game loop at ${GAME_CONFIG.TICK_RATE}Hz`); + + setInterval(() => { + this.tick(); + }, 1000 / GAME_CONFIG.TICK_RATE); + + setInterval(() => { + this.sendPingsToAllClients(); + }, 5000); + } + + tick() { + const now = Date.now(); + const deltaTime = GAME_CONFIG.PHYSICS_TIMESTEP; + + for (const robot of this.robots.values()) { + this.updateRobotPhysics(robot, deltaTime); + } + + const worldState = { + type: "worldState", + data: { + tick: this.tickCount, + timestamp: now, + robots: Array.from(this.robots.values()).map((robot) => ({ + id: robot.id, + position: robot.position, + rotation: robot.rotation, + velocity: robot.velocity, + })), + }, + }; + + this.broadcast(worldState); + + this.tickCount++; + this.lastTick = now; + } + + updateRobotPhysics(robot, deltaTime) { + let targetVelocity = { x: 0, y: 0 }; + + if (robot.inputs.forward) targetVelocity.y -= GAME_CONFIG.ROBOT_SPEED; + if (robot.inputs.backward) targetVelocity.y += GAME_CONFIG.ROBOT_SPEED; + if (robot.inputs.left) targetVelocity.x -= GAME_CONFIG.ROBOT_SPEED; + if (robot.inputs.right) targetVelocity.x += GAME_CONFIG.ROBOT_SPEED; + + const smoothing = 0.8; + robot.velocity.x = + robot.velocity.x * (1 - smoothing) + targetVelocity.x * smoothing; + robot.velocity.y = + robot.velocity.y * (1 - smoothing) + targetVelocity.y * smoothing; + + robot.position.x += robot.velocity.x * deltaTime; + robot.position.y += robot.velocity.y * deltaTime; + + robot.position.x = Math.max( + 0, + Math.min( + GAME_CONFIG.WORLD_SIZE.width - GAME_CONFIG.ROBOT_SIZE.width, + robot.position.x + ) + ); + robot.position.y = Math.max( + 0, + Math.min( + GAME_CONFIG.WORLD_SIZE.height - GAME_CONFIG.ROBOT_SIZE.height, + robot.position.y + ) + ); + + if ( + Math.abs(robot.velocity.x) > 10 || + Math.abs(robot.velocity.y) > 10 + ) { + robot.rotation = + (Math.atan2(robot.velocity.y, robot.velocity.x) * 180) / + Math.PI; + } + } + + sendToClient(clientId, message) { + const client = this.clients.get(clientId); + if (!client || client.ws.readyState !== 1) return; // 1 = OPEN + + try { + client.ws.send(JSON.stringify(message)); + } catch (error) { + console.error(`message drop to ${clientId}:`, error); + } + } + + broadcast(message) { + const messageStr = JSON.stringify(message); + + for (const [clientId, client] of this.clients) { + if (client.ws.readyState === 1) { + // 1 = OPEN + try { + client.ws.send(messageStr); + } catch (error) { + console.error(`message drop to ${clientId}:`, error); + } + } + } + } + + broadcastToOthers(excludeClientId, message) { + const messageStr = JSON.stringify(message); + + for (const [clientId, client] of this.clients) { + if (clientId !== excludeClientId && client.ws.readyState === 1) { + try { + client.ws.send(messageStr); + } catch (error) { + console.error(`broadcast drop to ${clientId}:`, error); + } + } + } + } + + sendPing(clientId) { + this.sendToClient(clientId, { + type: "ping", + data: { timestamp: Date.now() }, + }); + } + + sendPingsToAllClients() { + const now = Date.now(); + + for (const [clientId, client] of this.clients) { + this.sendToClient(clientId, { + type: "ping", + data: { timestamp: now }, + }); + } + } + + getStats() { + const clients = Array.from(this.clients.values()); + return { + connectedClients: this.clients.size, + activeRobots: this.robots.size, + averageLatency: + clients.length > 0 + ? clients.reduce((sum, client) => sum + client.latency, 0) / + clients.length + : 0, + tickRate: GAME_CONFIG.TICK_RATE, + uptime: process.uptime(), + }; + } +} + +const gameServer = new GameServer(); +gameServer.startGameLoop(); + +// Graceful shutdown +process.on("SIGINT", () => { + console.log("\nstopping..."); + if (gameServer.httpServer) { + gameServer.httpServer.close(() => { + console.log("closed http server"); + process.exit(0); + }); + } +}); + +// Log stats every 30 seconds +setInterval(() => { + const stats = gameServer.getStats(); + console.log(`PERIODIC STATS:`, { + clients: stats.connectedClients, + robots: stats.activeRobots, + avgLatency: Math.round(stats.averageLatency) + "ms", + uptime: Math.round(stats.uptime) + "s", + }); +}, 30000);