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