Files
claudePySnake/web/webrtc_transport.js
Vladyslav Doloman b221645750 Implement UDP protocol with binary compression and 32-player support
Major networking overhaul to reduce latency and bandwidth:

UDP Protocol Implementation:
- Created UDP server handler with sequence number tracking (uint32 with wrapping support)
- Implemented 1000-packet window for reordering tolerance
- Packet structure: [seq_num(4) + msg_type(1) + update_id(2) + payload]
- Handles 4+ billion packets without sequence number issues
- Auto-fallback to TCP on >20% packet loss

Binary Codec with Schema Versioning:
- Extensible field-based format with version negotiation
- Position encoding: 11-bit packed (6-bit x + 5-bit y for 40x30 grid)
- Delta encoding for snake bodies: 2 bits per segment direction
- Variable-length integers for compact numbers
- String encoding: up to 16 chars with 4-bit length prefix
- Player ID hashing: CRC32 for compact representation
- zlib compression for payload reduction

Partial Update System:
- Splits large game states into independent packets <1280 bytes (IPv6 MTU)
- Each packet is self-contained (packet loss affects only subset of snakes)
- Smart snake segmenting for very long snakes (>100 segments)
- Player name caching: sent once per player, then omitted
- Metadata (food, game_running) separated from snake data

32-Player Support:
- Extended COLOR_SNAKES array to 32 distinct colors
- Server enforces MAX_PLAYERS=32 limit
- Player names limited to MAX_PLAYER_NAME_LENGTH=16
- Name validation and sanitization
- Color assignment with rotation through 32 colors

Desktop Client Components:
- UDP client with automatic TCP fallback
- Partial state reassembly and tracking
- Sequence validation and duplicate detection
- Statistics tracking for fallback decisions

Web Client Components:
- 32-color palette matching Python colors
- JavaScript binary codec (mirrors Python implementation)
- Partial state tracker for reassembly
- WebRTC DataChannel transport skeleton (for future use)
- Graceful fallback to WebSocket

Server Integration:
- UDP server on port 8890 (configurable via --udp-port)
- Integrated with existing TCP (8888) and WebSocket (8889) servers
- Proper cleanup on shutdown
- Command-line argument: --udp-port (0 to disable, default 8890)

Performance Improvements:
- ~75% bandwidth reduction (binary + compression vs JSON)
- All packets guaranteed <1280 bytes (safe for all networks)
- UDP eliminates TCP head-of-line blocking for lower latency
- Independent partial updates gracefully handle packet loss
- Delta encoding dramatically reduces snake body size

Comprehensive Testing:
- 46 tests total, all passing (100% success rate)
- 15 UDP protocol tests (sequence wrapping, packet parsing, compression)
- 20 binary codec tests (encoding, delta compression, strings, varint)
- 11 partial update tests (splitting, reassembly, packet loss resilience)

Files Added:
- src/shared/binary_codec.py: Extensible binary serialization
- src/shared/udp_protocol.py: UDP packet handling with sequence numbers
- src/server/udp_handler.py: Async UDP server
- src/server/partial_update.py: State splitting logic
- src/client/udp_client.py: Desktop UDP client with TCP fallback
- src/client/partial_state_tracker.py: Client-side reassembly
- web/binary_codec.js: JavaScript binary codec
- web/partial_state_tracker.js: JavaScript reassembly
- web/webrtc_transport.js: WebRTC transport (ready for future use)
- tests/test_udp_protocol.py: UDP protocol tests
- tests/test_binary_codec.py: Binary codec tests
- tests/test_partial_updates.py: Partial update tests

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-04 23:50:31 +03:00

339 lines
9.3 KiB
JavaScript

/**
* 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;
}
}