mirror of
https://github.com/logsol/chuck.js.git
synced 2026-05-11 10:37:34 +00:00
Add standalone POC comparing WebRTC DataChannel vs Socket.IO
Self-contained test under poc-webrtc/ that does not touch the game. Spins up an Express + WebSocket signaling + node-datachannel server alongside a Socket.IO server, serves a simple browser client that runs the same game-like traffic pattern (14Hz worldUpdates, input events, ping/pong) over either transport based on a URL flag. Captures per-session stats to a JSONL file and ships an analyze.js that prints a per-(transport, phase) summary of RTT percentiles, receive rate, and seq-gap counts so the TCP-vs-UDP-style comparison becomes quantitative rather than eyeball. Confirms node-datachannel installs and works on this platform and that the dual-channel (reliable + unreliable) pattern is feasible to maintain — both prerequisites for the real integration.
This commit is contained in:
parent
71e4b4e847
commit
47faae81e5
9 changed files with 2407 additions and 0 deletions
85
poc-webrtc/README.md
Normal file
85
poc-webrtc/README.md
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
# chuck.js WebRTC POC
|
||||
|
||||
Isolated test of WebRTC DataChannels (UDP-like, unreliable) as an alternative
|
||||
to Socket.IO (TCP) for the game's networking. Does not touch the chuck.js code.
|
||||
|
||||
## Run
|
||||
|
||||
```
|
||||
cd poc-webrtc
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
|
||||
Open two browser tabs:
|
||||
|
||||
- http://localhost:1235/?transport=webrtc
|
||||
- http://localhost:1235/?transport=socketio
|
||||
|
||||
Use **A/D** or **←/→** to move, **Space** to jump. The blue box is yours; yellow
|
||||
boxes are other connected clients (open more tabs to add players).
|
||||
|
||||
The right-hand panel shows live RTT, packet rate, last received sequence
|
||||
number, and number of detected sequence gaps (= packets the unreliable channel
|
||||
silently dropped).
|
||||
|
||||
Every connection also streams its stats to the server once per second. The
|
||||
server writes everything to `runs/<timestamp>.jsonl` (one file per server
|
||||
start). Use the **phase buttons** on the UI before/after changing network
|
||||
conditions so the analyzer can group samples per phase.
|
||||
|
||||
After a test session, summarize with:
|
||||
|
||||
```
|
||||
node analyze.js
|
||||
```
|
||||
|
||||
This prints a per-(transport, phase) table of RTT percentiles, receive rate,
|
||||
and seq gap deltas — making the comparison quantitative instead of eyeball.
|
||||
|
||||
## What to look for
|
||||
|
||||
Baseline on localhost (no packet loss): both transports show single-digit
|
||||
millisecond RTT and zero gaps. WebRTC's RTT may be a hair higher due to DTLS
|
||||
overhead. That's fine — the point of the comparison is **under loss**.
|
||||
|
||||
Add packet loss via Chrome DevTools (Network → "Add custom profile" with
|
||||
download/upload throttling and packet loss) **or** via `tc netem` on Linux:
|
||||
|
||||
```
|
||||
sudo tc qdisc add dev lo root netem loss 5% delay 50ms
|
||||
# … run the test …
|
||||
sudo tc qdisc del dev lo root
|
||||
```
|
||||
|
||||
Expected difference under 5% loss:
|
||||
|
||||
| Metric | Socket.IO | WebRTC unreliable |
|
||||
|-----------------------|----------------------|------------------------|
|
||||
| RTT median | rises slightly | rises slightly |
|
||||
| RTT 95th percentile | spikes wildly (HoL) | stays close to median |
|
||||
| Seq gaps | ≈ 0 (TCP retransmits)| matches loss rate |
|
||||
| Box motion smoothness | stutters | smooth (skips a frame) |
|
||||
|
||||
The Socket.IO tab will stutter visibly because TCP holds back all later
|
||||
packets until the dropped one is retransmitted. The WebRTC tab will drop a
|
||||
worldUpdate here and there but every other update arrives on time.
|
||||
|
||||
## What this POC does NOT include
|
||||
|
||||
- TURN server (only STUN — works on localhost/LAN; real internet may need
|
||||
TURN for symmetric NATs)
|
||||
- Reconnection / connection-loss handling
|
||||
- Auth / encryption beyond WebRTC's built-in DTLS
|
||||
- chuck.js's actual protocol (it mimics the *shape* — 14Hz broadcasts, input
|
||||
events, ping/pong — not the message format)
|
||||
|
||||
## Decision after running
|
||||
|
||||
If WebRTC clearly outperforms under packet loss → write the integration plan
|
||||
that swaps chuck.js's Socket.IO game-command stream for an unreliable
|
||||
DataChannel (keeping Socket.IO or a reliable channel for join/leave/round
|
||||
control).
|
||||
|
||||
If the difference is marginal → the reconciliation-based approach already in
|
||||
the game is probably good enough, and the integration cost isn't justified.
|
||||
100
poc-webrtc/analyze.js
Normal file
100
poc-webrtc/analyze.js
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
// Reads a run JSONL file and prints a per-(transport, phase) summary.
|
||||
// Usage: node analyze.js [runs/<file>.jsonl]
|
||||
// If no file given, uses the most recent file in runs/.
|
||||
|
||||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
function pickFile() {
|
||||
if (process.argv[2]) return process.argv[2];
|
||||
const dir = path.join(__dirname, "runs");
|
||||
const files = fs.readdirSync(dir)
|
||||
.filter((f) => f.endsWith(".jsonl"))
|
||||
.map((f) => ({ f, t: fs.statSync(path.join(dir, f)).mtimeMs }))
|
||||
.sort((a, b) => b.t - a.t);
|
||||
if (!files.length) {
|
||||
console.error("No run files in " + dir);
|
||||
process.exit(1);
|
||||
}
|
||||
return path.join(dir, files[0].f);
|
||||
}
|
||||
|
||||
const file = pickFile();
|
||||
console.log("Analyzing: " + file + "\n");
|
||||
|
||||
const lines = fs.readFileSync(file, "utf8").trim().split("\n");
|
||||
const events = lines.map((l) => { try { return JSON.parse(l); } catch (_) { return null; } }).filter(Boolean);
|
||||
|
||||
// Group stats events by (session, transport) → phase
|
||||
// Phase is the most recent "phase" event for that session before this stats event.
|
||||
const sessionPhase = new Map();
|
||||
const bucket = new Map(); // key: transport|phase → samples
|
||||
|
||||
function bucketKey(transport, phase) { return transport + "|" + phase; }
|
||||
|
||||
for (const e of events) {
|
||||
if (e.type === "session_start") {
|
||||
sessionPhase.set(e.session, "unmarked");
|
||||
} else if (e.type === "phase") {
|
||||
sessionPhase.set(e.session, e.label || "unmarked");
|
||||
} else if (e.type === "stats") {
|
||||
const phase = sessionPhase.get(e.session) || "unmarked";
|
||||
const k = bucketKey(e.transport, phase);
|
||||
if (!bucket.has(k)) {
|
||||
bucket.set(k, {
|
||||
transport: e.transport, phase,
|
||||
rtt: [], recvRate: [], sendRate: [], seqGaps: [], reports: 0
|
||||
});
|
||||
}
|
||||
const b = bucket.get(k);
|
||||
b.reports++;
|
||||
for (const r of e.rttSamples) b.rtt.push(r);
|
||||
b.recvRate.push(e.recvRate);
|
||||
b.sendRate.push(e.sendRate);
|
||||
b.seqGaps.push(e.seqGaps);
|
||||
}
|
||||
}
|
||||
|
||||
function pct(arr, p) {
|
||||
if (!arr.length) return NaN;
|
||||
const s = arr.slice().sort((a, b) => a - b);
|
||||
return s[Math.min(s.length - 1, Math.floor(s.length * p))];
|
||||
}
|
||||
function mean(arr) { return arr.length ? arr.reduce((a, b) => a + b, 0) / arr.length : NaN; }
|
||||
function fmt(n) { return Number.isFinite(n) ? n.toFixed(1) : " - "; }
|
||||
function pad(s, n) { s = String(s); return s + " ".repeat(Math.max(0, n - s.length)); }
|
||||
|
||||
if (!bucket.size) {
|
||||
console.log("No stats events found in run.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const header = [
|
||||
pad("transport", 10), pad("phase", 18),
|
||||
pad("samples", 8), pad("rtt p50", 10), pad("rtt p95", 10),
|
||||
pad("rtt p99", 10), pad("rtt max", 10),
|
||||
pad("recvHz", 8), pad("gapsΔ", 8)
|
||||
].join(" ");
|
||||
console.log(header);
|
||||
console.log("-".repeat(header.length));
|
||||
|
||||
const keys = [...bucket.keys()].sort();
|
||||
for (const k of keys) {
|
||||
const b = bucket.get(k);
|
||||
const gapsDelta = b.seqGaps.length ? b.seqGaps[b.seqGaps.length - 1] - b.seqGaps[0] : 0;
|
||||
console.log([
|
||||
pad(b.transport, 10),
|
||||
pad(b.phase, 18),
|
||||
pad(b.rtt.length, 8),
|
||||
pad(fmt(pct(b.rtt, 0.5)), 10),
|
||||
pad(fmt(pct(b.rtt, 0.95)), 10),
|
||||
pad(fmt(pct(b.rtt, 0.99)), 10),
|
||||
pad(fmt(Math.max(0, ...b.rtt)), 10),
|
||||
pad(fmt(mean(b.recvRate)), 8),
|
||||
pad(gapsDelta, 8)
|
||||
].join(" "));
|
||||
}
|
||||
|
||||
console.log("\nLegend: rtt in ms, recvHz = worldUpdates/sec, gapsΔ = seq gaps observed during phase");
|
||||
1576
poc-webrtc/package-lock.json
generated
Normal file
1576
poc-webrtc/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
16
poc-webrtc/package.json
Normal file
16
poc-webrtc/package.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"name": "chuck-webrtc-poc",
|
||||
"version": "0.0.1",
|
||||
"description": "POC: WebRTC DataChannel vs Socket.IO for chuck.js networking",
|
||||
"main": "server.js",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"node-datachannel": "^0.10.0",
|
||||
"socket.io": "^4.7.4",
|
||||
"ws": "^8.16.0"
|
||||
}
|
||||
}
|
||||
80
poc-webrtc/public/client.js
Normal file
80
poc-webrtc/public/client.js
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
// WebRTC DataChannel client. Signaling over WebSocket, then two channels:
|
||||
// - reliable: default settings, used for welcome/control
|
||||
// - unreliable: ordered:false, maxRetransmits:0 — used for game state + pings
|
||||
|
||||
(function () {
|
||||
const POC = window.POC;
|
||||
POC.setStatus("signaling…", "wait");
|
||||
|
||||
const wsProto = location.protocol === "https:" ? "wss" : "ws";
|
||||
const ws = new WebSocket(wsProto + "://" + location.host + "/signal");
|
||||
|
||||
const pc = new RTCPeerConnection({
|
||||
iceServers: [{ urls: "stun:stun.l.google.com:19302" }]
|
||||
});
|
||||
|
||||
// Create both channels client-side so the server's onDataChannel fires for both.
|
||||
const reliable = pc.createDataChannel("reliable");
|
||||
const unreliable = pc.createDataChannel("unreliable", {
|
||||
ordered: false,
|
||||
maxRetransmits: 0
|
||||
});
|
||||
|
||||
function wireChannel(ch, label) {
|
||||
ch.onopen = () => {
|
||||
POC.log(`channel '${label}' open`);
|
||||
if (label === "unreliable") {
|
||||
POC.attach((obj) => {
|
||||
if (ch.readyState === "open") ch.send(JSON.stringify(obj));
|
||||
});
|
||||
POC.setStatus("connected (webrtc unreliable)", "ok");
|
||||
}
|
||||
};
|
||||
ch.onclose = () => {
|
||||
POC.log(`channel '${label}' closed`);
|
||||
POC.setStatus("disconnected", "bad");
|
||||
};
|
||||
ch.onerror = (e) => POC.log(`channel '${label}' error: ${e.message || e}`);
|
||||
ch.onmessage = (e) => {
|
||||
try { POC.handleMessage(JSON.parse(e.data)); }
|
||||
catch (err) { POC.log("parse error: " + err.message); }
|
||||
};
|
||||
}
|
||||
wireChannel(reliable, "reliable");
|
||||
wireChannel(unreliable, "unreliable");
|
||||
|
||||
pc.onicecandidate = (e) => {
|
||||
if (e.candidate) {
|
||||
ws.send(JSON.stringify({
|
||||
type: "ice",
|
||||
candidate: e.candidate.candidate,
|
||||
mid: e.candidate.sdpMid
|
||||
}));
|
||||
}
|
||||
};
|
||||
pc.onconnectionstatechange = () => {
|
||||
POC.log("pc state: " + pc.connectionState);
|
||||
if (pc.connectionState === "failed") POC.setStatus("failed", "bad");
|
||||
};
|
||||
|
||||
ws.onopen = async () => {
|
||||
POC.log("signaling open, creating offer");
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
ws.send(JSON.stringify({ type: "sdp", sdp: offer.sdp, sdpType: offer.type }));
|
||||
};
|
||||
ws.onmessage = async (e) => {
|
||||
const m = JSON.parse(e.data);
|
||||
if (m.type === "sdp") {
|
||||
await pc.setRemoteDescription({ type: m.sdpType, sdp: m.sdp });
|
||||
POC.log("got remote sdp (" + m.sdpType + ")");
|
||||
} else if (m.type === "ice") {
|
||||
try {
|
||||
await pc.addIceCandidate({ candidate: m.candidate, sdpMid: m.mid });
|
||||
} catch (err) {
|
||||
POC.log("addIceCandidate error: " + err.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
ws.onclose = () => POC.log("signaling closed");
|
||||
})();
|
||||
214
poc-webrtc/public/common.js
Normal file
214
poc-webrtc/public/common.js
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
// Shared client-side helpers: rendering, stats, input. The transport-specific
|
||||
// client (client.js or socketio-client.js) calls into POC.attach() with three
|
||||
// callbacks: send(obj), onmessage handler (provided by POC), and a status setter.
|
||||
|
||||
(function () {
|
||||
const canvas = document.getElementById("world");
|
||||
const ctx = canvas.getContext("2d");
|
||||
const rttChart = document.getElementById("rtt-chart");
|
||||
const rttCtx = rttChart.getContext("2d");
|
||||
|
||||
const statusEl = document.getElementById("status");
|
||||
const statusDot = document.getElementById("status-dot");
|
||||
const rttEl = document.getElementById("rtt");
|
||||
const rttAvgEl = document.getElementById("rtt-avg");
|
||||
const rttP95El = document.getElementById("rtt-p95");
|
||||
const rateEl = document.getElementById("rate");
|
||||
const seqEl = document.getElementById("seq");
|
||||
const gapsEl = document.getElementById("gaps");
|
||||
const logEl = document.getElementById("log");
|
||||
|
||||
const state = {
|
||||
myBoxId: null,
|
||||
boxes: {},
|
||||
send: null,
|
||||
pingSeq: 0,
|
||||
outstandingPings: new Map(),
|
||||
rttSamples: [], // sliding window, last 50 (for display)
|
||||
rttBucket: [], // accumulator since last stats report
|
||||
rateWindow: [], // worldUpdate receive times (last 1s)
|
||||
sendWindow: [], // send times (last 1s)
|
||||
lastSeq: 0,
|
||||
seqGaps: 0,
|
||||
inputSeq: 0
|
||||
};
|
||||
|
||||
const heldKeys = new Set();
|
||||
let lastSentCmd = null;
|
||||
|
||||
function setStatus(text, cls) {
|
||||
statusEl.textContent = text;
|
||||
statusDot.className = "dot " + (cls || "wait");
|
||||
}
|
||||
|
||||
function log(line) {
|
||||
const ts = new Date().toISOString().substr(11, 12);
|
||||
logEl.textContent = ts + " " + line + "\n" + logEl.textContent;
|
||||
if (logEl.textContent.length > 4000) logEl.textContent = logEl.textContent.slice(0, 4000);
|
||||
}
|
||||
|
||||
function handleMessage(data) {
|
||||
if (data.type === "welcome") {
|
||||
state.myBoxId = data.boxId;
|
||||
log("welcome boxId=" + data.boxId);
|
||||
} else if (data.type === "worldUpdate") {
|
||||
state.boxes = data.boxes || {};
|
||||
state.rateWindow.push(performance.now());
|
||||
if (data.seq > state.lastSeq + 1 && state.lastSeq > 0) {
|
||||
state.seqGaps += data.seq - state.lastSeq - 1;
|
||||
}
|
||||
state.lastSeq = data.seq;
|
||||
} else if (data.type === "pong") {
|
||||
const sent = state.outstandingPings.get(data.t);
|
||||
if (sent !== undefined) {
|
||||
const rtt = performance.now() - sent;
|
||||
state.outstandingPings.delete(data.t);
|
||||
state.rttSamples.push(rtt);
|
||||
state.rttBucket.push(rtt);
|
||||
if (state.rttSamples.length > 50) state.rttSamples.shift();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sendRaw(obj) {
|
||||
if (!state.send) return;
|
||||
state.sendWindow.push(performance.now());
|
||||
state.send(obj);
|
||||
}
|
||||
|
||||
function ping() {
|
||||
if (!state.send) return;
|
||||
const t = performance.now();
|
||||
state.outstandingPings.set(t, t);
|
||||
sendRaw({ type: "ping", t });
|
||||
// GC stale pings (older than 5s)
|
||||
for (const k of state.outstandingPings.keys()) {
|
||||
if (performance.now() - k > 5000) state.outstandingPings.delete(k);
|
||||
}
|
||||
}
|
||||
setInterval(ping, 100);
|
||||
|
||||
function sendInput(cmd) {
|
||||
if (!state.send || lastSentCmd === cmd) return;
|
||||
lastSentCmd = cmd;
|
||||
state.inputSeq++;
|
||||
sendRaw({ type: "input", seq: state.inputSeq, cmd });
|
||||
}
|
||||
|
||||
// Report stats to server every 1s for offline analysis
|
||||
setInterval(() => {
|
||||
if (!state.send) return;
|
||||
const now = performance.now();
|
||||
state.sendWindow = state.sendWindow.filter((t) => now - t < 1000);
|
||||
const bucket = state.rttBucket;
|
||||
state.rttBucket = [];
|
||||
sendRaw({
|
||||
type: "stats",
|
||||
rttSamples: bucket,
|
||||
recvRate: state.rateWindow.length,
|
||||
sendRate: state.sendWindow.length,
|
||||
lastSeq: state.lastSeq,
|
||||
seqGaps: state.seqGaps,
|
||||
outstanding: state.outstandingPings.size
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
// Phase marker buttons
|
||||
function markPhase(label) {
|
||||
sendRaw({ type: "phase", label });
|
||||
log("phase → " + label);
|
||||
}
|
||||
window.POC_MARK = markPhase;
|
||||
|
||||
function tickInput() {
|
||||
const left = heldKeys.has("ArrowLeft") || heldKeys.has("a") || heldKeys.has("A");
|
||||
const right = heldKeys.has("ArrowRight") || heldKeys.has("d") || heldKeys.has("D");
|
||||
if (left && !right) sendInput("left");
|
||||
else if (right && !left) sendInput("right");
|
||||
else if (!left && !right) sendInput("stop");
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", (e) => {
|
||||
if (e.repeat) return;
|
||||
heldKeys.add(e.key);
|
||||
if (e.key === " " || e.key === "Spacebar") {
|
||||
state.inputSeq++;
|
||||
if (state.send) state.send({ type: "input", seq: state.inputSeq, cmd: "jump" });
|
||||
e.preventDefault();
|
||||
} else {
|
||||
tickInput();
|
||||
}
|
||||
});
|
||||
window.addEventListener("keyup", (e) => {
|
||||
heldKeys.delete(e.key);
|
||||
tickInput();
|
||||
});
|
||||
|
||||
function fmt(n) { return n.toFixed(1); }
|
||||
function p95(arr) {
|
||||
if (!arr.length) return 0;
|
||||
const sorted = arr.slice().sort((a, b) => a - b);
|
||||
return sorted[Math.floor(sorted.length * 0.95)];
|
||||
}
|
||||
|
||||
function refreshStats() {
|
||||
const samples = state.rttSamples;
|
||||
if (samples.length) {
|
||||
const last = samples[samples.length - 1];
|
||||
const avg = samples.reduce((a, b) => a + b, 0) / samples.length;
|
||||
rttEl.textContent = fmt(last) + " ms";
|
||||
rttAvgEl.textContent = fmt(avg) + " ms";
|
||||
rttP95El.textContent = fmt(p95(samples)) + " ms";
|
||||
}
|
||||
const now = performance.now();
|
||||
state.rateWindow = state.rateWindow.filter((t) => now - t < 1000);
|
||||
rateEl.textContent = String(state.rateWindow.length);
|
||||
seqEl.textContent = String(state.lastSeq);
|
||||
gapsEl.textContent = String(state.seqGaps);
|
||||
}
|
||||
setInterval(refreshStats, 200);
|
||||
|
||||
function render() {
|
||||
ctx.fillStyle = "#222";
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.fillStyle = "#444";
|
||||
ctx.fillRect(0, canvas.height - 20, canvas.width, 20);
|
||||
for (const id in state.boxes) {
|
||||
const b = state.boxes[id];
|
||||
ctx.fillStyle = id === state.myBoxId ? "#4cf" : "#cc6";
|
||||
ctx.fillRect(b.x - 15, b.y - 15, 30, 30);
|
||||
ctx.fillStyle = "#111";
|
||||
ctx.font = "10px monospace";
|
||||
ctx.fillText(id, b.x - 8, b.y + 3);
|
||||
}
|
||||
|
||||
// RTT chart
|
||||
rttCtx.fillStyle = "#1c1c1c";
|
||||
rttCtx.fillRect(0, 0, rttChart.width, rttChart.height);
|
||||
const samples = state.rttSamples;
|
||||
if (samples.length > 1) {
|
||||
const max = Math.max(50, ...samples);
|
||||
rttCtx.strokeStyle = "#4cf";
|
||||
rttCtx.beginPath();
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
const x = (i / (samples.length - 1)) * rttChart.width;
|
||||
const y = rttChart.height - (samples[i] / max) * (rttChart.height - 4) - 2;
|
||||
if (i === 0) rttCtx.moveTo(x, y); else rttCtx.lineTo(x, y);
|
||||
}
|
||||
rttCtx.stroke();
|
||||
rttCtx.fillStyle = "#666";
|
||||
rttCtx.font = "9px monospace";
|
||||
rttCtx.fillText(max.toFixed(0) + "ms", 2, 10);
|
||||
}
|
||||
|
||||
requestAnimationFrame(render);
|
||||
}
|
||||
requestAnimationFrame(render);
|
||||
|
||||
window.POC = {
|
||||
attach(send) { state.send = send; },
|
||||
setStatus,
|
||||
log,
|
||||
handleMessage
|
||||
};
|
||||
})();
|
||||
67
poc-webrtc/public/index.html
Normal file
67
poc-webrtc/public/index.html
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>chuck.js WebRTC POC</title>
|
||||
<style>
|
||||
body { font-family: monospace; background: #111; color: #ddd; margin: 0; padding: 12px; }
|
||||
.row { display: flex; gap: 12px; align-items: flex-start; }
|
||||
.panel { background: #1c1c1c; padding: 10px; border-radius: 6px; }
|
||||
canvas { background: #222; border: 1px solid #333; image-rendering: pixelated; }
|
||||
.stat { display: flex; justify-content: space-between; gap: 16px; min-width: 240px; }
|
||||
.stat span:last-child { color: #8cf; font-weight: bold; }
|
||||
h1 { font-size: 16px; margin: 0 0 8px; }
|
||||
.hint { color: #888; font-size: 11px; margin-top: 8px; }
|
||||
#log { font-size: 11px; line-height: 1.4; white-space: pre-wrap; max-height: 200px; overflow-y: auto; color: #888; }
|
||||
.dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; vertical-align: middle; margin-right: 4px; }
|
||||
.dot.ok { background: #4c4; }
|
||||
.dot.bad { background: #c44; }
|
||||
.dot.wait { background: #cc4; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>chuck.js networking POC — transport: <span id="transport">?</span></h1>
|
||||
<div class="row">
|
||||
<div class="panel">
|
||||
<canvas id="world" width="800" height="600"></canvas>
|
||||
<div class="hint">A/D or ←/→ move · Space jump</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="stat"><span><span class="dot wait" id="status-dot"></span>connection</span><span id="status">connecting…</span></div>
|
||||
<div class="stat"><span>RTT (last)</span><span id="rtt">– ms</span></div>
|
||||
<div class="stat"><span>RTT (avg, 50)</span><span id="rtt-avg">– ms</span></div>
|
||||
<div class="stat"><span>RTT (95p, 50)</span><span id="rtt-p95">– ms</span></div>
|
||||
<div class="stat"><span>worldUpdate /s</span><span id="rate">0</span></div>
|
||||
<div class="stat"><span>recv seq</span><span id="seq">0</span></div>
|
||||
<div class="stat"><span>seq gaps</span><span id="gaps">0</span></div>
|
||||
<canvas id="rtt-chart" width="240" height="80" style="margin-top:8px"></canvas>
|
||||
<div style="margin-top:8px">
|
||||
<div style="color:#888;font-size:11px;margin-bottom:4px">Mark phase in log:</div>
|
||||
<button onclick="POC_MARK('baseline')">baseline</button>
|
||||
<button onclick="POC_MARK('1%-loss')">1% loss</button>
|
||||
<button onclick="POC_MARK('5%-loss')">5% loss</button>
|
||||
<button onclick="POC_MARK('10%-loss+100ms')">10% + 100ms</button>
|
||||
<button onclick="POC_MARK(prompt('label?')||'')">custom…</button>
|
||||
</div>
|
||||
<div id="log"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
window.POC_TRANSPORT = new URLSearchParams(location.search).get("transport") || "webrtc";
|
||||
document.getElementById("transport").textContent = window.POC_TRANSPORT;
|
||||
</script>
|
||||
<script src="common.js"></script>
|
||||
<script>
|
||||
if (window.POC_TRANSPORT === "socketio") {
|
||||
const s = document.createElement("script");
|
||||
s.src = "/socketio/socket.io.js";
|
||||
s.onload = () => { const c = document.createElement("script"); c.src = "socketio-client.js"; document.body.appendChild(c); };
|
||||
document.body.appendChild(s);
|
||||
} else {
|
||||
const c = document.createElement("script");
|
||||
c.src = "client.js";
|
||||
document.body.appendChild(c);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
20
poc-webrtc/public/socketio-client.js
Normal file
20
poc-webrtc/public/socketio-client.js
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
// Socket.IO comparison client. Uses the same POC.attach() / POC.handleMessage()
|
||||
// interface as the WebRTC client so the comparison is apples-to-apples.
|
||||
|
||||
(function () {
|
||||
const POC = window.POC;
|
||||
POC.setStatus("connecting (socketio)…", "wait");
|
||||
|
||||
const sock = io({ path: "/socketio" });
|
||||
sock.on("connect", () => {
|
||||
POC.log("socket.io connected " + sock.id);
|
||||
POC.setStatus("connected (socket.io)", "ok");
|
||||
POC.attach((obj) => sock.emit("msg", obj));
|
||||
});
|
||||
sock.on("disconnect", () => {
|
||||
POC.log("socket.io disconnected");
|
||||
POC.setStatus("disconnected", "bad");
|
||||
});
|
||||
sock.on("connect_error", (err) => POC.log("socket.io error: " + err.message));
|
||||
sock.on("msg", (data) => POC.handleMessage(data));
|
||||
})();
|
||||
249
poc-webrtc/server.js
Normal file
249
poc-webrtc/server.js
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
"use strict";
|
||||
|
||||
const express = require("express");
|
||||
const http = require("http");
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const { WebSocketServer } = require("ws");
|
||||
const { Server: SocketIOServer } = require("socket.io");
|
||||
const nodeDataChannel = require("node-datachannel");
|
||||
|
||||
const PORT = 1235;
|
||||
|
||||
// ---------------- Run logging ----------------
|
||||
|
||||
const RUN_FILE = path.join(__dirname, "runs", new Date().toISOString().replace(/[:.]/g, "-") + ".jsonl");
|
||||
fs.mkdirSync(path.dirname(RUN_FILE), { recursive: true });
|
||||
fs.writeFileSync(RUN_FILE, JSON.stringify({ type: "run_start", t: Date.now() }) + "\n");
|
||||
console.log("Run log: " + RUN_FILE);
|
||||
|
||||
function logEvent(obj) {
|
||||
obj.t = obj.t || Date.now();
|
||||
fs.appendFile(RUN_FILE, JSON.stringify(obj) + "\n", () => {});
|
||||
}
|
||||
const TICK_HZ = 60;
|
||||
const BROADCAST_HZ = 14;
|
||||
const WORLD_WIDTH = 800;
|
||||
const WORLD_HEIGHT = 600;
|
||||
const SPEED = 240;
|
||||
|
||||
const app = express();
|
||||
app.use(express.static(path.join(__dirname, "public")));
|
||||
|
||||
const server = http.createServer(app);
|
||||
|
||||
// ---------------- Shared game state ----------------
|
||||
|
||||
const boxes = {};
|
||||
let nextBoxId = 1;
|
||||
|
||||
function createBox() {
|
||||
const id = "b" + nextBoxId++;
|
||||
boxes[id] = {
|
||||
id,
|
||||
x: WORLD_WIDTH / 2,
|
||||
y: WORLD_HEIGHT / 2,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
lastInputSeq: 0
|
||||
};
|
||||
return id;
|
||||
}
|
||||
|
||||
function destroyBox(id) {
|
||||
delete boxes[id];
|
||||
}
|
||||
|
||||
function applyInput(boxId, input) {
|
||||
const b = boxes[boxId];
|
||||
if (!b) return;
|
||||
if (input.seq !== undefined) b.lastInputSeq = input.seq;
|
||||
if (input.cmd === "left") b.vx = -SPEED;
|
||||
else if (input.cmd === "right") b.vx = SPEED;
|
||||
else if (input.cmd === "stop") b.vx = 0;
|
||||
else if (input.cmd === "jump") b.vy = -SPEED * 1.5;
|
||||
}
|
||||
|
||||
// Common handler for stats/phase messages so both transports log identically.
|
||||
function handleClientReport(session, transport, data) {
|
||||
if (data.type === "stats") {
|
||||
logEvent({
|
||||
type: "stats",
|
||||
session, transport,
|
||||
rttSamples: data.rttSamples || [],
|
||||
recvRate: data.recvRate || 0,
|
||||
sendRate: data.sendRate || 0,
|
||||
lastSeq: data.lastSeq || 0,
|
||||
seqGaps: data.seqGaps || 0,
|
||||
outstanding: data.outstanding || 0
|
||||
});
|
||||
} else if (data.type === "phase") {
|
||||
logEvent({ type: "phase", session, transport, label: data.label || "" });
|
||||
console.log(`[${transport}] phase: ${data.label}`);
|
||||
}
|
||||
}
|
||||
|
||||
setInterval(() => {
|
||||
const dt = 1 / TICK_HZ;
|
||||
for (const id in boxes) {
|
||||
const b = boxes[id];
|
||||
b.x += b.vx * dt;
|
||||
b.y += b.vy * dt + 0.5 * 800 * dt * dt;
|
||||
b.vy += 800 * dt;
|
||||
if (b.x < 0) { b.x = 0; b.vx = 0; }
|
||||
if (b.x > WORLD_WIDTH) { b.x = WORLD_WIDTH; b.vx = 0; }
|
||||
if (b.y > WORLD_HEIGHT - 20) {
|
||||
b.y = WORLD_HEIGHT - 20;
|
||||
b.vy = 0;
|
||||
}
|
||||
}
|
||||
}, 1000 / TICK_HZ);
|
||||
|
||||
function snapshotWorld() {
|
||||
const out = {};
|
||||
for (const id in boxes) {
|
||||
const b = boxes[id];
|
||||
out[id] = { x: b.x, y: b.y, ack: b.lastInputSeq };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------- WebRTC signaling + peer management ----------------
|
||||
|
||||
const wss = new WebSocketServer({ server, path: "/signal" });
|
||||
const rtcPeers = new Map();
|
||||
let nextPeerId = 1;
|
||||
|
||||
let rtcBroadcastSeq = 0;
|
||||
setInterval(() => {
|
||||
rtcBroadcastSeq++;
|
||||
const msg = JSON.stringify({
|
||||
type: "worldUpdate",
|
||||
seq: rtcBroadcastSeq,
|
||||
t: Date.now(),
|
||||
boxes: snapshotWorld()
|
||||
});
|
||||
for (const peer of rtcPeers.values()) {
|
||||
const ch = peer.unreliable;
|
||||
if (ch && ch.isOpen()) {
|
||||
try { ch.sendMessage(msg); } catch (_) {}
|
||||
}
|
||||
}
|
||||
}, 1000 / BROADCAST_HZ);
|
||||
|
||||
wss.on("connection", (ws) => {
|
||||
const peerId = "p" + nextPeerId++;
|
||||
const boxId = createBox();
|
||||
console.log(`[webrtc] signaling open ${peerId} → box ${boxId}`);
|
||||
|
||||
const pc = new nodeDataChannel.PeerConnection(peerId, {
|
||||
iceServers: ["stun:stun.l.google.com:19302"]
|
||||
});
|
||||
|
||||
const peer = { pc, boxId, reliable: null, unreliable: null };
|
||||
rtcPeers.set(peerId, peer);
|
||||
logEvent({ type: "session_start", session: peerId, transport: "webrtc", boxId });
|
||||
|
||||
pc.onLocalDescription((sdp, type) => {
|
||||
ws.send(JSON.stringify({ type: "sdp", sdp, sdpType: type }));
|
||||
});
|
||||
pc.onLocalCandidate((candidate, mid) => {
|
||||
ws.send(JSON.stringify({ type: "ice", candidate, mid }));
|
||||
});
|
||||
|
||||
pc.onDataChannel((dc) => {
|
||||
const label = dc.getLabel();
|
||||
if (label === "reliable") peer.reliable = dc;
|
||||
else if (label === "unreliable") peer.unreliable = dc;
|
||||
|
||||
dc.onOpen(() => {
|
||||
console.log(`[webrtc] ${peerId} channel '${label}' open`);
|
||||
if (label === "reliable") {
|
||||
dc.sendMessage(JSON.stringify({ type: "welcome", boxId }));
|
||||
}
|
||||
});
|
||||
dc.onMessage((msg) => {
|
||||
try {
|
||||
const data = JSON.parse(msg);
|
||||
if (data.type === "input") {
|
||||
applyInput(boxId, data);
|
||||
} else if (data.type === "ping") {
|
||||
dc.sendMessage(JSON.stringify({ type: "pong", t: data.t }));
|
||||
} else if (data.type === "stats" || data.type === "phase") {
|
||||
handleClientReport(peerId, "webrtc", data);
|
||||
}
|
||||
} catch (_) {}
|
||||
});
|
||||
dc.onClosed(() => console.log(`[webrtc] ${peerId} channel '${label}' closed`));
|
||||
});
|
||||
|
||||
ws.on("message", (raw) => {
|
||||
try {
|
||||
const m = JSON.parse(raw);
|
||||
if (m.type === "sdp") pc.setRemoteDescription(m.sdp, m.sdpType);
|
||||
else if (m.type === "ice") pc.addRemoteCandidate(m.candidate, m.mid);
|
||||
} catch (e) {
|
||||
console.error("[webrtc] bad signaling msg", e);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
console.log(`[webrtc] signaling closed ${peerId}`);
|
||||
try { pc.close(); } catch (_) {}
|
||||
rtcPeers.delete(peerId);
|
||||
destroyBox(boxId);
|
||||
logEvent({ type: "session_end", session: peerId, transport: "webrtc" });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------- Socket.IO comparison transport ----------------
|
||||
|
||||
const io = new SocketIOServer(server, { path: "/socketio" });
|
||||
const ioBoxes = new Map();
|
||||
let ioBroadcastSeq = 0;
|
||||
|
||||
setInterval(() => {
|
||||
ioBroadcastSeq++;
|
||||
const msg = {
|
||||
type: "worldUpdate",
|
||||
seq: ioBroadcastSeq,
|
||||
t: Date.now(),
|
||||
boxes: snapshotWorld()
|
||||
};
|
||||
for (const sock of ioBoxes.keys()) {
|
||||
sock.emit("msg", msg);
|
||||
}
|
||||
}, 1000 / BROADCAST_HZ);
|
||||
|
||||
io.on("connection", (sock) => {
|
||||
const boxId = createBox();
|
||||
ioBoxes.set(sock, boxId);
|
||||
console.log(`[socketio] connected ${sock.id} → box ${boxId}`);
|
||||
sock.emit("msg", { type: "welcome", boxId });
|
||||
logEvent({ type: "session_start", session: sock.id, transport: "socketio", boxId });
|
||||
|
||||
sock.on("msg", (data) => {
|
||||
if (data.type === "input") applyInput(boxId, data);
|
||||
else if (data.type === "ping") sock.emit("msg", { type: "pong", t: data.t });
|
||||
else if (data.type === "stats" || data.type === "phase") {
|
||||
handleClientReport(sock.id, "socketio", data);
|
||||
}
|
||||
});
|
||||
sock.on("disconnect", () => {
|
||||
console.log(`[socketio] disconnected ${sock.id}`);
|
||||
destroyBox(boxId);
|
||||
ioBoxes.delete(sock);
|
||||
logEvent({ type: "session_end", session: sock.id, transport: "socketio" });
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`POC server listening on http://localhost:${PORT}`);
|
||||
console.log(` WebRTC test: http://localhost:${PORT}/?transport=webrtc`);
|
||||
console.log(` Socket.IO: http://localhost:${PORT}/?transport=socketio`);
|
||||
});
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
nodeDataChannel.cleanup();
|
||||
process.exit(0);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue