(() => { const GRID_SIZE = 16; const TICK_MS = 120; // game speed const canvas = document.getElementById('board'); const ctx = canvas.getContext('2d', { alpha: false }); const scoreEl = document.getElementById('score'); const wsDot = document.getElementById('ws-dot'); const wsLabel = document.getElementById('ws-label'); // NeoPixel matrix transmission config // Most WS2812(B) LEDs expect GRB color order. Many matrices are wired serpentine. const MATRIX_WIDTH = canvas.width; const MATRIX_HEIGHT = canvas.height; const COLOR_ORDER = 'RGB'; // one of: RGB, GRB, BRG, BGR, RBG, GBR const BRIGHTNESS = 1.0; // 0.0 .. 1.0 scalar const SERPENTINE = true; // true if every other row is reversed const FLIP_X = true; // set true if physical matrix is mirrored horizontally const FLIP_Y = false; // set true if physical matrix is flipped vertically /** Game state */ let snakeSegments = []; let currentDirection = { x: 1, y: 0 }; let pendingDirection = { x: 1, y: 0 }; let appleCell = null; let score = 0; let isGameOver = false; let sendingFrame = false; let ws; function connectWebSocket() { const protocol = location.protocol === 'https:' ? 'wss' : 'ws'; const url = `${protocol}://${location.host}/ws`; ws = new WebSocket(url); ws.binaryType = 'arraybuffer'; setWsStatus('connecting'); ws.addEventListener('open', () => setWsStatus('open')); ws.addEventListener('close', () => setWsStatus('closed')); ws.addEventListener('error', () => setWsStatus('error')); } function clampToByte(value) { if (value < 0) return 0; if (value > 255) return 255; return value | 0; } function applyBrightness(value) { // value is 0..255, BRIGHTNESS is 0..1 return clampToByte(Math.round(value * BRIGHTNESS)); } function mapXYToLinearIndex(x, y, width, height) { // Apply optional flips to align with physical orientation const mappedX = FLIP_X ? (width - 1 - x) : x; const mappedY = FLIP_Y ? (height - 1 - y) : y; // Serpentine wiring reverses every other row (commonly odd rows) const isOddRow = (mappedY % 2) === 1; const columnInRow = (SERPENTINE && isOddRow) ? (width - 1 - mappedX) : mappedX; return (mappedY * width) + columnInRow; } function writePixelWithColorOrder(target, baseIndex, r, g, b) { // target is a Uint8Array, baseIndex is pixelIndex * 3 switch (COLOR_ORDER) { case 'RGB': target[baseIndex + 0] = r; target[baseIndex + 1] = g; target[baseIndex + 2] = b; break; case 'GRB': target[baseIndex + 0] = g; target[baseIndex + 1] = r; target[baseIndex + 2] = b; break; case 'BRG': target[baseIndex + 0] = b; target[baseIndex + 1] = r; target[baseIndex + 2] = g; break; case 'BGR': target[baseIndex + 0] = b; target[baseIndex + 1] = g; target[baseIndex + 2] = r; break; case 'RBG': target[baseIndex + 0] = r; target[baseIndex + 1] = b; target[baseIndex + 2] = g; break; case 'GBR': target[baseIndex + 0] = g; target[baseIndex + 1] = b; target[baseIndex + 2] = r; break; default: // Fallback to GRB if misconfigured target[baseIndex + 0] = g; target[baseIndex + 1] = r; target[baseIndex + 2] = b; break; } } function encodeCanvasToNeoPixelFrame() { // Produces headerless frame: width*height pixels, 3 bytes per pixel in COLOR_ORDER const width = MATRIX_WIDTH; const height = MATRIX_HEIGHT; const out = new Uint8Array(width * height * 3); const src = ctx.getImageData(0, 0, width, height).data; // RGBA for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const srcIndex = ((y * width) + x) * 4; const r = applyBrightness(src[srcIndex + 0]); const g = applyBrightness(src[srcIndex + 1]); const b = applyBrightness(src[srcIndex + 2]); const pixelIndex = mapXYToLinearIndex(x, y, width, height); const base = pixelIndex * 3; writePixelWithColorOrder(out, base, r, g, b); } } return out; } function setWsStatus(state) { if (state === 'open') { wsDot.classList.add('ok'); wsLabel.textContent = 'WS: connected'; } else if (state === 'connecting') { wsDot.classList.remove('ok'); wsLabel.textContent = 'WS: connecting…'; } else if (state === 'closed') { wsDot.classList.remove('ok'); wsLabel.textContent = 'WS: disconnected'; // try to reconnect after a delay setTimeout(connectWebSocket, 1000); } else { wsDot.classList.remove('ok'); wsLabel.textContent = 'WS: error'; } } function resetGame() { snakeSegments = [ { x: 4, y: 8 }, { x: 3, y: 8 }, { x: 2, y: 8 } ]; currentDirection = { x: 1, y: 0 }; pendingDirection = { x: 1, y: 0 }; score = 0; scoreEl.textContent = String(score); isGameOver = false; placeApple(); } function placeApple() { const occupied = new Set(snakeSegments.map(c => `${c.x},${c.y}`)); let x, y; do { x = Math.floor(Math.random() * GRID_SIZE); y = Math.floor(Math.random() * GRID_SIZE); } while (occupied.has(`${x},${y}`)); appleCell = { x, y }; } function stepGame() { if (isGameOver) return; // Commit pending direction (prevents double-turning in one tick) if ((pendingDirection.x !== -currentDirection.x) || (pendingDirection.y !== -currentDirection.y)) { currentDirection = pendingDirection; } const head = snakeSegments[0]; const newHead = { x: head.x + currentDirection.x, y: head.y + currentDirection.y }; // Wall collision if (newHead.x < 0 || newHead.x >= GRID_SIZE || newHead.y < 0 || newHead.y >= GRID_SIZE) { isGameOver = true; return; } // Self collision for (let i = 0; i < snakeSegments.length; i++) { const seg = snakeSegments[i]; if (seg.x === newHead.x && seg.y === newHead.y) { isGameOver = true; return; } } // Move snake snakeSegments.unshift(newHead); const ateApple = (newHead.x === appleCell.x && newHead.y === appleCell.y); if (ateApple) { score += 1; scoreEl.textContent = String(score); placeApple(); } else { snakeSegments.pop(); } } function renderGame() { // Background ctx.fillStyle = '#000000'; ctx.fillRect(0, 0, canvas.width, canvas.height); // Apple ctx.fillStyle = '#d23'; ctx.fillRect(appleCell.x, appleCell.y, 1, 1); // Snake ctx.fillStyle = '#3bd16f'; for (const cell of snakeSegments) { ctx.fillRect(cell.x, cell.y, 1, 1); } if (isGameOver) { // Simple overlay pixel art for game over (draw a cross) ctx.fillStyle = '#f33'; for (let i = 0; i < GRID_SIZE; i++) { ctx.fillRect(i, i, 1, 1); ctx.fillRect(GRID_SIZE - 1 - i, i, 1, 1); } } } function sendFrame() { if (!ws || ws.readyState !== WebSocket.OPEN) return; if (sendingFrame) return; sendingFrame = true; try { const frame = encodeCanvasToNeoPixelFrame(); // Send ArrayBuffer view; receivers expect raw 3-byte-per-pixel stream ws.send(frame.buffer); } catch (_) { // ignore } finally { sendingFrame = false; } } function gameLoop() { stepGame(); renderGame(); sendFrame(); } // Controls window.addEventListener('keydown', (e) => { if (e.key === ' ') { resetGame(); return; } if (e.key === 'ArrowUp' && currentDirection.y !== 1) { pendingDirection = { x: 0, y: -1 }; } else if (e.key === 'ArrowDown' && currentDirection.y !== -1) { pendingDirection = { x: 0, y: 1 }; } else if (e.key === 'ArrowLeft' && currentDirection.x !== 1) { pendingDirection = { x: -1, y: 0 }; } else if (e.key === 'ArrowRight' && currentDirection.x !== -1) { pendingDirection = { x: 1, y: 0 }; } }); // Start resetGame(); connectWebSocket(); setInterval(gameLoop, TICK_MS); })();