mirror of
https://github.com/logsol/chuck.js.git
synced 2026-05-11 10:37:34 +00:00
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.
249 lines
7.6 KiB
JavaScript
249 lines
7.6 KiB
JavaScript
"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);
|
|
});
|