/** * WebRTC DataChannel transport for low-latency game updates */ class WebRTCTransport { constructor(signalingWs, onStateUpdate, playerId) { this.signalingWs = signalingWs; // WebSocket for signaling this.onStateUpdate = onStateUpdate; this.playerId = playerId; this.peerConnection = null; this.dataChannel = null; this.connected = false; this.fallbackToWebSocket = false; this.sequenceTracker = new SequenceTracker(); this.partialTracker = new PartialStateTracker(); // Statistics this.packetsReceived = 0; this.packetsLost = 0; this.lastUpdateId = -1; } /** * Check if browser supports WebRTC */ static isSupported() { return typeof RTCPeerConnection !== 'undefined'; } /** * Initialize WebRTC connection */ async init() { if (!WebRTCTransport.isSupported()) { console.log('WebRTC not supported, using WebSocket'); this.fallbackToWebSocket = true; return false; } try { // Create peer connection this.peerConnection = new RTCPeerConnection({ iceServers: [ { urls: 'stun:stun.l.google.com:19302' } ] }); // Create data channel with UDP-like behavior this.dataChannel = this.peerConnection.createDataChannel('game-updates', { ordered: false, // Unordered delivery maxRetransmits: 0 // No retransmissions (UDP-like) }); this.setupDataChannel(); // Handle ICE candidates this.peerConnection.onicecandidate = (event) => { if (event.candidate) { // Send ICE candidate to server via WebSocket this.signalingWs.send(JSON.stringify({ type: 'webrtc_ice', player_id: this.playerId, candidate: event.candidate })); } }; // Create and send offer const offer = await this.peerConnection.createOffer(); await this.peerConnection.setLocalDescription(offer); // Send offer to server via WebSocket this.signalingWs.send(JSON.stringify({ type: 'webrtc_offer', player_id: this.playerId, sdp: offer.sdp })); return true; } catch (error) { console.error('WebRTC initialization failed:', error); this.fallbackToWebSocket = true; return false; } } /** * Setup data channel event handlers */ setupDataChannel() { this.dataChannel.binaryType = 'arraybuffer'; this.dataChannel.onopen = () => { console.log('WebRTC DataChannel opened'); this.connected = true; }; this.dataChannel.onclose = () => { console.log('WebRTC DataChannel closed'); this.connected = false; }; this.dataChannel.onerror = (error) => { console.error('WebRTC DataChannel error:', error); this.fallbackToWebSocket = true; }; this.dataChannel.onmessage = (event) => { this.handleMessage(new Uint8Array(event.data)); }; } /** * Handle WebRTC signaling messages from server */ async handleSignaling(message) { try { if (message.type === 'webrtc_answer') { // Received SDP answer from server await this.peerConnection.setRemoteDescription({ type: 'answer', sdp: message.sdp }); } else if (message.type === 'webrtc_ice') { // Received ICE candidate from server if (message.candidate) { await this.peerConnection.addIceCandidate(message.candidate); } } } catch (error) { console.error('Error handling WebRTC signaling:', error); } } /** * Handle incoming binary message from DataChannel */ async handleMessage(data) { // Parse UDP-style packet const result = await UDPProtocol.parsePacket(data); if (!result) { return; } const { seqNum, msgType, updateId, payload } = result; // Check sequence if (!this.sequenceTracker.shouldAccept(seqNum)) { // Old or duplicate packet return; } // Update statistics this.packetsReceived++; // Check for lost packets if (this.lastUpdateId !== -1) { const expectedId = UDPProtocol.nextUpdateId(this.lastUpdateId); if (updateId !== expectedId && updateId !== this.lastUpdateId) { // Detect loss (accounting for wrapping) const gap = (updateId - expectedId) & 0xFFFF; if (gap < 100) { this.packetsLost += gap; } } } this.lastUpdateId = updateId; // Check fallback condition if (this.packetsReceived > 100) { const lossRate = this.packetsLost / (this.packetsReceived + this.packetsLost); if (lossRate > 0.2) { console.log(`High packet loss (${(lossRate * 100).toFixed(1)}%), suggesting WebSocket fallback`); this.fallbackToWebSocket = true; } } // Process packet if (msgType === BinaryMessageType.PARTIAL_STATE_UPDATE || msgType === BinaryMessageType.GAME_META_UPDATE) { // Process partial update const ready = this.partialTracker.processPacket(updateId, payload); if (ready) { // Get assembled state const gameState = this.partialTracker.getGameState(); this.onStateUpdate(gameState); } } } /** * Check if should fallback to WebSocket */ shouldFallback() { return this.fallbackToWebSocket; } /** * Check if connected */ isConnected() { return this.connected; } /** * Close WebRTC connection */ close() { if (this.dataChannel) { this.dataChannel.close(); } if (this.peerConnection) { this.peerConnection.close(); } this.connected = false; } } /** * Sequence tracker for WebRTC (mirrors Python/UDP version) */ class SequenceTracker { constructor() { this.lastSeq = 0; this.receivedSeqs = new Set(); } shouldAccept(seqNum) { // Check if newer if (!UDPProtocol.isSeqNewer(seqNum, this.lastSeq)) { return false; } // Check for duplicate if (this.receivedSeqs.has(seqNum)) { return false; } // Accept packet this.lastSeq = seqNum; this.receivedSeqs.add(seqNum); // Clean up old sequences if (this.receivedSeqs.size > 1000) { const minSeq = (this.lastSeq - 1000) & 0xFFFFFFFF; this.receivedSeqs = new Set( Array.from(this.receivedSeqs).filter(s => UDPProtocol.isSeqNewer(s, minSeq) ) ); } return true; } reset() { this.lastSeq = 0; this.receivedSeqs.clear(); } } /** * UDP Protocol utilities (JavaScript version) */ class UDPProtocol { static SEQUENCE_WINDOW = 1000; static MAX_SEQUENCE = 0xFFFFFFFF; static MAX_UPDATE_ID = 0xFFFF; /** * Check if new_seq is newer than last_seq (with wrapping) */ static isSeqNewer(newSeq, lastSeq, window = UDPProtocol.SEQUENCE_WINDOW) { const diff = (newSeq - lastSeq) & 0xFFFFFFFF; if (diff === 0) { return false; // Duplicate } // Treat as signed: if diff > 2^31, it wrapped backwards if (diff > 0x7FFFFFFF) { return false; // Old packet } if (diff > window) { return false; // Too far ahead } return true; } /** * Parse UDP-style packet * Returns {seqNum, msgType, updateId, payload} or null */ static async parsePacket(packet) { if (packet.length < 7) { return null; } const dv = new DataView(packet.buffer, packet.byteOffset, packet.byteLength); const seqNum = dv.getUint32(0, false); // Big endian let msgType = dv.getUint8(4); const updateId = dv.getUint16(5, false); // Big endian let payload = packet.slice(7); // Check compression flag const compressed = (msgType & 0x80) !== 0; msgType &= 0x7F; // Clear compression flag // Decompress if needed if (compressed && payload.length > 0) { try { payload = await BinaryCodec.decompress(payload); } catch (e) { console.error('Decompression failed:', e); return null; } } return { seqNum, msgType, updateId, payload }; } /** * Get next update ID with wrapping */ static nextUpdateId(updateId) { return (updateId + 1) & 0xFFFF; } }