Route gameCommand traffic through WebRTC unreliable DataChannel

Socket.IO (TCP) holds back later packets while it retransmits a lost
one, which stalls worldUpdate delivery on lossy long-distance links —
exactly the pattern game state suffers worst from. WebRTC DataChannels
in unreliable mode (ordered:false, maxRetransmits:0) drop late packets
instead of queueing them, which is what we want for high-frequency
state sync.

Adds a per-user WebRTCTransport on top of the existing Socket.IO
connection. Socket.IO stays in charge of bootstrap, signaling
(SDP/ICE exchange), and control messages — only gameCommand payloads
get routed onto the unreliable channel once it's open. If WebRTC
fails to negotiate, gameCommand transparently falls back to
Socket.IO, so the game keeps working unchanged.

A new StatsLogger writes per-session JSONL events (session_start,
webrtc_ready with negotiation time, per-second stats with transport,
RTT samples, recv/send rates, seq gaps) so we can compare real-world
runs (e.g. Germany server <-> Korea client) instead of guessing.
URL flag ?webrtc=0 forces fallback for A/B testing.

scripts/webrtc-browser-test.js spins up a headless Chromium against
a freshly-started server and asserts the unreliable channel opens
and gameCommand traffic actually rides it.
This commit is contained in:
Jeena 2026-05-11 00:38:18 +00:00
parent 47faae81e5
commit a0481ed867
9 changed files with 1412 additions and 138 deletions

View file

