From 0f4e96372c102a48da4ca7d01ad7abe2d4af104b Mon Sep 17 00:00:00 2001 From: Aries Powvalla Date: Thu, 17 Jul 2025 09:02:48 -0700 Subject: [PATCH] Prediction Spike --- client-prediction-spike/.gitignore | 30 + client-prediction-spike/client/client.js | 810 ++++++++++++++++++ client-prediction-spike/client/dashboard.html | 584 +++++++++++++ client-prediction-spike/client/index.html | 210 +++++ client-prediction-spike/package.json | 26 + client-prediction-spike/server.js | 754 ++++++++++++++++ 6 files changed, 2414 insertions(+) create mode 100644 client-prediction-spike/.gitignore create mode 100644 client-prediction-spike/client/client.js create mode 100644 client-prediction-spike/client/dashboard.html create mode 100644 client-prediction-spike/client/index.html create mode 100644 client-prediction-spike/package.json create mode 100644 client-prediction-spike/server.js diff --git a/client-prediction-spike/.gitignore b/client-prediction-spike/.gitignore new file mode 100644 index 0000000000..a8a3e12932 --- /dev/null +++ b/client-prediction-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/client-prediction-spike/client/client.js b/client-prediction-spike/client/client.js new file mode 100644 index 0000000000..9df9c1a69b --- /dev/null +++ b/client-prediction-spike/client/client.js @@ -0,0 +1,810 @@ +class PredictionRobotClient { + constructor() { + this.ws = null; + this.clientId = null; + this.robotId = null; + this.connected = false; + + this.robots = new Map(); + this.worldSize = { width: 1000, height: 1000 }; + this.robotSize = { width: 50, height: 50 }; + this.robotSpeed = 200; + + this.inputs = { + w: false, + s: false, + a: false, + d: false, + }; + this.inputSequence = 0; + this.inputBuffer = new Map(); + + this.predictionEnabled = true; + this.clientRobotState = null; + this.serverRobotState = null; + this.stateHistory = new Map(); + + this.correctionCount = 0; + this.totalDivergence = 0; + this.lastCorrectionTime = 0; + this.inputLagMeasurements = []; + + // Enhanced client metrics + this.clientMetrics = { + startTime: Date.now(), + totalFrames: 0, + frameTimes: [], + inputsSent: 0, + messagesReceived: 0, + bytesReceived: 0, + bytesSent: 0, + connectionTime: 0, + averageFPS: 0, + predictionAccuracy: { + samples: [], + averageError: 0 + }, + networkStats: { + packetsLost: 0, + roundTripTimes: [], + jitter: 0 + }, + serverMetrics: null + }; + + this.canvas = document.getElementById("gameCanvas"); + this.ctx = this.canvas.getContext("2d"); + this.lastRenderTime = 0; + + this.statusEl = document.getElementById("status"); + this.playerCountEl = document.getElementById("playerCount"); + this.latencyEl = document.getElementById("latency"); + this.serverTickEl = document.getElementById("serverTick"); + this.predictionEnabledEl = document.getElementById("predictionEnabled"); + this.correctionCountEl = document.getElementById("correctionCount"); + this.avgDivergenceEl = document.getElementById("avgDivergence"); + this.inputLagEl = document.getElementById("inputLag"); + this.inputSequenceEl = document.getElementById("inputSequence"); + this.lastCorrectionEl = document.getElementById("lastCorrection"); + + // Additional metric elements + this.fpsEl = document.getElementById("fps"); + this.serverTickRateEl = document.getElementById("serverTickRate"); + this.networkThroughputEl = document.getElementById("networkThroughput"); + this.serverMemoryEl = document.getElementById("serverMemory"); + this.totalMessagesEl = document.getElementById("totalMessages"); + this.predictionAccuracyEl = document.getElementById("predictionAccuracy"); + this.uptimeEl = document.getElementById("uptime"); + this.jitterEl = document.getElementById("jitter"); + + // Panel elements for toggling + this.infoPanel = document.getElementById("info"); + this.predictionPanel = document.getElementById("predictionStats"); + this.serverPanel = document.getElementById("serverStats"); + this.controlsPanel = document.getElementById("controls"); + + this.metricsVisible = true; + + this.setupCanvas(); + this.setupInputHandlers(); + this.connect(); + this.startRenderLoop(); + this.startMetricsCollection(); + } + + connect() { + const protocol = location.protocol === "https:" ? "wss:" : "ws:"; + const wsUrl = `${protocol}//${location.host}`; + + console.log(`Connecting to ${wsUrl}...`); + + try { + this.ws = new WebSocket(wsUrl); + + this.ws.onopen = () => { + this.connected = true; + this.clientMetrics.connectionTime = Date.now(); + this.statusEl.textContent = "Connected"; + console.log("Connected to prediction server"); + }; + + this.ws.onmessage = (event) => { + this.clientMetrics.messagesReceived++; + this.clientMetrics.bytesReceived += event.data.length; + 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 "gameState": + this.handleGameState(message.data); + break; + case "robotJoined": + this.handleRobotJoined(message.data); + break; + case "robotLeft": + this.handleRobotLeft(message.data); + break; + case "ping": + this.handlePing(message.data); + break; + case "inputAck": + this.handleInputAck(message.data); + break; + case "stateCorrection": + this.handleStateCorrection(message.data); + break; + case "serverMetrics": + this.handleServerMetrics(message.data); + break; + default: + console.warn("Unknown message type:", message.type); + } + } 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 }); + + if (robot.id === this.robotId) { + this.clientRobotState = { + position: { ...robot.position }, + velocity: { ...robot.velocity }, + rotation: robot.rotation, + lastUpdateTime: Date.now(), + }; + this.serverRobotState = { ...this.clientRobotState }; + } + }); + + this.playerCountEl.textContent = data.robots.length; + console.log(`Initialized with ${data.robots.length} robots`); + } + + handleGameState(data) { + this.serverTickEl.textContent = data.sequence; + + data.robots.forEach((robotData) => { + const robot = this.robots.get(robotData.id); + if (robot) { + robot.position = { ...robotData.position }; + robot.velocity = { ...robotData.velocity }; + robot.rotation = robotData.rotation; + robot.reconciliation = robotData.reconciliation; + + if (robotData.id === this.robotId) { + this.serverRobotState = { + position: { ...robotData.position }, + velocity: { ...robotData.velocity }, + rotation: robotData.rotation, + lastUpdateTime: Date.now(), + }; + + if (this.predictionEnabled && this.clientRobotState) { + this.sendPredictionState(); + } + } + } + }); + + this.playerCountEl.textContent = data.robots.length; + } + + handleRobotJoined(data) { + this.robots.set(data.id, { ...data }); + this.playerCountEl.textContent = this.robots.size; + console.log(`Robot ${data.id} joined`); + } + + handleRobotLeft(data) { + this.robots.delete(data.robotId); + this.playerCountEl.textContent = this.robots.size; + console.log(`Robot ${data.robotId} left`); + } + + handlePing(data) { + const timestamp = Date.now(); + this.sendMessage({ + type: "pong", + data: { timestamp: data.timestamp }, + }); + + // Track round trip time for jitter calculation + const rtt = timestamp - data.timestamp; + this.clientMetrics.networkStats.roundTripTimes.push(rtt); + if (this.clientMetrics.networkStats.roundTripTimes.length > 10) { + this.clientMetrics.networkStats.roundTripTimes.shift(); + } + + // Calculate jitter (variation in latency) + if (this.clientMetrics.networkStats.roundTripTimes.length > 1) { + const rtts = this.clientMetrics.networkStats.roundTripTimes; + const avg = rtts.reduce((a, b) => a + b, 0) / rtts.length; + const variance = rtts.reduce((sum, rtt) => sum + Math.pow(rtt - avg, 2), 0) / rtts.length; + this.clientMetrics.networkStats.jitter = Math.sqrt(variance); + } + } + + handleServerMetrics(data) { + this.clientMetrics.serverMetrics = data; + this.updateServerMetricsDisplay(); + } + + startMetricsCollection() { + // Update client metrics every second + setInterval(() => { + this.updateClientMetrics(); + }, 1000); + } + + updateClientMetrics() { + // Calculate FPS + if (this.clientMetrics.frameTimes.length > 0) { + const validFrameTimes = this.clientMetrics.frameTimes.filter(t => t > 0); + if (validFrameTimes.length > 0) { + const avgFrameTime = validFrameTimes.reduce((a, b) => a + b, 0) / validFrameTimes.length; + this.clientMetrics.averageFPS = Math.round(1000 / avgFrameTime); + } + } + + // Calculate prediction accuracy + if (this.clientMetrics.predictionAccuracy.samples.length > 0) { + this.clientMetrics.predictionAccuracy.averageError = + this.clientMetrics.predictionAccuracy.samples.reduce((a, b) => a + b, 0) / + this.clientMetrics.predictionAccuracy.samples.length; + } + + this.updateMetricsDisplay(); + } + + updateMetricsDisplay() { + // Update existing elements that might exist + if (this.fpsEl) this.fpsEl.textContent = this.clientMetrics.averageFPS || "0"; + if (this.totalMessagesEl) this.totalMessagesEl.textContent = this.clientMetrics.messagesReceived; + if (this.predictionAccuracyEl) { + this.predictionAccuracyEl.textContent = + Math.round(this.clientMetrics.predictionAccuracy.averageError * 10) / 10 + "px"; + } + if (this.jitterEl) { + this.jitterEl.textContent = Math.round(this.clientMetrics.networkStats.jitter * 10) / 10 + "ms"; + } + if (this.uptimeEl) { + const uptime = Date.now() - this.clientMetrics.startTime; + const seconds = Math.floor(uptime / 1000) % 60; + const minutes = Math.floor(uptime / 60000); + this.uptimeEl.textContent = `${minutes}:${seconds.toString().padStart(2, '0')}`; + } + } + + updateServerMetricsDisplay() { + const serverMetrics = this.clientMetrics.serverMetrics; + if (!serverMetrics) return; + + if (this.serverTickRateEl) { + this.serverTickRateEl.textContent = `${serverMetrics.actualTickRate}/${serverMetrics.tickRate}`; + } + if (this.networkThroughputEl) { + this.networkThroughputEl.textContent = `${serverMetrics.messagesPerSecond}/s`; + } + if (this.serverMemoryEl) { + this.serverMemoryEl.textContent = `${serverMetrics.memoryUsageMB}MB`; + } + } + + handleInputAck(data) { + this.inputBuffer.delete(data.sequence); + + const inputTime = this.stateHistory.get(data.sequence); + if (inputTime) { + const lag = Date.now() - inputTime.timestamp; + this.inputLagMeasurements.push(lag); + + if (this.inputLagMeasurements.length > 10) { + this.inputLagMeasurements.shift(); + } + + const avgLag = + this.inputLagMeasurements.reduce((a, b) => a + b, 0) / + this.inputLagMeasurements.length; + this.inputLagEl.textContent = Math.round(avgLag); + } + } + + handleStateCorrection(data) { + if (!this.predictionEnabled) return; + + this.correctionCount++; + this.totalDivergence += data.divergence; + this.lastCorrectionTime = Date.now(); + + // Track prediction accuracy + this.clientMetrics.predictionAccuracy.samples.push(data.divergence); + if (this.clientMetrics.predictionAccuracy.samples.length > 50) { + this.clientMetrics.predictionAccuracy.samples.shift(); + } + + this.showCorrectionIndicator(data.correctionType); + + if (data.correctionType === "snap") { + this.clientRobotState = { + position: { ...data.robotState.position }, + velocity: { ...data.robotState.velocity }, + rotation: data.robotState.rotation, + lastUpdateTime: Date.now(), + }; + } else { + this.smoothStateCorrection(data.robotState); + } + + this.updatePredictionMetrics(); + } + + showCorrectionIndicator(type) { + this.lastCorrectionEl.textContent = type.toUpperCase(); + this.lastCorrectionEl.classList.add("correction-flash"); + + setTimeout(() => { + this.lastCorrectionEl.classList.remove("correction-flash"); + }, 500); + } + + smoothStateCorrection(targetState) { + const smoothingFactor = 0.3; + + this.clientRobotState.position.x = + this.clientRobotState.position.x * (1 - smoothingFactor) + + targetState.position.x * smoothingFactor; + this.clientRobotState.position.y = + this.clientRobotState.position.y * (1 - smoothingFactor) + + targetState.position.y * smoothingFactor; + this.clientRobotState.velocity.x = + this.clientRobotState.velocity.x * (1 - smoothingFactor) + + targetState.velocity.x * smoothingFactor; + this.clientRobotState.velocity.y = + this.clientRobotState.velocity.y * (1 - smoothingFactor) + + targetState.velocity.y * smoothingFactor; + this.clientRobotState.rotation = targetState.rotation; + } + + sendPredictionState() { + if (!this.clientRobotState) return; + + this.sendMessage({ + type: "predictionState", + data: { + position: { ...this.clientRobotState.position }, + velocity: { ...this.clientRobotState.velocity }, + rotation: this.clientRobotState.rotation, + timestamp: Date.now(), + }, + }); + } + + setupCanvas() { + this.canvas.width = this.worldSize.width; + this.canvas.height = this.worldSize.height; + + window.addEventListener("resize", () => { + this.canvas.width = this.worldSize.width; + this.canvas.height = this.worldSize.height; + }); + } + + setupInputHandlers() { + document.addEventListener("keydown", (e) => { + this.handleKeyInput(e.key.toLowerCase(), true); + }); + + document.addEventListener("keyup", (e) => { + this.handleKeyInput(e.key.toLowerCase(), false); + }); + + document.addEventListener("keypress", (e) => { + if (["w", "a", "s", "d", "p", "r", "m"].includes(e.key.toLowerCase())) { + e.preventDefault(); + } + }); + } + + handleKeyInput(key, pressed) { + if (pressed) { + switch (key) { + case "p": + this.togglePrediction(); + return; + case "r": + this.resetStats(); + return; + case "m": + this.toggleMetrics(); + return; + } + } + + if (["w", "a", "s", "d"].includes(key)) { + const oldInputs = { ...this.inputs }; + this.inputs[key] = pressed; + + if (JSON.stringify(oldInputs) !== JSON.stringify(this.inputs)) { + this.sendInputs(); + } + } + } + + sendInputs() { + if (!this.connected) return; + + this.inputSequence++; + this.clientMetrics.inputsSent++; + const timestamp = Date.now(); + + this.inputBuffer.set(this.inputSequence, { + inputs: { ...this.inputs }, + timestamp: timestamp, + }); + + this.stateHistory.set(this.inputSequence, { + state: this.clientRobotState ? { ...this.clientRobotState } : null, + timestamp: timestamp, + }); + + if (this.stateHistory.size > 60) { + const oldestKey = Math.min(...this.stateHistory.keys()); + this.stateHistory.delete(oldestKey); + } + + const message = { + type: "input", + data: { + inputs: { ...this.inputs }, + sequence: this.inputSequence, + timestamp: timestamp, + }, + }; + + this.sendMessage(message); + this.inputSequenceEl.textContent = this.inputSequence; + } + + applyClientPrediction() { + if (!this.clientRobotState) return; + + const now = Date.now(); + const deltaTime = Math.min( + (now - this.clientRobotState.lastUpdateTime) / 1000, + 1 / 60 + ); + + const targetVelocity = { x: 0, y: 0 }; + + if (this.inputs.w) targetVelocity.y -= this.robotSpeed; + if (this.inputs.s) targetVelocity.y += this.robotSpeed; + if (this.inputs.a) targetVelocity.x -= this.robotSpeed; + if (this.inputs.d) targetVelocity.x += this.robotSpeed; + + const smoothing = 0.15; + this.clientRobotState.velocity.x = + this.clientRobotState.velocity.x * (1 - smoothing) + + targetVelocity.x * smoothing; + this.clientRobotState.velocity.y = + this.clientRobotState.velocity.y * (1 - smoothing) + + targetVelocity.y * smoothing; + + this.clientRobotState.position.x += + this.clientRobotState.velocity.x * deltaTime; + this.clientRobotState.position.y += + this.clientRobotState.velocity.y * deltaTime; + + this.clientRobotState.position.x = Math.max( + 0, + Math.min( + this.worldSize.width - this.robotSize.width, + this.clientRobotState.position.x + ) + ); + this.clientRobotState.position.y = Math.max( + 0, + Math.min( + this.worldSize.height - this.robotSize.height, + this.clientRobotState.position.y + ) + ); + + if ( + Math.abs(this.clientRobotState.velocity.x) > 10 || + Math.abs(this.clientRobotState.velocity.y) > 10 + ) { + this.clientRobotState.rotation = + (Math.atan2( + this.clientRobotState.velocity.y, + this.clientRobotState.velocity.x + ) * + 180) / + Math.PI; + } + + this.clientRobotState.lastUpdateTime = now; + } + + togglePrediction() { + this.predictionEnabled = !this.predictionEnabled; + this.predictionEnabledEl.textContent = this.predictionEnabled + ? "ON" + : "OFF"; + this.predictionEnabledEl.className = this.predictionEnabled + ? "stat-value prediction-enabled" + : "stat-value prediction-disabled"; + + console.log( + `Prediction ${this.predictionEnabled ? "enabled" : "disabled"}` + ); + } + + resetStats() { + this.correctionCount = 0; + this.totalDivergence = 0; + this.inputLagMeasurements = []; + this.lastCorrectionEl.textContent = "None"; + + // Reset client metrics + this.clientMetrics.totalFrames = 0; + this.clientMetrics.frameTimes = []; + this.clientMetrics.inputsSent = 0; + this.clientMetrics.messagesReceived = 0; + this.clientMetrics.predictionAccuracy.samples = []; + this.clientMetrics.networkStats.roundTripTimes = []; + this.clientMetrics.startTime = Date.now(); + + this.updatePredictionMetrics(); + console.log("Stats reset"); + } + + toggleMetrics() { + this.metricsVisible = !this.metricsVisible; + const display = this.metricsVisible ? "block" : "none"; + + if (this.infoPanel) this.infoPanel.style.display = display; + if (this.predictionPanel) this.predictionPanel.style.display = display; + if (this.serverPanel) this.serverPanel.style.display = display; + if (this.controlsPanel) this.controlsPanel.style.display = display; + + console.log(`Metrics panels ${this.metricsVisible ? "shown" : "hidden"}`); + } + + updatePredictionMetrics() { + this.correctionCountEl.textContent = this.correctionCount; + + const avgDivergence = + this.correctionCount > 0 + ? (this.totalDivergence / this.correctionCount).toFixed(1) + : "0.0"; + this.avgDivergenceEl.textContent = avgDivergence; + } + + sendMessage(message) { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + const messageStr = JSON.stringify(message); + this.clientMetrics.bytesSent += messageStr.length; + this.ws.send(messageStr); + } + } + + startRenderLoop() { + const render = (timestamp) => { + this.render(timestamp); + requestAnimationFrame(render); + }; + requestAnimationFrame(render); + } + + render(timestamp) { + // Track frame timing + if (this.lastRenderTime > 0) { + const frameTime = timestamp - this.lastRenderTime; + this.clientMetrics.frameTimes.push(frameTime); + if (this.clientMetrics.frameTimes.length > 60) { + this.clientMetrics.frameTimes.shift(); + } + } + this.clientMetrics.totalFrames++; + + if (this.predictionEnabled && this.clientRobotState) { + this.applyClientPrediction(); + } + + this.ctx.fillStyle = "#000"; + this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); + + this.drawGrid(); + + for (const [robotId, robot] of this.robots) { + if (robotId === this.robotId) { + this.drawOwnRobot(robot); + } else { + this.drawOtherRobot(robot); + } + } + + this.drawDebugInfo(); + + this.lastRenderTime = timestamp; + } + + drawGrid() { + this.ctx.strokeStyle = "#333"; + this.ctx.lineWidth = 1; + + for (let x = 0; x <= this.worldSize.width; x += 100) { + this.ctx.beginPath(); + this.ctx.moveTo(x, 0); + this.ctx.lineTo(x, this.worldSize.height); + this.ctx.stroke(); + } + + for (let y = 0; y <= this.worldSize.height; y += 100) { + this.ctx.beginPath(); + this.ctx.moveTo(0, y); + this.ctx.lineTo(this.worldSize.width, y); + this.ctx.stroke(); + } + } + + drawOwnRobot(robot) { + const state = + this.predictionEnabled && this.clientRobotState + ? this.clientRobotState + : robot; + + if (this.predictionEnabled && this.serverRobotState) { + this.drawRobotAt( + this.serverRobotState.position, + this.serverRobotState.rotation, + "rgba(255, 255, 255, 0.3)", + "Ghost", + true + ); + } + + this.drawRobotAt(state.position, state.rotation, "#ff4444", "YOU"), + false; + + if ( + this.predictionEnabled && + Date.now() - this.lastCorrectionTime < 1000 + ) { + this.drawCorrectionIndicator(state.position); + } + } + + drawOtherRobot(robot) { + this.drawRobotAt( + robot.position, + robot.rotation, + "#44ff44", + "OTHER", + false + ); + } + + drawRobotAt(position, rotation, color, label, secondary) { + this.ctx.save(); + this.ctx.translate( + position.x + this.robotSize.width / 2, + position.y + this.robotSize.height / 2 + ); + this.ctx.rotate((rotation * Math.PI) / 180); + + this.ctx.fillStyle = color; + this.ctx.fillRect( + -this.robotSize.width / 2, + -this.robotSize.height / 2, + this.robotSize.width, + this.robotSize.height + ); + + this.ctx.fillStyle = "#fff"; + this.ctx.fillRect(this.robotSize.width / 2 - 5, -2, 10, 4); + + this.ctx.restore(); + + this.ctx.font = "10px monospace"; + this.ctx.textAlign = "center"; + if (secondary) { + this.ctx.fillStyle = "#aaa"; + this.ctx.fillText( + label, + position.x + this.robotSize.width / 2, + position.y + 62 + ); + } else { + this.ctx.fillStyle = "#fff"; + this.ctx.fillText( + label, + position.x + this.robotSize.width / 2, + position.y - 5 + ); + } + } + + drawCorrectionIndicator(position) { + const radius = 30; + const alpha = Math.max( + 0, + 1 - (Date.now() - this.lastCorrectionTime) / 1000 + ); + + this.ctx.strokeStyle = `rgba(255, 255, 0, ${alpha})`; + this.ctx.lineWidth = 3; + this.ctx.beginPath(); + this.ctx.arc( + position.x + this.robotSize.width / 2, + position.y + this.robotSize.height / 2, + radius, + 0, + Math.PI * 2 + ); + this.ctx.stroke(); + } + + drawDebugInfo() { + if ( + !this.predictionEnabled || + !this.clientRobotState || + !this.serverRobotState + ) + return; + + this.ctx.strokeStyle = "rgba(255, 255, 0, 0.8)"; + this.ctx.lineWidth = 2; + this.ctx.beginPath(); + this.ctx.moveTo( + this.clientRobotState.position.x + this.robotSize.width / 2, + this.clientRobotState.position.y + this.robotSize.height / 2 + ); + this.ctx.lineTo( + this.serverRobotState.position.x + this.robotSize.width / 2, + this.serverRobotState.position.y + this.robotSize.height / 2 + ); + this.ctx.stroke(); + } +} + +document.addEventListener("DOMContentLoaded", () => { + new PredictionRobotClient(); +}); diff --git a/client-prediction-spike/client/dashboard.html b/client-prediction-spike/client/dashboard.html new file mode 100644 index 0000000000..2b1c5318c9 --- /dev/null +++ b/client-prediction-spike/client/dashboard.html @@ -0,0 +1,584 @@ + + + + + + Client Prediction Metrics Dashboard + + + +
+
+ + + + + Last Update: Never + +
+ + +
+

Server Status

+
+
+ Server Online +
+
+
+ Uptime + 0:00:00 +
+
+ Connected Clients + 0 +
+
+ Active Robots + 0 +
+
+ Tick Rate + 60/60 Hz +
+
+
+ + +
+

Network Performance

+
+
+ Messages/sec + 0 +
+
+ Total Messages + 0 +
+
+ Data Sent (KB/s) + 0.0 +
+
+ Data Received (KB/s) + 0.0 +
+
+ Average Latency + 0ms +
+
+ Packet Loss + 0% +
+
+
+ + +
+

Prediction System

+
+
+ Total Corrections + 0 +
+
+ Avg Divergence + 0.0px +
+
+ Recent Corrections + 0 +
+
+ Prediction Accuracy + 100% +
+
+ Input Rate + 0/s +
+
+ Total Inputs + 0 +
+
+
+ + +
+

System Performance

+
+
+ Memory Usage + 0MB +
+
+ Avg Tick Time + 0.0ms +
+
+ Max Tick Time + 0.0ms +
+
+ Tick Efficiency + 100% +
+
+
+ + +
+

Connected Clients

+
+ +
+
+ + +
+

Latency History

+
+
Latency over time (ms)
+ +
+
+ + +
+

Prediction Corrections

+
+
Corrections per minute
+ +
+
+ + +
+

Event Log

+
+ +
+
+
+ + + + diff --git a/client-prediction-spike/client/index.html b/client-prediction-spike/client/index.html new file mode 100644 index 0000000000..d41d825774 --- /dev/null +++ b/client-prediction-spike/client/index.html @@ -0,0 +1,210 @@ + + + + + + Client-Side Prediction Spike + + + + + +
+
+ s: + Connecting... +
+
+ count: + 0 +
+
+ ping: + -ms +
+
+ jitter: + 0.0ms +
+
+ tick: + - +
+
+ fps: + 60 +
+
+ uptime: + 0:00 +
+
+ +
+
+ pred: + ON +
+
+ corrections: + 0 +
+
+ err: + 0.0px +
+
+ accuracy: + 0.0px +
+
+ latency: + 0ms +
+
+ cli-seq: + 0 +
+
+ realtime: + None +
+
+ +
+
+ srv-tick: + 60/60 +
+
+ msg/s: + 0 +
+
+ memory: + 0MB +
+
+ total-msg: + 0 +
+
+ +
+
since a certain someone doesn't know how to use wasd :)
+
WASD: Move robot
+
P: Toggle prediction
+
R: Reset stats
+
M: Toggle metrics (press to show/hide panels)
+
+ + + + diff --git a/client-prediction-spike/package.json b/client-prediction-spike/package.json new file mode 100644 index 0000000000..ba262e6afd --- /dev/null +++ b/client-prediction-spike/package.json @@ -0,0 +1,26 @@ +{ + "name": "synthesis-client-prediction-spike", + "version": "1.0.0", + "description": "Client-side prediction with server reconciliation spike for improved input responsiveness", + "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/client-prediction-spike/server.js b/client-prediction-spike/server.js new file mode 100644 index 0000000000..84c67c990a --- /dev/null +++ b/client-prediction-spike/server.js @@ -0,0 +1,754 @@ +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 + PREDICTION_BUFFER_SIZE: 120, // Keep 2 seconds of history at 60fps + RECONCILIATION_THRESHOLD: 4, // Pixels - correction threshold +}; + +class GameServer { + constructor() { + this.clients = new Map(); + this.robots = new Map(); + this.lastTick = Date.now(); + this.tickCount = 0; + this.sequenceNumber = 0; + this.inputHistory = new Map(); + this.stateHistory = new Map(); + + // Enhanced metrics collection + this.serverMetrics = { + startTime: Date.now(), + totalMessages: 0, + totalInputs: 0, + totalCorrections: 0, + totalDivergence: 0, + frameTimeSamples: [], + clientLatencies: new Map(), + inputRates: new Map(), + correctionHistory: [], + networkStats: { + bytesReceived: 0, + bytesSent: 0, + messagesPerSecond: 0, + lastSecondMessages: 0, + lastSecondStart: Date.now() + }, + performanceStats: { + avgTickTime: 0, + maxTickTime: 0, + tickTimeSamples: [], + memoryUsage: process.memoryUsage(), + cpuUsage: 0 + } + }; + + this.app = express(); + this.httpServer = null; + this.wss = null; + + this.setupExpress(); + this.setupWebSocket(); + this.startMetricsCollection(); + } + + setupExpress() { + this.app.use(express.static(path.join(__dirname, "client"))); + this.app.use(helmet()); + + this.app.get("/", (req, res) => { + res.sendFile(path.join(__dirname, "client", "index.html")); + }); + + this.app.get("/dashboard", (req, res) => { + res.sendFile(path.join(__dirname, "client", "dashboard.html")); + }); + + 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, + predictionEnabled: true, + reconciliationThreshold: GAME_CONFIG.RECONCILIATION_THRESHOLD, + serverMetrics: this.getDetailedStats(), + uptime: Date.now() - this.serverMetrics.startTime + }); + }); + + this.app.get("/api/metrics", (req, res) => { + res.json(this.getDetailedStats()); + }); + } + + setupWebSocket() { + this.httpServer = this.app.listen(3000, () => { + console.log(`Prediction Server running on http://localhost:3000`); + console.log(`Tick Rate: ${GAME_CONFIG.TICK_RATE} Hz`); + console.log( + `Reconciliation Threshold: ${GAME_CONFIG.RECONCILIATION_THRESHOLD}px` + ); + }); + + this.wss = new WebSocketServer({ server: this.httpServer }); + + this.wss.on("connection", (ws, req) => { + this.handleClientConnection(ws, req); + }); + + this.startGameLoop(); + } + + startGameLoop() { + const targetDelta = 1000 / GAME_CONFIG.TICK_RATE; + + const gameLoop = () => { + const tickStart = Date.now(); + const now = Date.now(); + const deltaTime = (now - this.lastTick) / 1000; + + this.updateWorld(deltaTime); + this.sendStateUpdates(); + this.sendPingsToAllClients(); + + // Collect performance metrics + const tickTime = Date.now() - tickStart; + this.collectPerformanceMetrics(tickTime); + + this.lastTick = now; + this.tickCount++; + + setTimeout(gameLoop, targetDelta); + }; + + gameLoop(); + } + + startMetricsCollection() { + // Collect metrics every second + setInterval(() => { + this.updateNetworkStats(); + this.collectSystemMetrics(); + this.broadcastMetricsToClients(); + }, 1000); + } + + collectPerformanceMetrics(tickTime) { + this.serverMetrics.performanceStats.tickTimeSamples.push(tickTime); + + if (this.serverMetrics.performanceStats.tickTimeSamples.length > 60) { + this.serverMetrics.performanceStats.tickTimeSamples.shift(); + } + + this.serverMetrics.performanceStats.avgTickTime = + this.serverMetrics.performanceStats.tickTimeSamples.reduce((a, b) => a + b, 0) / + this.serverMetrics.performanceStats.tickTimeSamples.length; + + this.serverMetrics.performanceStats.maxTickTime = Math.max( + this.serverMetrics.performanceStats.maxTickTime, + tickTime + ); + } + + updateNetworkStats() { + const now = Date.now(); + if (now - this.serverMetrics.networkStats.lastSecondStart >= 1000) { + this.serverMetrics.networkStats.messagesPerSecond = + this.serverMetrics.networkStats.lastSecondMessages; + this.serverMetrics.networkStats.lastSecondMessages = 0; + this.serverMetrics.networkStats.lastSecondStart = now; + } + } + + collectSystemMetrics() { + this.serverMetrics.performanceStats.memoryUsage = process.memoryUsage(); + + // Collect CPU usage (simplified) + const usage = process.cpuUsage(); + this.serverMetrics.performanceStats.cpuUsage = + (usage.user + usage.system) / 1000000; // Convert to seconds + } + + broadcastMetricsToClients() { + const metricsData = { + type: "serverMetrics", + data: { + tickRate: GAME_CONFIG.TICK_RATE, + actualTickRate: this.calculateActualTickRate(), + connectedClients: this.clients.size, + totalMessages: this.serverMetrics.totalMessages, + messagesPerSecond: this.serverMetrics.networkStats.messagesPerSecond, + avgTickTime: Math.round(this.serverMetrics.performanceStats.avgTickTime * 100) / 100, + memoryUsageMB: Math.round(this.serverMetrics.performanceStats.memoryUsage.heapUsed / 1024 / 1024), + totalCorrections: this.serverMetrics.totalCorrections, + uptime: Date.now() - this.serverMetrics.startTime + } + }; + + this.broadcast(metricsData); + } + + calculateActualTickRate() { + if (this.serverMetrics.performanceStats.tickTimeSamples.length === 0) return 0; + + const avgTickTime = this.serverMetrics.performanceStats.avgTickTime; + return avgTickTime > 0 ? Math.round(1000 / avgTickTime) : 0; + } + + updateWorld(deltaTime) { + this.sequenceNumber++; + + this.storeStateSnapshot(); + + for (const [robotId, robot] of this.robots) { + this.updateRobotPhysics(robot, deltaTime); + } + } + + storeStateSnapshot() { + const snapshot = { + sequence: this.sequenceNumber, + timestamp: Date.now(), + robots: new Map(), + }; + + for (const [robotId, robot] of this.robots) { + snapshot.robots.set(robotId, { + position: { ...robot.position }, + velocity: { ...robot.velocity }, + rotation: robot.rotation, + inputs: { ...robot.inputs }, + }); + } + + this.stateHistory.set(this.sequenceNumber, snapshot); + + if (this.stateHistory.size > GAME_CONFIG.PREDICTION_BUFFER_SIZE) { + const oldestKey = Math.min(...this.stateHistory.keys()); + this.stateHistory.delete(oldestKey); + } + } + + updateRobotPhysics(robot, deltaTime) { + const inputs = robot.inputs || {}; + + const targetVelocity = { x: 0, y: 0 }; + + if (inputs.w) targetVelocity.y -= GAME_CONFIG.ROBOT_SPEED; + if (inputs.s) targetVelocity.y += GAME_CONFIG.ROBOT_SPEED; + if (inputs.a) targetVelocity.x -= GAME_CONFIG.ROBOT_SPEED; + if (inputs.d) targetVelocity.x += GAME_CONFIG.ROBOT_SPEED; + + const smoothing = 0.15; + 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; + } + } + + sendStateUpdates() { + const gameState = { + sequence: this.sequenceNumber, + timestamp: Date.now(), + robots: Array.from(this.robots.values()).map((robot) => ({ + ...robot, + reconciliation: robot.reconciliation || {}, + })), + }; + + this.broadcast({ + type: "gameState", + data: gameState, + }); + } + + handleClientConnection(ws, req) { + const clientId = uuidv4(); + const robotId = uuidv4(); + + const client = { + id: clientId, + ws: ws, + robotId: robotId, + latency: 0, + lastPing: Date.now(), + inputSequence: 0, + lastProcessedInput: 0, + }; + + this.clients.set(clientId, client); + this.inputHistory.set(clientId, new Map()); + + 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, + inputs: {}, + lastInputTime: Date.now(), + reconciliation: { + corrections: 0, + totalDivergence: 0, + lastCorrectionTime: 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()), + predictionConfig: { + enabled: true, + bufferSize: GAME_CONFIG.PREDICTION_BUFFER_SIZE, + reconciliationThreshold: + GAME_CONFIG.RECONCILIATION_THRESHOLD, + }, + }, + }); + + 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(`WebSocket error on ${clientId}:`, error); + this.handleClientDisconnection(clientId); + }); + + this.sendPing(clientId); + } + + handleClientMessage(clientId, data) { + const client = this.clients.get(clientId); + if (!client) return; + + // Track message metrics + this.serverMetrics.totalMessages++; + this.serverMetrics.networkStats.lastSecondMessages++; + this.serverMetrics.networkStats.bytesReceived += data.length; + + 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; + case "predictionState": + this.handlePredictionState(clientId, message.data); + break; + default: + console.warn(`Unknown message 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; + + // Track input metrics + this.serverMetrics.totalInputs++; + if (!this.serverMetrics.inputRates.has(clientId)) { + this.serverMetrics.inputRates.set(clientId, { count: 0, lastReset: Date.now() }); + } + + const inputRate = this.serverMetrics.inputRates.get(clientId); + inputRate.count++; + + const inputSequence = inputData.sequence || 0; + const clientInputHistory = this.inputHistory.get(clientId); + + clientInputHistory.set(inputSequence, { + inputs: { ...inputData.inputs }, + timestamp: inputData.timestamp || Date.now(), + processed: false, + }); + + if (clientInputHistory.size > GAME_CONFIG.PREDICTION_BUFFER_SIZE) { + const oldestKey = Math.min(...clientInputHistory.keys()); + clientInputHistory.delete(oldestKey); + } + + robot.inputs = { ...inputData.inputs }; + robot.lastInputTime = Date.now(); + client.lastProcessedInput = inputSequence; + + this.sendToClient(clientId, { + type: "inputAck", + data: { + sequence: inputSequence, + serverSequence: this.sequenceNumber, + timestamp: Date.now(), + robotState: { + position: { ...robot.position }, + velocity: { ...robot.velocity }, + rotation: robot.rotation, + }, + }, + }); + } + + handlePredictionState(clientId, predictionData) { + const client = this.clients.get(clientId); + if (!client) return; + + const robot = this.robots.get(client.robotId); + if (!robot) return; + + const divergence = this.calculateDivergence( + predictionData.position, + robot.position + ); + + robot.reconciliation.totalDivergence += divergence; + + if (divergence > GAME_CONFIG.RECONCILIATION_THRESHOLD) { + robot.reconciliation.corrections++; + robot.reconciliation.lastCorrectionTime = Date.now(); + + // Track server-wide correction metrics + this.serverMetrics.totalCorrections++; + this.serverMetrics.totalDivergence += divergence; + + this.serverMetrics.correctionHistory.push({ + timestamp: Date.now(), + clientId: clientId, + divergence: divergence, + position: { ...predictionData.position }, + serverPosition: { ...robot.position } + }); + + // Keep only recent correction history + if (this.serverMetrics.correctionHistory.length > 100) { + this.serverMetrics.correctionHistory.shift(); + } + + this.sendToClient(clientId, { + type: "stateCorrection", + data: { + sequence: this.sequenceNumber, + timestamp: Date.now(), + robotState: { + position: { ...robot.position }, + velocity: { ...robot.velocity }, + rotation: robot.rotation, + }, + divergence: divergence, + correctionType: + divergence > GAME_CONFIG.RECONCILIATION_THRESHOLD * 2 + ? "snap" + : "smooth", + }, + }); + } + } + + calculateDivergence(clientPos, serverPos) { + const dx = clientPos.x - serverPos.x; + const dy = clientPos.y - serverPos.y; + return Math.sqrt(dx * dx + dy * dy); + } + + handlePongMessage(clientId, data) { + const client = this.clients.get(clientId); + if (!client) return; + + const now = Date.now(); + const latency = now - data.timestamp; + client.latency = latency; + client.lastPing = now; + + // Track latency metrics + this.serverMetrics.clientLatencies.set(clientId, { + current: latency, + history: (this.serverMetrics.clientLatencies.get(clientId)?.history || []).slice(-20).concat(latency) + }); + } + + 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); + this.inputHistory.delete(clientId); + + console.log(`Clients remaining: ${this.clients.size}`); + } + + sendToClient(clientId, message) { + const client = this.clients.get(clientId); + if (!client || client.ws.readyState !== 1) return; + + try { + const messageStr = JSON.stringify(message); + this.serverMetrics.networkStats.bytesSent += messageStr.length; + client.ws.send(messageStr); + } catch (error) { + console.error(`Message send error to ${clientId}:`, error); + } + } + + broadcast(message) { + const messageStr = JSON.stringify(message); + this.serverMetrics.networkStats.bytesSent += messageStr.length * this.clients.size; + + for (const [clientId, client] of this.clients) { + if (client.ws.readyState === 1) { + try { + client.ws.send(messageStr); + } catch (error) { + console.error(`Broadcast error 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 error 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) { + if (now - client.lastPing > 1000) { + this.sendPing(clientId); + } + } + } + + getStats() { + const clients = Array.from(this.clients.values()); + const robots = Array.from(this.robots.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, + totalCorrections: robots.reduce( + (sum, robot) => sum + robot.reconciliation.corrections, + 0 + ), + averageDivergence: + robots.length > 0 + ? robots.reduce( + (sum, robot) => + sum + robot.reconciliation.totalDivergence, + 0 + ) / robots.length + : 0, + sequenceNumber: this.sequenceNumber, + tickCount: this.tickCount, + }; + } + + getDetailedStats() { + const clients = Array.from(this.clients.values()); + const robots = Array.from(this.robots.values()); + const now = Date.now(); + const uptime = now - this.serverMetrics.startTime; + + // Calculate input rates + const inputRates = new Map(); + for (const [clientId, rateData] of this.serverMetrics.inputRates) { + const timeSince = now - rateData.lastReset; + const rate = timeSince > 0 ? (rateData.count / timeSince) * 1000 : 0; + inputRates.set(clientId, Math.round(rate * 10) / 10); + } + + // Calculate average latencies with history + const latencyStats = new Map(); + for (const [clientId, latencyData] of this.serverMetrics.clientLatencies) { + const avg = latencyData.history.length > 0 + ? latencyData.history.reduce((a, b) => a + b, 0) / latencyData.history.length + : 0; + const min = latencyData.history.length > 0 ? Math.min(...latencyData.history) : 0; + const max = latencyData.history.length > 0 ? Math.max(...latencyData.history) : 0; + + latencyStats.set(clientId, { + current: latencyData.current, + average: Math.round(avg * 10) / 10, + min: min, + max: max + }); + } + + return { + // Basic stats + connectedClients: this.clients.size, + activeRobots: this.robots.size, + uptime: uptime, + + // Network stats + totalMessages: this.serverMetrics.totalMessages, + totalInputs: this.serverMetrics.totalInputs, + messagesPerSecond: this.serverMetrics.networkStats.messagesPerSecond, + bytesReceived: this.serverMetrics.networkStats.bytesReceived, + bytesSent: this.serverMetrics.networkStats.bytesSent, + networkThroughputKBps: { + received: Math.round((this.serverMetrics.networkStats.bytesReceived / 1024) / (uptime / 1000) * 10) / 10, + sent: Math.round((this.serverMetrics.networkStats.bytesSent / 1024) / (uptime / 1000) * 10) / 10 + }, + + // Performance stats + tickRate: { + target: GAME_CONFIG.TICK_RATE, + actual: this.calculateActualTickRate(), + efficiency: Math.round((this.calculateActualTickRate() / GAME_CONFIG.TICK_RATE) * 100) + }, + tickTiming: { + average: Math.round(this.serverMetrics.performanceStats.avgTickTime * 100) / 100, + maximum: this.serverMetrics.performanceStats.maxTickTime, + samples: this.serverMetrics.performanceStats.tickTimeSamples.length + }, + memory: { + heapUsedMB: Math.round(this.serverMetrics.performanceStats.memoryUsage.heapUsed / 1024 / 1024), + heapTotalMB: Math.round(this.serverMetrics.performanceStats.memoryUsage.heapTotal / 1024 / 1024), + externalMB: Math.round(this.serverMetrics.performanceStats.memoryUsage.external / 1024 / 1024) + }, + + // Game stats + prediction: { + totalCorrections: this.serverMetrics.totalCorrections, + avgDivergence: this.serverMetrics.totalCorrections > 0 + ? Math.round((this.serverMetrics.totalDivergence / this.serverMetrics.totalCorrections) * 10) / 10 + : 0, + recentCorrections: this.serverMetrics.correctionHistory.filter(c => now - c.timestamp < 10000).length + }, + + // Client-specific stats + clientStats: Array.from(this.clients.entries()).map(([clientId, client]) => ({ + id: clientId, + robotId: client.robotId, + latency: latencyStats.get(clientId) || { current: 0, average: 0, min: 0, max: 0 }, + inputRate: inputRates.get(clientId) || 0, + lastProcessedInput: client.lastProcessedInput, + connected: client.ws.readyState === 1 + })), + + // Recent activity + recentActivity: { + correctionsLast10s: this.serverMetrics.correctionHistory.filter(c => now - c.timestamp < 10000).length, + inputsPerSecond: this.serverMetrics.totalInputs > 0 ? Math.round((this.serverMetrics.totalInputs / (uptime / 1000)) * 10) / 10 : 0 + } + }; + } +} + +const server = new GameServer(); + +// Graceful shutdown +process.on("SIGINT", () => { + console.log("\nShutting down server..."); + console.log("Final stats:", server.getStats()); + process.exit(0); +});