class GameBoard { static SVG_NS = "http://www.w3.org/2000/svg"; // Stroke width as a fraction of the cell size. Slightly wider than the cell // interior so the stroke bridges the 2px grid gap between adjacent cells. static BODY_WIDTH_RATIO = 0.86; // How far the body stroke runs past the point where the icon artwork starts. static SEAM_OVERLAP_PX = 1; constructor(boardEl) { this._boardEl = boardEl; this._svgCache = new Map(); this._iconLeadInset = new Map(); this._measureCanvas = null; this._boardWidth = 0; this._boardHeight = 0; this._snakeLayer = null; this._lastPaint = null; this._lastArgs = null; this._resizeObserver = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => { this._fitBoard(); this._renderSnakeLayer(); }); if (this._resizeObserver && this._boardEl.parentElement) { this._resizeObserver.observe(this._boardEl.parentElement); } } clearBoard() { this._boardEl.innerHTML = ""; this._boardEl.style.gridTemplateColumns = "none"; this._boardEl.style.gridTemplateRows = "none"; this._boardEl.style.width = ""; this._boardEl.style.height = ""; this._boardWidth = 0; this._boardHeight = 0; this._snakeLayer = null; this._lastPaint = null; this._lastArgs = null; } _fitBoard() { const container = this._boardEl.parentElement; if (!container || !this._boardWidth || !this._boardHeight) return; const availableWidth = container.clientWidth; const availableHeight = container.clientHeight; if (availableWidth <= 0 || availableHeight <= 0) return; const style = window.getComputedStyle(this._boardEl); const horizontalChrome = Number.parseFloat(style.paddingLeft) + Number.parseFloat(style.paddingRight) + Number.parseFloat(style.borderLeftWidth) + Number.parseFloat(style.borderRightWidth); const verticalChrome = Number.parseFloat(style.paddingTop) + Number.parseFloat(style.paddingBottom) + Number.parseFloat(style.borderTopWidth) + Number.parseFloat(style.borderBottomWidth); const columnGap = Number.parseFloat(style.columnGap) || 0; const rowGap = Number.parseFloat(style.rowGap) || 0; const fixedWidth = horizontalChrome + (columnGap * Math.max(0, this._boardWidth - 1)); const fixedHeight = verticalChrome + (rowGap * Math.max(0, this._boardHeight - 1)); const cellSize = Math.max(0, Math.min( (availableWidth - fixedWidth) / this._boardWidth, (availableHeight - fixedHeight) / this._boardHeight, )); this._boardEl.style.width = `${(cellSize * this._boardWidth) + fixedWidth}px`; this._boardEl.style.height = `${(cellSize * this._boardHeight) + fixedHeight}px`; } async preloadSvgs(replay) { if (!replay || !Array.isArray(replay.turns)) return; const urls = new Set(); for (const turn of replay.turns) { const snakes = turn && turn.board && Array.isArray(turn.board.snakes) ? turn.board.snakes : []; for (const snake of snakes) { const custom = snake && (snake.customizations || {}); const headUrl = SnakeUtils.buildCustomizationIconUrl("heads", custom.head); const tailUrl = SnakeUtils.buildCustomizationIconUrl("tails", custom.tail); if (headUrl) urls.add(headUrl); if (tailUrl) urls.add(tailUrl); } } await Promise.all([...urls].map((url) => this._loadSvg(url))); } async _loadSvg(url) { if (this._svgCache.has(url)) return this._svgCache.get(url); let text = null; try { const res = await fetch(url); text = res.ok ? await res.text() : null; } catch { text = null; } this._svgCache.set(url, text); await this._measureLeadInset(url, text); return text; } // How far the artwork sits back from the edge that meets the body, as a // fraction of the cell. Most icons touch it (0), but a handful of designs // start further in and would leave a visible seam if the stroke stopped at // the cell border. Measured once per icon by rasterising it at the same // aspect ratio the layer uses. A null result means the artwork never spans // the full body width, so the stroke should not be pulled back at all. async _measureLeadInset(url, svgMarkup) { if (this._iconLeadInset.has(url)) return; this._iconLeadInset.set(url, null); if (!svgMarkup) return; const width = 200; const height = Math.round(width * GameBoard.BODY_WIDTH_RATIO); try { const parsed = new DOMParser().parseFromString( this._normalizeIconSvgMarkup(svgMarkup) || svgMarkup, "image/svg+xml", ); const svgEl = parsed.querySelector("svg"); if (!svgEl) return; svgEl.setAttribute("preserveAspectRatio", "none"); svgEl.setAttribute("width", String(width)); svgEl.setAttribute("height", String(height)); const image = new Image(); image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(new XMLSerializer().serializeToString(svgEl))}`; await image.decode(); if (!this._measureCanvas) this._measureCanvas = document.createElement("canvas"); const canvas = this._measureCanvas; canvas.width = width; canvas.height = height; const context = canvas.getContext("2d", { willReadFrequently: true }); context.clearRect(0, 0, width, height); context.drawImage(image, 0, 0, width, height); const pixels = context.getImageData(0, 0, width, height).data; // The edge must be covered across its whole width. Accepting "almost // covered" leaves a notch at the seam corners that reads as a line, so // the bar is every row inked, with a low alpha cut-off so antialiased // edge pixels still count. for (let x = 0; x < width / 2; x += 1) { let covered = 0; for (let y = 0; y < height; y += 1) { if (pixels[(((y * width) + x) * 4) + 3] > 8) covered += 1; } if (covered === height) { this._iconLeadInset.set(url, x / width); return; } } } catch { // Leave the inset unknown; the stroke then runs to the cell centre. } } _parseViewBox(svgEl) { const raw = String(svgEl.getAttribute("viewBox") || "").trim(); const parts = raw.split(/\s+/).map((item) => Number(item)); if (parts.length !== 4 || parts.some((v) => Number.isNaN(v))) { return { minX: 0, minY: 0, width: 100, height: 100 }; } return { minX: parts[0], minY: parts[1], width: parts[2], height: parts[3] }; } _groupLooksOffCanvas(groupEl, viewBox) { const attrNames = new Set(["x", "y", "cx", "cy", "x1", "y1", "x2", "y2", "d", "points"]); const allElements = [groupEl, ...groupEl.querySelectorAll("*")]; let farOutsideCount = 0; let numericCount = 0; const minAllowedX = viewBox.minX - Math.max(40, viewBox.width * 0.8); const minAllowedY = viewBox.minY - Math.max(40, viewBox.height * 0.8); const maxAllowedX = viewBox.minX + viewBox.width + Math.max(40, viewBox.width * 0.8); const maxAllowedY = viewBox.minY + viewBox.height + Math.max(40, viewBox.height * 0.8); for (const node of allElements) { for (const attr of node.getAttributeNames()) { if (!attrNames.has(attr)) continue; const value = node.getAttribute(attr); if (!value) continue; const matches = value.match(/-?\d*\.?\d+/g); if (!matches) continue; for (let idx = 0; idx < matches.length; idx += 1) { const num = Number(matches[idx]); if (Number.isNaN(num)) continue; numericCount += 1; const isXCoord = idx % 2 === 0; if (isXCoord) { if (num < minAllowedX || num > maxAllowedX) farOutsideCount += 1; } else { if (num < minAllowedY || num > maxAllowedY) farOutsideCount += 1; } } } } if (numericCount < 10) return false; return farOutsideCount / numericCount > 0.55; } _maxNestedGroupDepth(groupEl) { let maxDepth = 1; const stack = [{ node: groupEl, depth: 1 }]; while (stack.length > 0) { const entry = stack.pop(); if (!entry) continue; maxDepth = Math.max(maxDepth, entry.depth); for (const child of Array.from(entry.node.children)) { if (!child.tagName || child.tagName.toLowerCase() !== "g") continue; stack.push({ node: child, depth: entry.depth + 1 }); } } return maxDepth; } _normalizeIconSvgMarkup(svgMarkup) { if (!svgMarkup) return null; try { const parser = new DOMParser(); const parsed = parser.parseFromString(svgMarkup, "image/svg+xml"); const svgEl = parsed.querySelector("svg"); if (!svgEl) return svgMarkup; const topLevelGroups = Array.from(svgEl.children).filter( (el) => el.tagName && el.tagName.toLowerCase() === "g" ); if (topLevelGroups.length > 1) { const viewBox = this._parseViewBox(svgEl); const firstGroup = topLevelGroups[0]; if (this._groupLooksOffCanvas(firstGroup, viewBox) || this._maxNestedGroupDepth(firstGroup) >= 3) { firstGroup.remove(); } } return new XMLSerializer().serializeToString(svgEl); } catch { return svgMarkup; } } _createIconLayer(iconUrl, color, transformValue, type) { const layer = document.createElement("div"); layer.className = type === "head" ? "icon-layer icon-layer--head" : "icon-layer icon-layer--tail"; layer.style.setProperty("--icon-transform", transformValue || "rotate(0deg)"); // The icon artwork joins the body along its full leading edge, so the layer // is squeezed to the body stroke width and stretched (no aspect ratio) to // meet the stroke end flush. layer.style.setProperty("--icon-cross-inset", `${(1 - GameBoard.BODY_WIDTH_RATIO) * 50}%`); // Both head and tail artwork is inlined rather than used as a CSS mask: an // SVG referenced as an image keeps its own preserveAspectRatio, so it would // letterbox inside the non-square layer and leave a gap towards the body. const svgMarkup = this._svgCache.get(iconUrl); if (svgMarkup) { layer.innerHTML = this._normalizeIconSvgMarkup(svgMarkup); const svgEl = layer.querySelector("svg"); if (svgEl) { svgEl.setAttribute("preserveAspectRatio", "none"); svgEl.style.width = "100%"; svgEl.style.height = "100%"; svgEl.style.fill = color || "currentColor"; svgEl.removeAttribute("width"); svgEl.removeAttribute("height"); } return layer; } // Artwork not cached yet (live turn arriving before the preload finished): // fall back to the mask so the icon still shows, and repaint once loaded. layer.classList.add("icon-layer--masked"); layer.style.setProperty("--icon-url", `url(${iconUrl})`); layer.style.setProperty("--icon-color", color || "var(--you)"); this._loadSvg(iconUrl).then((markup) => { if (markup) this._repaintLast(); }); return layer; } _repaintLast() { const args = this._lastArgs; if (!args) return; this.paintBoard(args.turnData, args.width, args.height, args.selectedSnakeId, args.replay); } _cellKey(x, y) { return `${x}:${y}`; } // Collapses the stacked duplicate segments Battlesnake emits at spawn and // right after eating, so the body becomes a clean orthogonal polyline. _bodyPolyline(snake) { const body = Array.isArray(snake && snake.body) ? snake.body : []; const points = []; for (const part of body) { if (!part) continue; const point = { x: Number(part.x), y: Number(part.y) }; if (Number.isNaN(point.x) || Number.isNaN(point.y)) continue; const previous = points[points.length - 1]; if (previous && previous.x === point.x && previous.y === point.y) continue; points.push(point); } return points; } // Pixel geometry of the grid, derived from the same box metrics the CSS grid // uses, so SVG coordinates land exactly on cell centres. _gridMetrics() { if (!this._boardWidth || !this._boardHeight) return null; const style = window.getComputedStyle(this._boardEl); const paddingLeft = Number.parseFloat(style.paddingLeft) || 0; const paddingTop = Number.parseFloat(style.paddingTop) || 0; const paddingRight = Number.parseFloat(style.paddingRight) || 0; const paddingBottom = Number.parseFloat(style.paddingBottom) || 0; const columnGap = Number.parseFloat(style.columnGap) || 0; const rowGap = Number.parseFloat(style.rowGap) || 0; const boxWidth = this._boardEl.clientWidth; const boxHeight = this._boardEl.clientHeight; const cellWidth = (boxWidth - paddingLeft - paddingRight - (columnGap * (this._boardWidth - 1))) / this._boardWidth; const cellHeight = (boxHeight - paddingTop - paddingBottom - (rowGap * (this._boardHeight - 1))) / this._boardHeight; if (!(cellWidth > 0) || !(cellHeight > 0)) return null; return { paddingLeft, paddingTop, columnGap, rowGap, cellWidth, cellHeight, boxWidth, boxHeight }; } _cellCenter(point, metrics) { const row = this._boardHeight - 1 - point.y; return { x: metrics.paddingLeft + (point.x * (metrics.cellWidth + metrics.columnGap)) + (metrics.cellWidth / 2), y: metrics.paddingTop + (row * (metrics.cellHeight + metrics.rowGap)) + (metrics.cellHeight / 2), }; } // Pulls the polyline end back so the customization icon owns its cell. // Combined with a butt cap the stroke stops exactly where the artwork starts: // at the cell border for the usual icon, deeper into the cell for artwork // that sits back from that border (leadInset). _retractEnd(endCenter, neighbourCenter, metrics, leadInset) { const dx = endCenter.x - neighbourCenter.x; const dy = endCenter.y - neighbourCenter.y; const distance = Math.hypot(dx, dy); if (!(distance > 0)) return endCenter; const halfCell = Math.abs(dx) >= Math.abs(dy) ? metrics.cellWidth / 2 : metrics.cellHeight / 2; // A full CSS pixel of overlap: both sides are the same colour, so overlap // is free, and it keeps sub-pixel rounding from opening a hairline seam on // displays with a fractional device pixel ratio. const artworkOffset = (leadInset || 0) * halfCell * 2; const pullBack = Math.min(distance, Math.max(0, halfCell - artworkOffset - GameBoard.SEAM_OVERLAP_PX)); return { x: endCenter.x - ((dx / distance) * pullBack), y: endCenter.y - ((dy / distance) * pullBack), }; } _appendRoundEnd(group, center, radius, color) { const dot = document.createElementNS(GameBoard.SVG_NS, "circle"); dot.setAttribute("cx", `${center.x}`); dot.setAttribute("cy", `${center.y}`); dot.setAttribute("r", `${radius}`); dot.setAttribute("fill", color); group.appendChild(dot); } _renderSnakeLayer() { if (!this._snakeLayer || !this._lastPaint) return; const metrics = this._gridMetrics(); while (this._snakeLayer.firstChild) this._snakeLayer.removeChild(this._snakeLayer.firstChild); if (!metrics) return; this._snakeLayer.setAttribute("viewBox", `0 0 ${metrics.boxWidth} ${metrics.boxHeight}`); this._snakeLayer.setAttribute("width", `${metrics.boxWidth}`); this._snakeLayer.setAttribute("height", `${metrics.boxHeight}`); const cellSize = Math.min(metrics.cellWidth, metrics.cellHeight); const strokeWidth = cellSize * GameBoard.BODY_WIDTH_RATIO; const { snakes, selectedSnakeId } = this._lastPaint; for (const entry of snakes) { const points = entry.points; if (points.length === 0) continue; const centers = points.map((point) => this._cellCenter(point, metrics)); const dimmed = Boolean(selectedSnakeId) && entry.snakeId !== selectedSnakeId; // One group per snake so dimming applies once instead of stacking up on // overlapping shapes. const group = document.createElementNS(GameBoard.SVG_NS, "g"); if (dimmed) group.setAttribute("opacity", "0.2"); this._snakeLayer.appendChild(group); if (centers.length === 1) { // Fully stacked body (spawn turn): a single round blob, unless the head // icon already fills that cell. if (!entry.headIcon) { this._appendRoundEnd(group, centers[0], strokeWidth / 2, entry.color); } continue; } const last = centers.length - 1; // An icon only takes over its cell if its artwork spans the full body // width somewhere; otherwise the stroke runs to the cell centre and keeps // its rounded end, with the icon drawn on top. Ends without an icon keep // the rounded look via an explicit cap circle, because linecap applies to // both ends of the path at once. if (entry.headIcon && entry.headLeadInset !== null) { centers[0] = this._retractEnd(centers[0], centers[1], metrics, entry.headLeadInset); } else { this._appendRoundEnd(group, centers[0], strokeWidth / 2, entry.color); } if (entry.tailIcon && entry.tailLeadInset !== null) { centers[last] = this._retractEnd(centers[last], centers[last - 1], metrics, entry.tailLeadInset); } else { this._appendRoundEnd(group, centers[last], strokeWidth / 2, entry.color); } const path = document.createElementNS(GameBoard.SVG_NS, "path"); path.setAttribute("d", centers.map((point, idx) => `${idx === 0 ? "M" : "L"}${point.x} ${point.y}`).join(" ")); path.setAttribute("fill", "none"); path.setAttribute("stroke", entry.color); path.setAttribute("stroke-width", `${strokeWidth}`); path.setAttribute("stroke-linecap", "butt"); path.setAttribute("stroke-linejoin", "round"); group.appendChild(path); } } paintBoard(turnData, width, height, selectedSnakeId, replay) { this.clearBoard(); if (!turnData || !width || !height) return; this._lastArgs = { turnData, width, height, selectedSnakeId, replay }; const colorById = SnakeUtils.buildSnakeColorById(turnData, replay); const customById = SnakeUtils.buildSnakeCustomizationById(turnData, replay); this._boardWidth = Number(width); this._boardHeight = Number(height); this._boardEl.style.gridTemplateColumns = `repeat(${width}, minmax(0, 1fr))`; this._boardEl.style.gridTemplateRows = `repeat(${height}, minmax(0, 1fr))`; this._fitBoard(); const foods = new Set((turnData.food || []).map((p) => this._cellKey(p.x, p.y))); const hazards = new Set((turnData.hazards || []).map((p) => this._cellKey(p.x, p.y))); const occupiedCells = new Set(); const snakeHead = new Set(); const snakeTail = new Map(); const headVariantByCell = new Map(); const tailVariantByCell = new Map(); const headIconByCell = new Map(); const tailIconByCell = new Map(); const headTransformByCell = new Map(); const tailTransformByCell = new Map(); const snakeColorByCell = new Map(); const snakeIdByCell = new Map(); const snakeEntries = []; (turnData.snakes || []).forEach((snake, idx) => { if (!snake) return; const snakeId = snake.snake_id || snake.id || `${Utils.safeString(snake.snake_name)}-${idx}`; const bodyColor = SnakeUtils.resolveSnakeColor(snakeId, snake.is_you, colorById); const custom = customById.get(snakeId) || {}; const headVariant = SnakeUtils.stableVariantFromString(custom.head); const tailVariant = SnakeUtils.stableVariantFromString(custom.tail); const headIcon = SnakeUtils.buildCustomizationIconUrl("heads", custom.head); const tailIcon = SnakeUtils.buildCustomizationIconUrl("tails", custom.tail); const headTransform = SnakeUtils.directionToHeadTransform(SnakeUtils.inferHeadDirection(snake)); const tailTransform = SnakeUtils.directionToTailTransform(SnakeUtils.inferTailDirection(snake)); const points = this._bodyPolyline(snake); for (const part of (snake.body || [])) { occupiedCells.add(this._cellKey(part.x, part.y)); snakeIdByCell.set(this._cellKey(part.x, part.y), snakeId); } // `head` is authoritative when present, but some payloads omit it; the // polyline start is the same cell. const headPoint = snake.head || points[0] || null; const headKey = headPoint ? this._cellKey(headPoint.x, headPoint.y) : null; if (headKey !== null) { snakeHead.add(headKey); headVariantByCell.set(headKey, headVariant); headTransformByCell.set(headKey, headTransform); snakeColorByCell.set(headKey, bodyColor); if (headIcon) headIconByCell.set(headKey, headIcon); } // A tail stacked under this snake's own head has no free cell to draw in. // Another snake's head landing there must not suppress it, so the check is // snake-local rather than against every head seen so far. let drawTailIcon = false; if (points.length > 0) { const tail = points[points.length - 1]; const tailKey = this._cellKey(tail.x, tail.y); drawTailIcon = Boolean(tailIcon) && tailKey !== headKey; snakeTail.set(tailKey, snake.is_you ? "snake-tail-you" : "snake-tail-enemy"); tailVariantByCell.set(tailKey, tailVariant); tailTransformByCell.set(tailKey, tailTransform); snakeColorByCell.set(tailKey, bodyColor); if (drawTailIcon) tailIconByCell.set(tailKey, tailIcon); } const leadInset = (url) => (this._iconLeadInset.has(url) ? this._iconLeadInset.get(url) : 0); snakeEntries.push({ snakeId, color: bodyColor, points, headIcon: Boolean(headIcon), tailIcon: drawTailIcon, headLeadInset: headIcon ? leadInset(headIcon) : 0, tailLeadInset: drawTailIcon ? leadInset(tailIcon) : 0, }); }); for (let y = height - 1; y >= 0; y--) { for (let x = 0; x < width; x++) { const key = this._cellKey(x, y); const cell = document.createElement("div"); cell.className = "cell"; const occupied = occupiedCells.has(key); if (hazards.has(key)) { cell.classList.add("hazard"); // Keep the hazard hatch readable on top of the snake stroke. if (occupied) cell.classList.add("hazard-over-snake"); } if (foods.has(key) && !occupied) cell.classList.add("food"); if (occupied && selectedSnakeId && snakeIdByCell.get(key) !== selectedSnakeId) { cell.style.opacity = "0.2"; } if (snakeTail.has(key)) { cell.classList.add(snakeTail.get(key)); cell.classList.add(`tail-style-${tailVariantByCell.get(key) || 1}`); const tailIcon = tailIconByCell.get(key); if (tailIcon) { cell.classList.add("has-tail-icon", "icon-tail"); cell.appendChild(this._createIconLayer( tailIcon, snakeColorByCell.get(key) || "var(--you)", tailTransformByCell.get(key) || "scaleX(-1)", "tail", )); } } if (snakeHead.has(key)) { cell.classList.add("snake-head"); cell.classList.add(`head-style-${headVariantByCell.get(key) || 1}`); const headIcon = headIconByCell.get(key); if (headIcon) { cell.classList.add("has-head-icon", "icon-head"); cell.appendChild(this._createIconLayer( headIcon, snakeColorByCell.get(key) || "var(--you)", headTransformByCell.get(key) || "rotate(0deg)", "head", )); } } this._boardEl.appendChild(cell); } } this._snakeLayer = document.createElementNS(GameBoard.SVG_NS, "svg"); this._snakeLayer.setAttribute("class", "snake-layer"); this._boardEl.appendChild(this._snakeLayer); this._lastPaint = { snakes: snakeEntries, selectedSnakeId: selectedSnakeId || null }; this._renderSnakeLayer(); } }