@ -19,78 +19,100 @@ function (ProtocolHelper, GameController, User, nc, Settings, domController) {
this.gameController = null; this.gameController = null;
this.users = {}; this.users = {};
// WebRTC state
this.useWebrtcRequested = readWebRtcPreference();
this.peerConnection = null;
this.unreliableChannel = null;
this.unreliableReady = false;
this.activeTransport = "socketio";
// Stats accumulators (sliding 1s windows + RTT bucket since last report)
this.rttBucket = [];
this.sendTimestamps = [];
this.recvTimestamps = [];
this.gapCount = 0;
this.lastWorldUpdateSeq = 0;
this.socketLink.on('connect', this.onConnect.bind(this)); this.socketLink.on('connect', this.onConnect.bind(this));
this.socketLink.on('disconnect', this.onDisconnect.bind(this)); this.socketLink.on('disconnect', this.onDisconnect.bind(this));
var self = this; var self = this;
this.socketLink.on('message', function (message) { this.socketLink.on('message', function (message) {
var m = JSON.parse(message) self.handleIncoming(message, "socketio");
if(Settings.NETWORK_LOG_INCOMING) {
var shouldBeFiltered = false;
var keyword;
for (var i = 0; i < Settings.NETWORK_LOG_FILTER.length; i++) {
keyword = Settings.NETWORK_LOG_FILTER[i];
if(message.search(keyword) != -1) {
shouldBeFiltered = true;
break;
}
};
if(!shouldBeFiltered) {
console.log('INCOMING', message);
}
}
ProtocolHelper.applyCommand(message, self);
}); });
nc.on(nc.ns.client.to.server.gameCommand.send, this.sendGameCommand, this); nc.on(nc.ns.client.to.server.gameCommand.send, this.sendGameCommand, this);
nc.on(nc.ns.core.game.events.level.loaded, this.onLevelLoaded, this); nc.on(nc.ns.core.game.events.level.loaded, this.onLevelLoaded, this);
domController.setNick(nickname); domController.setNick(nickname);
domController.setTransport && domController.setTransport("socket.io");
// Periodic stats report (over Socket.IO so it always reaches the server)
setInterval(this.reportStats.bind(this), 1000);
} }
// Socket callbacks function readWebRtcPreference() {
try {
var params = new URLSearchParams(window.location.search);
if (params.get("webrtc") === "0") return false;
if (params.get("webrtc") === "1") return true;
} catch (_) {}
return Settings.USE_WEBRTC !== false;
}
// ---------------- Socket.IO lifecycle ----------------
Networker.prototype.onConnect = function () { Networker.prototype.onConnect = function () {
console.log('connected.') console.log('connected.');
if(this.channelName) { if (this.channelName) {
var options = { this.sendCommand('join', { channelName: this.channelName, nickname: this.nickname });
channelName: this.channelName,
nickname: this.nickname
}
this.sendCommand('join', options);
domController.setConnected(true); domController.setConnected(true);
if (this.useWebrtcRequested) this.startWebrtc();
} else { } else {
alert("Error: no channel name"); alert("Error: no channel name");
window.location.href = "/"; window.location.href = "/";
} }
} };
Networker.prototype.onDisconnect = function () { Networker.prototype.onDisconnect = function () {
//if(this.gameController) this.gameController.destruct();
//this.gameController = null;
console.log('disconnected. game destroyed. no auto-reconnect'); console.log('disconnected. game destroyed. no auto-reconnect');
domController.setConnected(false); domController.setConnected(false);
} this.teardownWebrtc();
};
Networker.prototype.handleIncoming = function (message, transport) {
if (Settings.NETWORK_LOG_INCOMING) {
var skip = false;
for (var i = 0; i < Settings.NETWORK_LOG_FILTER.length; i++) {
if (message.indexOf(Settings.NETWORK_LOG_FILTER[i]) !== -1) { skip = true; break; }
}
if (!skip) console.log('INCOMING(' + transport + ')', message);
}
// Track worldUpdate seq gaps for stats. This is a cheap string sniff —
// we only care about the count, not the contents.
if (transport === "webrtc" || transport === "socketio") {
this.recvTimestamps.push(performance.now());
}
try {
ProtocolHelper.applyCommand(message, this);
} catch (e) {
console.warn("applyCommand failed:", e.message, message);
}
};
Networker.prototype.onJoinSuccess = function (options) { Networker.prototype.onJoinSuccess = function (options) {
console.log("join success") console.log("join success");
this.onUserJoined(options.user, true); this.onUserJoined(options.user, true);
this.meUserId = options.user.id; this.meUserId = options.user.id;
if (options.joinedUsers) { if (options.joinedUsers) {
for(var i = 0; i < options.joinedUsers.length; i++) { for (var i = 0; i < options.joinedUsers.length; i++) {
this.onUserJoined(options.joinedUsers[i]); this.onUserJoined(options.joinedUsers[i]);
} }
} }
this.initPing(); this.initPing();
} };
Networker.prototype.onJoinError = function(options) { Networker.prototype.onJoinError = function(options) {
alert(options.message); alert(options.message);
@ -98,17 +120,7 @@ function (ProtocolHelper, GameController, User, nc, Settings, domController) {
}; };
Networker.prototype.onLevelLoaded = function() { Networker.prototype.onLevelLoaded = function() {
/*
this.gameController.createMe(this.users[this.meUserId]);
for (var userId in this.users) {
if(this.meUserId != userId) {
this.gameController.createPlayer(this.users[userId]);
}
}*/
this.gameController.onLevelLoaded(); this.gameController.onLevelLoaded();
this.sendGameCommand("clientReady"); this.sendGameCommand("clientReady");
}; };
@ -117,61 +129,58 @@ function (ProtocolHelper, GameController, User, nc, Settings, domController) {
}; };
Networker.prototype.ping = function() { Networker.prototype.ping = function() {
this.lastPingSentAt = performance.now();
this.sendCommand("ping", Date.now()); this.sendCommand("ping", Date.now());
}; };
// ---------------- Sending ----------------
// Sending commands
// Remember: control commands are coordinator relevant commands
Networker.prototype.sendCommand = function (command, options) { Networker.prototype.sendCommand = function (command, options) {
var message = ProtocolHelper.encodeCommand(command, options); var message = ProtocolHelper.encodeCommand(command, options);
this.socketLink.send(message); this.socketLink.send(message);
this.logOutgoing(message, "socketio");
if(Settings.NETWORK_LOG_OUTGOING) { };
var shouldBeFiltered = false;
var keyword;
for (var i = 0; i < Settings.NETWORK_LOG_FILTER.length; i++) {
keyword = Settings.NETWORK_LOG_FILTER[i];
if(message.search(keyword) != -1) {
shouldBeFiltered = true;
break;
}
};
if(!shouldBeFiltered) {
console.log('OUTGOING', message);
}
}
}
Networker.prototype.sendGameCommand = function(command, options) { Networker.prototype.sendGameCommand = function(command, options) {
var message = ProtocolHelper.encodeCommand(command, options); var inner = ProtocolHelper.encodeCommand(command, options);
this.sendCommand('gameCommand', message); var message = ProtocolHelper.encodeCommand("gameCommand", inner);
} if (this.unreliableReady) {
try {
this.unreliableChannel.send(message);
this.sendTimestamps.push(performance.now());
this.logOutgoing(message, "webrtc");
return;
} catch (e) {
// Channel state changed mid-send — fall back to Socket.IO.
}
}
this.socketLink.send(message);
this.sendTimestamps.push(performance.now());
this.logOutgoing(message, "socketio");
};
Networker.prototype.logOutgoing = function (message, transport) {
if (!Settings.NETWORK_LOG_OUTGOING) return;
for (var i = 0; i < Settings.NETWORK_LOG_FILTER.length; i++) {
if (message.indexOf(Settings.NETWORK_LOG_FILTER[i]) !== -1) return;
}
console.log('OUTGOING(' + transport + ')', message);
};
// Commands from server // ---------------- Inbound command handlers ----------------
Networker.prototype.onUserJoined = function (options, isMe) { Networker.prototype.onUserJoined = function (options, isMe) {
var user = new User(options.id, options); var user = new User(options.id, options);
console.log(options.nickname)
this.users[user.id] = user; this.users[user.id] = user;
if (!isMe && this.gameController && this.gameController.level && this.gameController.level.isLoaded) {
if (!isMe
&& this.gameController
&& this.gameController.level
&& this.gameController.level.isLoaded) {
this.gameController.createPlayer(user); this.gameController.createPlayer(user);
} }
} };
Networker.prototype.onUserLeft = function (userId) { Networker.prototype.onUserLeft = function (userId) {
this.gameController.onUserLeft(userId); this.gameController.onUserLeft(userId);
delete this.users[userId]; delete this.users[userId];
} };
Networker.prototype.onGameCommand = function(message) { Networker.prototype.onGameCommand = function(message) {
if (this.gameController) { if (this.gameController) {
@ -179,30 +188,22 @@ function (ProtocolHelper, GameController, User, nc, Settings, domController) {
} else { } else {
console.warn("Networker.onGameCommand: this.gameController is undefined", message); console.warn("Networker.onGameCommand: this.gameController is undefined", message);
} }
} };
Networker.prototype.onPong = function(timestamp) { Networker.prototype.onPong = function(timestamp) {
var ping = (Date.now() - parseInt(timestamp, 10)); var ping = (Date.now() - parseInt(timestamp, 10));
domController.setPing(ping); domController.setPing(ping);
this.rttBucket.push(ping);
setTimeout(this.ping.bind(this), 1000); setTimeout(this.ping.bind(this), 1000);
}; };
Networker.prototype.onBeginRound = function(options) { Networker.prototype.onBeginRound = function(options) {
if (this.gameController) this.gameController.destroy();
if(this.gameController) {
this.gameController.destroy();
}
this.gameController = new GameController(options); this.gameController = new GameController(options);
this.gameController.createMe(this.users[this.meUserId]); this.gameController.createMe(this.users[this.meUserId]);
for (var userId in this.users) { for (var userId in this.users) {
if(this.meUserId != userId) { if (this.meUserId != userId) this.gameController.createPlayer(this.users[userId]);
this.gameController.createPlayer(this.users[userId]);
}
} }
this.gameController.beginRound(); this.gameController.beginRound();
}; };
@ -210,6 +211,109 @@ function (ProtocolHelper, GameController, User, nc, Settings, domController) {
this.gameController.endRound(); this.gameController.endRound();
}; };
// ---------------- WebRTC setup ----------------
Networker.prototype.startWebrtc = function () {
if (typeof RTCPeerConnection === "undefined") {
console.warn("[webrtc] RTCPeerConnection not available");
return;
}
var self = this;
try {
var iceServers = (Settings.WEBRTC_STUN_SERVERS || ["stun:stun.l.google.com:19302"])
.map(function (url) { return { urls: url }; });
this.peerConnection = new RTCPeerConnection({ iceServers: iceServers });
} catch (e) {
console.warn("[webrtc] PeerConnection ctor failed:", e.message);
return;
}
this.unreliableChannel = this.peerConnection.createDataChannel("unreliable", {
ordered: false,
maxRetransmits: 0
});
this.unreliableChannel.onopen = function () {
console.log("[webrtc] unreliable channel open");
self.unreliableReady = true;
self.activeTransport = "webrtc";
domController.setTransport && domController.setTransport("webrtc");
};
this.unreliableChannel.onclose = function () {
console.log("[webrtc] unreliable channel closed");
self.unreliableReady = false;
self.activeTransport = "socketio";
domController.setTransport && domController.setTransport("socket.io");
};
this.unreliableChannel.onmessage = function (e) {
self.handleIncoming(e.data, "webrtc");
};
this.unreliableChannel.onerror = function (e) {
console.warn("[webrtc] channel error:", e && e.message);
};
this.peerConnection.onicecandidate = function (e) {
if (e.candidate) {
self.sendCommand("webrtcIce", {
candidate: e.candidate.candidate,
mid: e.candidate.sdpMid
});
}
};
this.peerConnection.onconnectionstatechange = function () {
console.log("[webrtc] state:", self.peerConnection.connectionState);
};
this.peerConnection.createOffer()
.then(function (offer) { return self.peerConnection.setLocalDescription(offer); })
.then(function () {
var d = self.peerConnection.localDescription;
self.sendCommand("webrtcOffer", { sdp: d.sdp, type: d.type });
})
.catch(function (err) {
console.warn("[webrtc] offer creation failed:", err.message);
});
};
Networker.prototype.onWebrtcAnswer = function (options) {
if (!this.peerConnection || !options) return;
this.peerConnection.setRemoteDescription({ type: options.type || "answer", sdp: options.sdp })
.catch(function (e) { console.warn("[webrtc] setRemoteDescription failed:", e.message); });
};
Networker.prototype.onWebrtcIce = function (options) {
if (!this.peerConnection || !options) return;
var init = { candidate: options.candidate, sdpMid: options.mid };
this.peerConnection.addIceCandidate(init)
.catch(function (e) { console.warn("[webrtc] addIceCandidate failed:", e.message); });
};
Networker.prototype.teardownWebrtc = function () {
try { if (this.unreliableChannel) this.unreliableChannel.close(); } catch (_) {}
try { if (this.peerConnection) this.peerConnection.close(); } catch (_) {}
this.unreliableChannel = null;
this.peerConnection = null;
this.unreliableReady = false;
};
// ---------------- Stats reporting ----------------
Networker.prototype.reportStats = function () {
if (!this.socketLink) return;
var now = performance.now();
this.sendTimestamps = this.sendTimestamps.filter(function (t) { return now - t < 1000; });
this.recvTimestamps = this.recvTimestamps.filter(function (t) { return now - t < 1000; });
var bucket = this.rttBucket;
this.rttBucket = [];
this.sendCommand("stats", {
transport: this.activeTransport,
rttSamples: bucket,
recvRate: this.recvTimestamps.length,
sendRate: this.sendTimestamps.length,
gaps: this.gapCount
});
};
return Networker; return Networker;
}); });

