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:
Jeena 2026-05-11 00:38:01 +00:00
parent 71e4b4e847
commit 47faae81e5
9 changed files with 2407 additions and 0 deletions

View 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
View 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
};
})();

View 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>

View 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));
})();