View file

@ -115,6 +115,13 @@ function (Settings, nc, Screenfull, Graph, pointerLockManager) {
li.appendChild(this.ping); li.appendChild(this.ping);
this.devToolsContainer.appendChild(li); this.devToolsContainer.appendChild(li);
// create Transport: container
li = document.createElement("li");
this.transport = document.createElement("label");
this.transport.innerHTML = "transport:?";
li.appendChild(this.transport);
this.devToolsContainer.appendChild(li);
// create debug mode // create debug mode
li = document.createElement("li"); li = document.createElement("li");
@ -165,6 +172,10 @@ function (Settings, nc, Screenfull, Graph, pointerLockManager) {
// this.pingGraph.addValue(ping); // this.pingGraph.addValue(ping);
}; };
DomController.prototype.setTransport = function(name) {
if (this.transport) this.transport.innerHTML = "transport:" + name;
};
DomController.prototype.getCanvasContainer = function () { DomController.prototype.getCanvasContainer = function () {
var container = document.getElementById(Settings.CANVAS_DOM_ID); var container = document.getElementById(Settings.CANVAS_DOM_ID);

View file

@ -83,6 +83,8 @@ function () {
NETWORK_LOG_INCOMING: false, NETWORK_LOG_INCOMING: false,
NETWORK_LOG_OUTGOING: false, NETWORK_LOG_OUTGOING: false,
NETWORK_LOG_FILTER: ["ping", "pong", "worldUpdate", "lookAt"], NETWORK_LOG_FILTER: ["ping", "pong", "worldUpdate", "lookAt"],
USE_WEBRTC: true,
WEBRTC_STUN_SERVERS: ["stun:stun.l.google.com:19302"],
// CHANNEL // CHANNEL
CHANNEL_MAX_USERS: 20, CHANNEL_MAX_USERS: 20,

38
app/Server/StatsLogger.js Normal file
View file

@ -0,0 +1,38 @@
define([
"fs",
"path"
],
function (fs, path) {
"use strict";
var runsDir = path.join(process.cwd(), "runs");
if (!fs.existsSync(runsDir)) {
fs.mkdirSync(runsDir, { recursive: true });
}
var runFile = path.join(
runsDir,
new Date().toISOString().replace(/[:.]/g, "-") + ".jsonl"
);
var location = process.env.POC_LOCATION || "unknown";
fs.writeFileSync(runFile, JSON.stringify({
type: "run_start",
t: Date.now(),
location: location,
pid: process.pid
}) + "\n");
console.log("[stats] logging to " + runFile);
return {
log: function (obj) {
obj.t = obj.t || Date.now();
obj.location = obj.location || location;
fs.appendFile(runFile, JSON.stringify(obj) + "\n", function () {});
},
getFile: function () { return runFile; },
getLocation: function () { return location; }
};
});

View file

@ -1,10 +1,12 @@
define([ define([
"Game/Core/User", "Game/Core/User",
"Lib/Utilities/Protocol/Helper", "Lib/Utilities/Protocol/Helper",
"Lib/Utilities/NotificationCenter" "Lib/Utilities/NotificationCenter",
"Server/WebRTCTransport",
"Server/StatsLogger"
], ],
function (Parent, ProtocolHelper, nc) { function (Parent, ProtocolHelper, nc, WebRTCTransport, StatsLogger) {
"use strict"; "use strict";
@ -16,39 +18,75 @@ function (Parent, ProtocolHelper, nc) {
this.channelPipe = null; this.channelPipe = null;
this.options = null; this.options = null;
var self = this;
socketLink.on('message', this.onMessage.bind(this)); socketLink.on('message', this.onMessage.bind(this));
socketLink.on('disconnect', this.onDisconnect.bind(this)); socketLink.on('disconnect', this.onDisconnect.bind(this));
nc.on(nc.ns.server.events.controlCommand.user + this.id, this.socketLink.send, this.socketLink); // Outbound messages from the rest of the server land here. We decide
// whether to send over Socket.IO (control) or the WebRTC unreliable
// channel (gameCommand, when ready).
nc.on(nc.ns.server.events.controlCommand.user + this.id, this.sendOutbound, this);
// Spin up the WebRTC peer immediately. Negotiation happens via the
// socket signaling messages once the client posts its offer.
this.webrtc = new WebRTCTransport(this.id, {
onLocalDescription: function (desc) {
self.socketLink.send(ProtocolHelper.encodeCommand("webrtcAnswer", desc));
},
onLocalCandidate: function (cand) {
self.socketLink.send(ProtocolHelper.encodeCommand("webrtcIce", cand));
},
onReady: function (negotiationMs) {
console.log("[webrtc] " + self.id + " unreliable channel open (" + negotiationMs + "ms)");
StatsLogger.log({
type: "webrtc_ready",
session: self.id,
dtMs: negotiationMs
});
},
onMessage: function (raw) {
// Inbound gameCommand from client over the unreliable channel.
// Same shape as if it came over Socket.IO: a JSON-encoded
// gameCommand envelope. Feed it to applyCommand the same way.
try {
var parsed = JSON.parse(raw);
if (parsed.gameCommand !== undefined) {
self.onGameCommand(parsed.gameCommand);
} else {
// Future: other unreliable-channel message types.
ProtocolHelper.applyCommand(raw, self);
}
} catch (e) {
console.warn("[webrtc] bad inbound message:", e.message);
}
},
onClosed: function () {
console.log("[webrtc] " + self.id + " unreliable channel closed");
}
});
StatsLogger.log({
type: "session_start",
session: this.id,
hasWebrtc: true
});
} }
User.prototype = Object.create(Parent.prototype); User.prototype = Object.create(Parent.prototype);
/* // ---------- Inbound from Socket.IO ----------
User.prototype.setChannelPipe = function(channelPipe) {
if(channelPipe) {
if (channelPipe.isWithinUserLimit()) {
this.channelPipe = channelPipe;
this.channelPipe.addUser(this);
} else {
var message = ProtocolHelper.encodeCommand("joinError", {message:"Channel is full"});
this.socketLink.send(message);
}
} else {
var message = ProtocolHelper.encodeCommand("joinError", {message:"Channel not found"});
this.socketLink.send(message);
}
};
*/
// Socket callbacks
User.prototype.onMessage = function (message) { User.prototype.onMessage = function (message) {
ProtocolHelper.applyCommand(message, this); ProtocolHelper.applyCommand(message, this);
} };
User.prototype.onDisconnect = function () { User.prototype.onDisconnect = function () {
if (this.webrtc) {
this.webrtc.destroy();
this.webrtc = null;
}
StatsLogger.log({ type: "session_end", session: this.id });
if(!this.channelPipe) { if(!this.channelPipe) {
console.warn("Disconnecting user without a channel. (Maybe channel was full)"); console.warn("Disconnecting user without a channel. (Maybe channel was full)");
@ -56,11 +94,32 @@ function (Parent, ProtocolHelper, nc) {
} }
this.channelPipe.removeUser(this); this.channelPipe.removeUser(this);
};
// ---------- Outbound routing ----------
// Decides whether a server-generated message should ride Socket.IO or the
// unreliable WebRTC channel. gameCommand traffic uses WebRTC when ready;
// everything else stays on Socket.IO.
User.prototype.sendOutbound = function (message) {
if (this.webrtc && this.webrtc.isReady() && isGameCommandMessage(message)) {
if (this.webrtc.send(message)) return;
// Send failed (channel state changed mid-send) — fall through.
}
this.socketLink.send(message);
};
function isGameCommandMessage(message) {
// The protocol wraps every message as {"<command>": <payload>}; we only
// peek at the first key without fully parsing the inner payload.
if (typeof message !== "string") return false;
// Cheap heuristic — robust because the envelope key is always first.
return message.indexOf('{"gameCommand"') === 0;
} }
// User command callbacks // ---------- Command callbacks (received from client) ----------
// Remember: control commands are coordinator relevant commands
User.prototype.onJoin = function(options) { User.prototype.onJoin = function(options) {
@ -83,14 +142,12 @@ function (Parent, ProtocolHelper, nc) {
var userOptions = { var userOptions = {
id: this.id, id: this.id,
nickname: options.nickname nickname: options.nickname
} };
this.options = userOptions; this.options = userOptions;
this.channelPipe.addUser(this); this.channelPipe.addUser(this);
}; };
/* FIXME: watch out and check in wich direction game and control commands flow */
User.prototype.onGameCommand = function(options) { User.prototype.onGameCommand = function(options) {
// repacking for transport via pipe
var message = ProtocolHelper.encodeCommand("gameCommand", options); var message = ProtocolHelper.encodeCommand("gameCommand", options);
this.channelPipe.sendToUser(this.id, message); this.channelPipe.sendToUser(this.id, message);
}; };
@ -100,6 +157,31 @@ function (Parent, ProtocolHelper, nc) {
nc.trigger(nc.ns.server.events.controlCommand.user + this.id, message); nc.trigger(nc.ns.server.events.controlCommand.user + this.id, message);
}; };
return User; // WebRTC signaling messages from the client (arriving over Socket.IO).
User.prototype.onWebrtcOffer = function (options) {
if (!this.webrtc || !options) return;
this.webrtc.handleRemoteDescription(options.sdp, options.type || "offer");
};
User.prototype.onWebrtcIce = function (options) {
if (!this.webrtc || !options) return;
this.webrtc.handleRemoteCandidate(options.candidate, options.mid);
};
// Periodic stats report from client.
User.prototype.onStats = function (options) {
if (!options) return;
StatsLogger.log({
type: "stats",
session: this.id,
transport: options.transport || "socketio",
rttSamples: options.rttSamples || [],
recvRate: options.recvRate || 0,
sendRate: options.sendRate || 0,
gaps: options.gaps || 0
});
};
return User;
}); });

View file

@ -0,0 +1,112 @@
define([
"node-datachannel"
],
function (nodeDataChannel) {
"use strict";
// Per-user WebRTC peer + unreliable data channel.
// The User class relays SDP/ICE between this transport and the client
// (over Socket.IO). This class only deals with the WebRTC peer state.
function WebRTCTransport(userId, callbacks) {
this.userId = userId;
this.callbacks = callbacks || {};
this.unreliable = null;
this.ready = false;
this.createdAt = Date.now();
var iceServers = ["stun:stun.l.google.com:19302"];
this.pc = new nodeDataChannel.PeerConnection("peer-" + userId, {
iceServers: iceServers
});
var self = this;
this.pc.onLocalDescription(function (sdp, type) {
if (self.callbacks.onLocalDescription) {
self.callbacks.onLocalDescription({ sdp: sdp, type: type });
}
});
this.pc.onLocalCandidate(function (candidate, mid) {
if (self.callbacks.onLocalCandidate) {
self.callbacks.onLocalCandidate({ candidate: candidate, mid: mid });
}
});
this.pc.onDataChannel(function (dc) {
var label = dc.getLabel();
if (label !== "unreliable") return;
self.unreliable = dc;
dc.onOpen(function () {
self.ready = true;
if (self.callbacks.onReady) {
self.callbacks.onReady(Date.now() - self.createdAt);
}
});
dc.onMessage(function (msg) {
if (self.callbacks.onMessage) self.callbacks.onMessage(msg);
});
dc.onClosed(function () {
self.ready = false;
if (self.callbacks.onClosed) self.callbacks.onClosed();
});
});
}
WebRTCTransport.prototype.handleRemoteDescription = function (sdp, type) {
try {
console.log("[webrtc] " + this.userId + " setRemoteDescription " + type);
this.pc.setRemoteDescription(sdp, type);
this.haveRemoteDesc = true;
// Flush any candidates that arrived before the offer.
if (this.pendingCandidates && this.pendingCandidates.length) {
var pending = this.pendingCandidates;
this.pendingCandidates = [];
for (var i = 0; i < pending.length; i++) {
this.handleRemoteCandidate(pending[i].candidate, pending[i].mid);
}
}
} catch (e) {
console.error("[webrtc] setRemoteDescription failed:", e && (e.message || e));
}
};
WebRTCTransport.prototype.handleRemoteCandidate = function (candidate, mid) {
// Buffer until we have the remote description — adding candidates before
// setRemoteDescription throws.
if (!this.haveRemoteDesc) {
this.pendingCandidates = this.pendingCandidates || [];
this.pendingCandidates.push({ candidate: candidate, mid: mid });
return;
}
try {
this.pc.addRemoteCandidate(candidate, mid || "0");
} catch (e) {
console.error("[webrtc] addRemoteCandidate failed:", e && (e.message || e));
}
};
WebRTCTransport.prototype.isReady = function () {
return this.ready && this.unreliable && this.unreliable.isOpen();
};
WebRTCTransport.prototype.send = function (message) {
if (!this.isReady()) return false;
try {
this.unreliable.sendMessage(message);
return true;
} catch (e) {
return false;
}
};
WebRTCTransport.prototype.destroy = function () {
try { if (this.unreliable) this.unreliable.close(); } catch (_) {}
try { this.pc.close(); } catch (_) {}
this.ready = false;
};
return WebRTCTransport;
});

747
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -17,16 +17,17 @@
}, },
"main": "server.js", "main": "server.js",
"dependencies": { "dependencies": {
"socket.io": "^4.7.4", "chart.js": "^4.4.0",
"express": "^4.18.2", "express": "^4.18.2",
"node-datachannel": "^0.10.0",
"requirejs": "^2.3.6", "requirejs": "^2.3.6",
"screenfull": "^6.0.2", "screenfull": "^6.0.2",
"chart.js": "^4.4.0" "socket.io": "^4.7.4"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.0.2" "nodemon": "^3.0.2",
"playwright": "^1.59.1"
}, },
"optionalDependencies": {},
"engines": { "engines": {
"node": ">=16.0.0" "node": ">=16.0.0"
}, },

View file

@ -0,0 +1,185 @@
// Headless-Chromium smoke test of the integrated WebRTC transport.
//
// Spawns the chuck.js server, creates a channel via the API, launches Chromium,
// loads the game, watches the DOM "transport" indicator and the runs/*.jsonl
// log to confirm the unreliable DataChannel actually opened end-to-end.
"use strict";
const { chromium } = require("playwright");
const { spawn } = require("child_process");
const http = require("http");
const fs = require("fs");
const path = require("path");
const SERVER_URL = "http://localhost:1234";
const CHANNEL = "browsersmoke";
function apiCall(body) {
return new Promise((resolve, reject) => {
const data = JSON.stringify(body);
const req = http.request({
host: "localhost", port: 1234, path: "/api", method: "POST",
headers: { "Content-Type": "application/json", "Content-Length": data.length }
}, (res) => {
let chunks = "";
res.on("data", (c) => (chunks += c));
res.on("end", () => resolve(JSON.parse(chunks)));
});
req.on("error", reject);
req.write(data); req.end();
});
}
async function waitForServerReady() {
for (let i = 0; i < 30; i++) {
try {
const r = await apiCall({ command: "getMaps" });
if (r.success) return;
} catch (_) {}
await new Promise((r) => setTimeout(r, 200));
}
throw new Error("server not ready");
}
function fileLog(pid, label, line) {
console.log("[" + label + ":" + pid + "] " + line.trim());
}
async function main() {
const srvLogPath = "/tmp/chuck-srv.log";
fs.writeFileSync(srvLogPath, "");
const srv = spawn("node", ["server.js"], {
cwd: process.cwd(),
stdio: ["ignore", fs.openSync(srvLogPath, "a"), fs.openSync(srvLogPath, "a")],
env: process.env
});
console.log("[test] server pid=" + srv.pid);
try {
await waitForServerReady();
console.log("[test] server up");
const r = await apiCall({
command: "createChannel",
options: { channelName: CHANNEL, levelUids: ["debug"], maxUsers: 4, minUsers: 1, scoreLimit: 5 }
});
if (!r.success) throw new Error("createChannel failed: " + JSON.stringify(r));
console.log("[test] channel created");
// Find the run file the server just opened.
await new Promise((r) => setTimeout(r, 500));
const runFiles = fs.readdirSync("runs").filter((f) => f.endsWith(".jsonl"))
.map((f) => ({ f, t: fs.statSync(path.join("runs", f)).mtimeMs }))
.sort((a, b) => b.t - a.t);
const runFile = path.join("runs", runFiles[0].f);
console.log("[test] run file: " + runFile);
const browser = await chromium.launch({
executablePath: "/usr/sbin/chromium",
headless: true,
args: ["--no-sandbox", "--disable-setuid-sandbox", "--use-fake-ui-for-media-stream"]
});
const ctx = await browser.newContext();
const page = await ctx.newPage();
page.on("console", (msg) => console.log("[browser]", msg.type(), msg.text()));
page.on("pageerror", (err) => console.error("[browser ERR]", err.message));
const url = SERVER_URL + "/#" + CHANNEL;
console.log("[test] navigating to " + url);
await page.goto(url, { waitUntil: "load" });
// Diagnostics: dump form visibility states and the latest /api response.
await page.waitForTimeout(2000);
const diag = await page.evaluate(() => ({
joinDisplay: getComputedStyle(document.getElementById("customjoinform")).display,
createDisplay: getComputedStyle(document.getElementById("createform")).display,
listDisplay: getComputedStyle(document.getElementById("listform")).display,
hash: window.location.hash,
customname: document.getElementById("customname").value
}));
console.log("[test] form states:", JSON.stringify(diag));
// If the hashchange didn't auto-show the form, force the showCustomJoinForm path.
if (diag.joinDisplay !== "block") {
console.log("[test] forcing showCustomJoinForm");
await page.evaluate(() => {
document.getElementById("customname").value = location.hash.substr(1);
document.getElementById("customjoinform").style.display = "block";
});
}
await page.waitForSelector("#customjoinform", { state: "visible", timeout: 8000 });
console.log("[test] customjoinform visible");
// Fill in nickname and submit the form.
await page.evaluate(() => {
document.getElementById("nick").value = "browser-bot";
const form = document.getElementById("customjoinform");
// The form's onsubmit returns false to prevent navigation. Calling
// onsubmit() directly invokes the join handler.
form.onsubmit({ preventDefault: () => {} });
});
console.log("[test] join submitted");
// Wait up to 12s for the transport indicator to switch to "webrtc",
// or for the JSONL to show a webrtc_ready event.
const deadline = Date.now() + 12000;
let webrtcReady = false;
let transportText = "";
while (Date.now() < deadline) {
await page.waitForTimeout(500);
transportText = await page.evaluate(() => {
const labels = Array.from(document.querySelectorAll("label"));
const t = labels.find((l) => /^transport:/.test(l.textContent || ""));
return t ? t.textContent : "";
}).catch(() => "");
const log = fs.readFileSync(runFile, "utf8");
if (/webrtc_ready/.test(log) || /transport:webrtc/.test(transportText)) {
webrtcReady = true;
break;
}
}
console.log("[test] transport indicator: '" + transportText + "'");
console.log("[test] webrtc_ready event seen: " + (webrtcReady ? "YES" : "NO"));
// Wait a bit more to collect stats events and verify gameCommand
// traffic is actually flowing.
await page.waitForTimeout(3000);
const events = fs.readFileSync(runFile, "utf8")
.split("\n").filter(Boolean).map((l) => { try { return JSON.parse(l); } catch (_) { return null; } })
.filter(Boolean);
const stats = events.filter((e) => e.type === "stats");
const lastStat = stats[stats.length - 1];
console.log("[test] stats events received: " + stats.length);
if (lastStat) {
console.log("[test] last stats: transport=" + lastStat.transport +
" recvRate=" + lastStat.recvRate +
" sendRate=" + lastStat.sendRate);
}
const webrtcInUse = lastStat && lastStat.transport === "webrtc";
const traffic = lastStat && lastStat.recvRate > 0;
const logTail = fs.readFileSync(runFile, "utf8").split("\n").filter(Boolean).slice(-10);
console.log("[test] last JSONL events:");
for (const l of logTail) console.log(" " + l);
await browser.close();
const pass = webrtcReady && webrtcInUse && traffic;
console.log("\nRESULT: " + (pass ? "PASS" : "FAIL") +
" (webrtc_ready=" + webrtcReady + " webrtcInUse=" + !!webrtcInUse +
" trafficFlowing=" + !!traffic + ")");
process.exitCode = pass ? 0 : 1;
} finally {
srv.kill("SIGINT");
await new Promise((r) => setTimeout(r, 500));
srv.kill("SIGKILL");
const tail = fs.readFileSync(srvLogPath, "utf8").split("\n").slice(-30).join("\n");
console.log("\n--- server log tail ---\n" + tail);
}
}
main().catch((e) => { console.error("[test] fatal:", e); process.exit(2); });