Qwen3.8 Flash
by QwenReasoningClean
Running live in a sandboxed frame. Click inside it before using the keyboard.
How it went
- Total time
- 10 min 7 s
- Cost
- $0.018
- Output tokens
- 38,539
- Spent thinking
- 29,095 tokens
- Speed
- 64.2 tok/s
- Console errors
- 0
- First token after
- 7.2 s
- First visible text after
- 8 min 37 s
- Prompt tokens
- 556
- Code size
- 26.3 KB
- Finish reason
- stop
- Served by
- Alibaba
- Model id
qwen/qwen3.8-flash- Reasoning sent
model default- Max output
- 64,000 tokens
- Generated
- Nov 11, 2026
What it was asked
The prompt
Build a polished Snake game in a single HTML file using vanilla JavaScript and a <canvas>. Requirements: - A grid of at least 20×20 cells, centered on the page. - Arrow keys and WASD control the snake. Quick successive key presses must both be honored, but the snake can never reverse onto itself. - Eating food adds 10 points and grows the snake by one cell; new food never spawns on the snake. - Game over when the snake hits a wall or itself, with an overlay showing the score; Space or Enter restarts, P pauses. - Visual polish: smooth movement, a snake with a distinct head and eyes facing its direction, and a small particle burst when eating. - Display the current score and the best score of the session.
System prompt, sent with every prompt
You are generating a self-contained web demo. It will be rendered automatically in a sandboxed iframe and compared side by side with the output of other AI models given the exact same task. Output rules: - Reply with exactly ONE fenced code block containing the complete file: ```html (or ```svg when the task asks for an SVG). No text before or after the code block. - Everything must be inline: CSS in <style>, JavaScript in <script>. No external files, images, fonts or network requests, except the CDN URLs explicitly allowed by the task. - Do not use localStorage, sessionStorage, cookies, IndexedDB, alert(), confirm(), prompt() or window.open(): they are blocked in the sandbox and will throw. - The page must look right in a 1280×800 viewport and adapt gracefully down to 375px wide. No horizontal scrollbar. - Something meaningful must be visible on first paint: a screenshot is taken automatically about 3 seconds after load, without any interaction. - Everything that makes the demo interesting must be visible within the first seconds: nothing hidden behind a high score, a later level or a long play session. - Games must start in a self-playing demo mode (a simple AI plays) behind a "Click or press a key to start" message, so the game can be judged in action without playing. - The iframe only receives keyboard events after the user clicks it: if the demo uses the keyboard, display a clear "Click or press a key to start" message. - The code must run with zero errors in the console of the latest Chrome.
Code run-1.html · 995 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Neon Snake</title>
<style>
:root {
--bg: #071020;
--panel: rgba(255, 255, 255, 0.07);
--text: #e2e8f0;
--muted: #94a3b8;
--green: #22c55e;
--pink: #f43f5e;
--amber: #fbbf24;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
overflow-x: hidden;
background: radial-gradient(1200px 800px at 50% 0%, #101a34 0%, var(--bg) 45%, #030712 100%);
}
body {
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
color: var(--text);
min-height: 100vh;
min-height: 100svh;
display: flex;
align-items: center;
justify-content: center;
padding: 12px;
}
#app {
width: min(92vw, 820px);
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
#hud {
width: min(92vw, 640px, calc(100vh - 170px));
width: min(92vw, 640px, calc(100svh - 170px));
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.pill {
background: rgba(15, 23, 42, 0.55);
border: 1px solid rgba(148, 163, 184, 0.16);
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.22), inset 0 1px 0 rgba(255,255,255,0.05);
border-radius: 999px;
padding: 7px 13px;
font-size: clamp(12px, 2.2vw, 15px);
letter-spacing: 0.04em;
white-space: nowrap;
}
.pill strong {
font-variant-numeric: tabular-nums;
color: #fff;
}
#scorePill strong { color: #86efac; }
#bestPill strong { color: #fde68a; }
.title {
font-weight: 800;
letter-spacing: 0.22em;
font-size: clamp(15px, 3.8vw, 26px);
background: linear-gradient(90deg, #86efac, #fbbf24, #f472b6);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
text-shadow: 0 0 28px rgba(34, 197, 94, 0.18);
}
#stage {
position: relative;
width: min(92vw, 640px, calc(100vh - 170px));
width: min(92vw, 640px, calc(100svh - 170px));
aspect-ratio: 1 / 1;
border-radius: 22px;
overflow: hidden;
border: 1px solid rgba(148, 163, 184, 0.2);
background: #08101f;
box-shadow:
0 28px 80px rgba(0, 0, 0, 0.48),
0 0 0 1px rgba(255, 255, 255, 0.03) inset,
0 0 80px rgba(34, 197, 94, 0.07) inset;
cursor: pointer;
}
canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: 10px;
padding: 24px;
background: rgba(4, 9, 20, 0.6);
backdrop-filter: blur(4px);
opacity: 0;
pointer-events: none;
transition: opacity 0.22s ease;
border-radius: inherit;
}
.overlay.show { opacity: 1; }
.overlay-title {
font-size: clamp(22px, 5.5vw, 38px);
font-weight: 850;
letter-spacing: 0.06em;
text-shadow: 0 8px 30px rgba(0, 0, 0, 0.45);
}
.overlay-sub {
color: #cbd5e1;
font-size: clamp(13px, 3vw, 17px);
max-width: 90%;
}
.blink {
animation: blink 1.25s ease-in-out infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.55; transform: scale(0.99); }
}
.big-score {
font-size: clamp(20px, 5vw, 28px);
color: #fff;
font-weight: 800;
}
.kbd-row {
display: flex;
gap: 6px;
flex-wrap: wrap;
justify-content: center;
align-items: center;
color: #a5b4fc;
font-size: 12px;
}
.kbd {
display: inline-grid;
place-items: center;
min-width: 24px;
height: 22px;
padding: 0 6px;
border-radius: 6px;
background: #111827;
border: 1px solid rgba(148, 163, 184, 0.25);
box-shadow: 0 2px 0 rgba(0,0,0,0.25);
color: #e5e7eb;
font-weight: 700;
font-size: 11px;
}
footer {
color: var(--muted);
font-size: clamp(11px, 2.4vw, 14px);
text-align: center;
line-height: 1.35;
width: min(92vw, 640px);
}
footer b { color: #e2e8f0; font-weight: 650; }
@media (max-width: 520px) {
#hud { gap: 5px; }
.pill { padding: 6px 9px; }
.title { letter-spacing: 0.14em; }
}
</style>
</head>
<body>
<div id="app">
<header id="hud">
<div class="pill" id="scorePill">Score <strong id="score">0</strong></div>
<div class="title">SNAKE</div>
<div class="pill" id="bestPill">Best <strong id="best">0</strong></div>
</header>
<main id="stage">
<canvas id="game"></canvas>
<div class="overlay show" id="startOverlay">
<div class="overlay-title blink">Click or press a key to start</div>
<div class="overlay-sub">Neon Snake is running a self-play demo behind this screen.</div>
<div class="kbd-row">
<span class="kbd">←</span><span class="kbd">↑</span><span class="kbd">↓</span><span class="kbd">→</span>
<span>or</span>
<span class="kbd">W</span><span class="kbd">A</span><span class="kbd">S</span><span class="kbd">D</span>
</div>
</div>
<div class="overlay" id="pauseOverlay">
<div class="overlay-title">Paused</div>
<div class="overlay-sub">Press <span class="kbd">P</span> to resume</div>
</div>
<div class="overlay" id="gameOverOverlay">
<div class="overlay-title" id="gameOverTitle">Game Over</div>
<div class="big-score" id="finalScore">Score: 0</div>
<div class="overlay-sub" id="finalBest">Best: 0</div>
<div class="overlay-sub">Press <span class="kbd">Space</span> or <span class="kbd">Enter</span> to restart</div>
</div>
</main>
<footer>
<b>Arrows / WASD</b> steer · <b>P</b> pause · <b>Space / Enter</b> restart after game over · wall or self bite ends the run.
</footer>
</div>
<script>
const GRID = 24;
const TAU = Math.PI * 2;
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const board = document.createElement('canvas');
const stage = document.getElementById('stage');
const scoreEl = document.getElementById('score');
const bestEl = document.getElementById('best');
const startOverlay = document.getElementById('startOverlay');
const pauseOverlay = document.getElementById('pauseOverlay');
const gameOverOverlay = document.getElementById('gameOverOverlay');
const gameOverTitle = document.getElementById('gameOverTitle');
const finalScore = document.getElementById('finalScore');
const finalBest = document.getElementById('finalBest');
let cssSize = 500;
let cell = 24;
let dpr = 1;
let mode = 'demo'; // 'demo' or 'player'
let phase = 'demo'; // 'demo', 'playing', 'paused', 'over'
let snake = [];
let fromSnake = [];
let dir = { x: 1, y: 0 };
let dirQueue = [];
let food = { x: 0, y: 0 };
let score = 0;
let best = 0;
let particles = [];
let timeSinceStep = 0;
let stepDuration = 125;
let last = performance.now();
let overAt = 0;
let demoRestartAt = 0;
const ALL_DIRS = [
{ x: 1, y: 0 },
{ x: -1, y: 0 },
{ x: 0, y: 1 },
{ x: 0, y: -1 }
];
const DIR_MAP = {
ArrowUp: { x: 0, y: -1 },
KeyW: { x: 0, y: -1 },
ArrowDown: { x: 0, y: 1 },
KeyS: { x: 0, y: 1 },
ArrowLeft: { x: -1, y: 0 },
KeyA: { x: -1, y: 0 },
ArrowRight: { x: 1, y: 0 },
KeyD: { x: 1, y: 0 }
};
const lerp = (a, b, t) => a + (b - a) * t;
const copySnake = arr => arr.map(p => ({ x: p.x, y: p.y }));
const cellKey = (x, y) => y * GRID + x;
function fit() {
const rect = canvas.getBoundingClientRect();
cssSize = Math.max(120, Math.floor(Math.min(rect.width || 500, rect.height || 500)));
dpr = Math.min(window.devicePixelRatio || 1, 2.5);
canvas.width = Math.round(cssSize * dpr);
canvas.height = Math.round(cssSize * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
cell = cssSize / GRID;
makeBoard();
}
function makeBoard() {
board.width = canvas.width;
board.height = canvas.height;
const bx = board.getContext('2d');
bx.setTransform(dpr, 0, 0, dpr, 0, 0);
bx.clearRect(0, 0, cssSize, cssSize);
const bg = bx.createLinearGradient(0, 0, cssSize, cssSize);
bg.addColorStop(0, '#0c1428');
bg.addColorStop(0.45, '#0a1120');
bg.addColorStop(1, '#050912');
bx.fillStyle = bg;
bx.fillRect(0, 0, cssSize, cssSize);
for (let y = 0; y < GRID; y++) {
for (let x = 0; x < GRID; x++) {
bx.fillStyle = ((x + y) & 1) ? 'rgba(255,255,255,0.016)' : 'rgba(0,0,0,0.075)';
bx.fillRect(x * cell, y * cell, cell, cell);
}
}
bx.strokeStyle = 'rgba(148, 163, 184, 0.055)';
bx.lineWidth = 1;
for (let i = 0; i <= GRID; i++) {
bx.beginPath();
bx.moveTo(i * cell, 0);
bx.lineTo(i * cell, cssSize);
bx.stroke();
bx.beginPath();
bx.moveTo(0, i * cell);
bx.lineTo(cssSize, i * cell);
bx.stroke();
}
const center = cssSize / 2;
const vignette = bx.createRadialGradient(center, center, cssSize * 0.2, center, center, cssSize * 0.75);
vignette.addColorStop(0, 'rgba(0,0,0,0)');
vignette.addColorStop(1, 'rgba(0,0,0,0.38)');
bx.fillStyle = vignette;
bx.fillRect(0, 0, cssSize, cssSize);
}
function spawnFood() {
const occupied = new Set();
snake.forEach(s => occupied.add(cellKey(s.x, s.y)));
const empty = [];
for (let y = 0; y < GRID; y++) {
for (let x = 0; x < GRID; x++) {
if (!occupied.has(cellKey(x, y))) empty.push({ x, y });
}
}
if (empty.length === 0) {
food = { x: -1, y: -1 };
endGame(true);
return;
}
const p = empty[Math.floor(Math.random() * empty.length)];
food = { x: p.x, y: p.y };
}
function newGame(isDemo) {
mode = isDemo ? 'demo' : 'player';
phase = isDemo ? 'demo' : 'playing';
const cy = Math.floor(GRID / 2);
snake = [
{ x: cy + 1, y: cy },
{ x: cy, y: cy },
{ x: cy - 1, y: cy }
];
fromSnake = copySnake(snake);
dir = { x: 1, y: 0 };
dirQueue = [];
score = 0;
particles = [];
timeSinceStep = 0;
stepDuration = 125;
spawnFood();
updateHUD();
updateOverlays();
}
function startPlayer() {
newGame(false);
}
function togglePause() {
if (mode !== 'player') return;
if (phase === 'playing') {
phase = 'paused';
} else if (phase === 'paused') {
phase = 'playing';
last = performance.now();
}
updateOverlays();
}
function updateHUD() {
scoreEl.textContent = score;
bestEl.textContent = best;
}
function updateOverlays() {
startOverlay.classList.toggle('show', mode === 'demo');
pauseOverlay.classList.toggle('show', phase === 'paused');
gameOverOverlay.classList.toggle('show', mode === 'player' && phase === 'over');
if (mode === 'player' && phase === 'over') {
finalScore.textContent = 'Score: ' + score;
finalBest.textContent = 'Best: ' + best;
}
}
function willCollide(nx, ny, eats) {
if (nx < 0 || ny < 0 || nx >= GRID || ny >= GRID) return true;
const tailIndex = snake.length - 1;
for (let i = 0; i < snake.length; i++) {
if (!eats && i === tailIndex) continue;
if (snake[i].x === nx && snake[i].y === ny) return true;
}
return false;
}
function doStep() {
const head = snake[0];
if (mode === 'demo') {
const aiDir = chooseAIDir();
if (aiDir) dir = aiDir;
} else if (dirQueue.length > 0) {
dir = dirQueue.shift();
}
const nx = head.x + dir.x;
const ny = head.y + dir.y;
const eats = food.x >= 0 && nx === food.x && ny === food.y;
if (willCollide(nx, ny, eats)) {
endGame(false);
return;
}
const old = copySnake(snake);
const next = [{ x: nx, y: ny }];
for (let i = 0; i < old.length; i++) next.push(old[i]);
if (!eats) next.pop();
if (eats) {
fromSnake = old.concat([{ x: old[old.length - 1].x, y: old[old.length - 1].y }]);
} else {
fromSnake = old;
}
snake = next;
if (eats) {
score += 10;
if (mode === 'player' && score > best) best = score;
spawnParticles(food.x, food.y);
spawnFood();
if (mode === 'player') {
stepDuration = Math.max(76, 128 - Math.min(38, Math.floor(score / 10) * 1.15));
}
updateHUD();
}
}
function endGame(isWin = false) {
if (phase === 'over') return;
phase = 'over';
overAt = performance.now();
timeSinceStep = 0;
if (mode === 'player' && score > best) {
best = score;
updateHUD();
}
gameOverTitle.textContent = isWin ? 'You Win!' : 'Game Over';
if (mode === 'demo') {
demoRestartAt = performance.now() + 900;
}
updateOverlays();
}
function simulateNextHead(nx, ny, eats) {
const next = [{ x: nx, y: ny }];
const keep = eats ? snake.length : snake.length - 1;
for (let i = 0; i < keep; i++) {
next.push({ x: snake[i].x, y: snake[i].y });
}
return next;
}
function evaluateState(body, start) {
const startKey = cellKey(start.x, start.y);
const blocked = new Uint8Array(GRID * GRID);
body.forEach(c => blocked[cellKey(c.x, c.y)] = 1);
blocked[startKey] = 0;
const distArr = new Int32Array(GRID * GRID).fill(-1);
distArr[startKey] = 0;
const queue = [startKey];
let qi = 0;
let reachable = 1;
let distToFood = 99999;
while (qi < queue.length) {
const k = queue[qi++];
const x = k % GRID;
const y = Math.floor(k / GRID);
const d = distArr[k];
if (food.x >= 0 && x === food.x && y === food.y && d < distToFood) {
distToFood = d;
}
const neighbors = [
[x + 1, y],
[x - 1, y],
[x, y + 1],
[x, y - 1]
];
for (const [nx, ny] of neighbors) {
if (nx < 0 || ny < 0 || nx >= GRID || ny >= GRID) continue;
const nk = cellKey(nx, ny);
if (blocked[nk] || distArr[nk] >= 0) continue;
distArr[nk] = d + 1;
reachable++;
queue.push(nk);
}
}
return { reachable, dist: distToFood };
}
function chooseAIDir() {
const head = snake[0];
const options = [];
for (const d of ALL_DIRS) {
if (d.x === -dir.x && d.y === -dir.y) continue;
const nx = head.x + d.x;
const ny = head.y + d.y;
const eats = food.x >= 0 && nx === food.x && ny === food.y;
if (willCollide(nx, ny, eats)) continue;
const nextBody = simulateNextHead(nx, ny, eats);
const info = evaluateState(nextBody, { x: nx, y: ny });
let scoreValue = info.reachable * 12;
if (info.dist < 1000) {
scoreValue += 5000 - info.dist * 18;
}
if (eats) {
scoreValue += 900;
}
// Penalize moves that squeeze the snake into tiny spaces.
if (info.reachable < nextBody.length + 3) scoreValue -= 8000;
if (info.reachable < 14) scoreValue -= 5000;
// Gentle bias toward staying near board center.
scoreValue += 60 - Math.hypot(nx - (GRID / 2 - 0.5), ny - (GRID / 2 - 0.5));
// Tiny randomization makes demo behavior less robotic.
scoreValue += Math.random() * 6;
options.push({ d, s: scoreValue });
}
if (!options.length) return null;
options.sort((a, b) => b.s - a.s);
return options[0].d;
}
function pushDir(d) {
if (!d) return;
const last = dirQueue.length > 0 ? dirQueue[dirQueue.length - 1] : dir;
if (last.x === d.x && last.y === d.y) return;
if (last.x === -d.x && last.y === -d.y) return;
if (dirQueue.length >= 3) return;
dirQueue.push(d);
}
function spawnParticles(gx, gy) {
const count = 16 + Math.floor(Math.random() * 10);
const colors = ['#ff8787', '#ffd166', '#7ce6af', '#ff6b6b', '#ffffff'];
for (let i = 0; i < count; i++) {
const angle = Math.random() * TAU;
const speed = 0.8 + Math.random() * 3.1;
particles.push({
x: gx + 0.5,
y: gy + 0.5,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
life: 0,
max: 0.35 + Math.random() * 0.55,
size: 0.07 + Math.random() * 0.15,
color: colors[Math.floor(Math.random() * colors.length)]
});
}
}
function updateParticles(dt) {
const decay = Math.pow(0.92, dt * 60);
particles = particles.filter(p => {
p.life += dt;
p.x += p.vx * dt;
p.y += p.vy * dt;
p.vx *= decay;
p.vy *= decay;
p.vy += 0.7 * dt; // tiny downward drift
return p.life < p.max;
});
}
function circle(x, y, r) {
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
}
function render(now) {
ctx.clearRect(0, 0, cssSize, cssSize);
// Board background.
ctx.drawImage(board, 0, 0, cssSize, cssSize);
// Food.
if (food.x >= 0) drawFood(now);
// Snake.
drawSnake(now);
// Particles.
drawParticles();
// Death flash.
if (phase === 'over') {
const age = Math.max(0, now - overAt);
const alpha = Math.max(0, 1 - age / 520) * 0.28;
if (alpha > 0) {
ctx.fillStyle = `rgba(244, 63, 94, ${alpha})`;
ctx.fillRect(0, 0, cssSize, cssSize);
}
}
}
function drawFood(now) {
const px = (food.x + 0.5) * cell;
const py = (food.y + 0.5) * cell;
const pulse = 1 + 0.075 * Math.sin(now / 230) + 0.025 * Math.sin(now / 91);
const r = cell * 0.36 * pulse;
const glow = ctx.createRadialGradient(px, py, r * 0.15, px, py, r * 2.15);
glow.addColorStop(0, 'rgba(244, 63, 94, 0.42)');
glow.addColorStop(0.45, 'rgba(251, 113, 133, 0.16)');
glow.addColorStop(1, 'rgba(244, 63, 94, 0)');
ctx.fillStyle = glow;
circle(px, py, r * 2.15);
ctx.fill();
const body = ctx.createRadialGradient(px - r * 0.28, py - r * 0.34, r * 0.06, px, py, r);
body.addColorStop(0, '#fff1f2');
body.addColorStop(0.25, '#fecdd3');
body.addColorStop(0.7, '#fb7185');
body.addColorStop(1, '#9f1239');
ctx.fillStyle = body;
circle(px, py, r);
ctx.fill();
// Highlight.
ctx.fillStyle = 'rgba(255,255,255,0.58)';
circle(px - r * 0.28, py - r * 0.32, r * 0.18);
ctx.fill();
// Leaf.
ctx.fillStyle = '#22c55e';
ctx.beginPath();
ctx.ellipse(px + r * 0.13, py - r * 0.78, r * 0.28, r * 0.16, -0.65, 0, TAU);
ctx.fill();
}
function drawSnake(now) {
const t = Math.max(0, Math.min(1, timeSinceStep / stepDuration));
const pos = [];
for (let i = 0; i < snake.length; i++) {
const s = snake[i];
const f = fromSnake[i] || fromSnake[fromSnake.length - 1] || s;
pos.push({
x: lerp(f.x, s.x, t) + 0.5,
y: lerp(f.y, s.y, t) + 0.5
});
}
if (pos.length === 0) return;
const first = pos[0];
const lastP = pos[pos.length - 1];
ctx.save();
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
ctx.moveTo(first.x * cell, first.y * cell);
for (let i = 1; i < pos.length; i++) {
ctx.lineTo(pos[i].x * cell, pos[i].y * cell);
}
// Outer dark edge.
ctx.strokeStyle = 'rgba(2, 24, 15, 0.86)';
ctx.lineWidth = cell * 0.88;
ctx.stroke();
// Main body gradient.
const grad = ctx.createLinearGradient(first.x * cell, first.y * cell, lastP.x * cell, lastP.y * cell);
grad.addColorStop(0, '#a7f3d0');
grad.addColorStop(0.28, '#4ade80');
grad.addColorStop(0.72, '#16a34a');
grad.addColorStop(1, '#047857');
ctx.strokeStyle = grad;
ctx.lineWidth = cell * 0.72;
ctx.stroke();
// Soft sheen.
ctx.strokeStyle = 'rgba(255, 255, 255, 0.13)';
ctx.lineWidth = cell * 0.2;
ctx.stroke();
ctx.restore();
// Head.
const hx = first.x * cell;
const hy = first.y * cell;
let eyeDir = { x: dir.x, y: dir.y };
if (pos.length > 1) {
const dx = pos[0].x - pos[1].x;
const dy = pos[0].y - pos[1].y;
const len = Math.hypot(dx, dy);
if (len > 0.0001) eyeDir = { x: dx / len, y: dy / len };
}
ctx.save();
ctx.shadowColor = 'rgba(34, 197, 94, 0.52)';
ctx.shadowBlur = cell * 0.55;
const headGrad = ctx.createRadialGradient(hx - cell * 0.14, hy - cell * 0.14, cell * 0.06, hx, hy, cell * 0.48);
headGrad.addColorStop(0, '#dcfce7');
headGrad.addColorStop(0.35, '#86efac');
headGrad.addColorStop(1, '#15803d');
ctx.fillStyle = headGrad;
circle(hx, hy, cell * 0.47);
ctx.fill();
ctx.restore();
// Eyes.
const perp = { x: -eyeDir.x, y: eyeDir.y };
const eyeForward = cell * 0.13;
const eyeSide = cell * 0.17;
const eyeR = cell * 0.125;
const pupilForward = cell * 0.065;
const pupilSide = cell * 0.11;
const pupilR = cell * 0.062;
for (const sign of [1, -1]) {
const ex = hx + eyeDir.x * eyeForward + perp.x * eyeSide * sign;
const ey = hy + eyeDir.y * eyeForward + perp.y * eyeSide * sign;
ctx.fillStyle = '#f8fafc';
circle(ex, ey, eyeR);
ctx.fill();
const px = ex + eyeDir.x * pupilForward + perp.x * pupilSide * sign * 0.16;
const py = ey + eyeDir.y * pupilForward + perp.y * pupilSide * sign * 0.16;
ctx.fillStyle = '#052e16';
circle(px, py, pupilR);
ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.88)';
circle(px - pupilR * 0.28, py - pupilR * 0.28, pupilR * 0.25);
ctx.fill();
}
// Tiny nostrils.
ctx.fillStyle = 'rgba(5, 46, 22, 0.38)';
const nForward = cell * 0.32;
const nSide = cell * 0.09;
for (const sign of [1, -1]) {
ctx.beginPath();
ctx.arc(
hx + eyeDir.x * nForward + perp.x * nSide * sign,
hy + eyeDir.y * nForward + perp.y * nSide * sign,
cell * 0.025,
0,
TAU
);
ctx.fill();
}
}
function drawParticles() {
if (!particles.length) return;
ctx.save();
ctx.globalCompositeOperation = 'lighter';
for (const p of particles) {
const a = 1 - p.life / p.max;
ctx.globalAlpha = Math.max(0, a * a);
ctx.fillStyle = p.color;
ctx.beginPath();
ctx.arc(p.x * cell, p.y * cell, Math.max(0.2, p.size * cell * (0.4 + a * 0.9)), 0, TAU);
ctx.fill();
}
ctx.restore();
ctx.globalAlpha = 1;
}
function loop(now) {
requestAnimationFrame(loop);
const dtMs = Math.min(100, now - last);
last = now;
const dt = dtMs / 1000;
if (phase === 'playing' || phase === 'demo') {
timeSinceStep += dtMs;
while (timeSinceStep >= stepDuration) {
timeSinceStep -= stepDuration;
doStep();
if (!(phase === 'playing' || phase === 'demo')) break;
}
} else if (mode === 'demo' && phase === 'over') {
if (now >= demoRestartAt) {
newGame(true);
}
}
updateParticles(dt);
render(now);
}
function onKeyDown(e) {
const code = e.code;
const isControl = code === 'ArrowUp' || code === 'ArrowDown' || code === 'ArrowLeft' || code === 'ArrowRight';
const isOther = code === 'Space' || code === 'Enter' || code === 'KeyW' || code === 'KeyA' || code === 'KeyS' || code === 'KeyD' || code === 'KeyP';
if (isControl || isOther) e.preventDefault();
if (e.repeat) return;
const d = DIR_MAP[code];
if (mode === 'demo') {
startPlayer();
if (d) pushDir(d);
return;
}
if (code === 'KeyP') {
togglePause();
return;
}
if (phase === 'playing' && d) {
pushDir(d);
return;
}
if ((code === 'Space' || code === 'Enter') && (phase === 'over' || phase === 'paused')) {
startPlayer();
}
}
function onClick() {
if (mode === 'demo') {
startPlayer();
} else if (phase === 'paused') {
togglePause();
} else if (phase === 'over') {
startPlayer();
}
}
// Touch/swipe controls are optional but make mobile testing easier.
let touchStart = null;
stage.addEventListener('touchstart', e => {
const t = e.touches[0];
if (t) touchStart = { x: t.clientX, y: t.clientY };
}, { passive: true });
stage.addEventListener('touchend', e => {
if (!touchStart) return;
const t = e.changedTouches[0];
if (!t) return;
const dx = t.clientX - touchStart.x;
const dy = t.clientY - touchStart.y;
touchStart = null;
if (Math.hypot(dx, dy) < 28) return;
let d = null;
if (Math.abs(dx) > Math.abs(dy)) {
d = dx > 0 ? { x: 1, y: 0 } : { x: -1, y: 0 };
} else {
d = dy > 0 ? { x: 0, y: 1 } : { x: 0, y: -1 };
}
if (mode === 'demo') {
startPlayer();
pushDir(d);
} else if (phase === 'playing') {
pushDir(d);
} else if (phase === 'over' || phase === 'paused') {
startPlayer();
pushDir(d);
}
}, { passive: true });
window.addEventListener('keydown', onKeyDown);
window.addEventListener('click', onClick);
if ('ResizeObserver' in window) {
new ResizeObserver(fit).observe(stage);
}
window.addEventListener('resize', fit);
// Initialize.
fit();
newGame(true);
last = performance.now();
requestAnimationFrame(loop);
</script>
</body>
</html>Reasoning trace (29,095 tokens)
We need build single HTML file snake game. Need ensure all requirements. Need think through implementation details.
Need output exactly one fenced html block. Need no external. Self-contained. Need polished.
Grid at least 20x20 centered. Smooth movement: can interpolate snake segment positions based on step time. Need direction queue to honor quick presses and no reversal. Eating: +10, grow by one. New food never on snake. Game over wall/self overlay score. Space/Enter restart, P pause. Particle burst when eating. Score best session.
We need design robust. Canvas size responsive. 1280x800 viewport, adapt to 375. Use grid 25x25 maybe. Centered on page with HUD. Need no horizontal scrollbar. Could have container with header, canvas, footer hints. Use CSS flex.
Game logic:
- GRID = 24 or 25. Cell size calculated from available width/height maybe capped.
- State snake array of cells, head first. Direction current [1,0] or queue.
- Use movement tick interval. Smooth rendering: maintain previous positions and target positions? Option: snake stored logical cells. On step, update logical cells. For rendering, interpolate from previous to current using progress = time since last step / stepDuration. Need handle growth. Simpler: each segment has from and to coordinates, and after step set segment positions to interpolated. Need maintain segment count.
Implementation smooth:
- snake: array of {x,y} integer current logical positions (head first). prev: array of previous positions? But segments move to next cell; length constant except growth. At step time, each segment i takes position of previous snake[i]? Actually if no grow, segment i moves to position segment i-1 (old). Head moves to new. So old positions for each current segment are known from before update. We can store previous positions array aligned to new snake array? Easier: keep cells array current. Also prevCells array old. On step:
old = clone cells.
compute newHead.
if growing: cells = [newHead, ...old] (tail remains old tail? Actually growth by one: new length = old+1, tail old tail stays same; cells[0]=newHead, cells[1..]=old. Then prevCells = old? For alignment: new cells length = old length+1. Need interpolation for each segment in new cells: segment j's from position to position. For j=0 head from old[0] to newHead. For j>=1, new[j] = old[j-1]; from = old[j] if exists else old[old.length-1]? Hmm for added segment at tail? In classic grow, tail does not move that step: new length old+1, cells = [head, old0, old1, ..., oldN-1]. Segment old length+1 at last has new position = old tail, no movement; from = old tail? Actually last segment new index N from position? If we consider from should be previous position of that visual segment. There are new segments: head (was head), body segment N+1 (was tail and stays). Need from for last = old tail, to = old tail. For j from 1 to old.length: to = old[j-1]; from = old[j] if j < old.length else old[j] (tail) to same. That's one way.
Alternatively maintain visual positions array that lags.
- Could render snake as rounded segments/line path with interpolated points. If using smooth path, we can store render positions each frame? Need stable.
Maybe easier: Use fixed-step game update with animation interpolation using head and previous snake cells. Draw snake as polyline connecting interpolated cell centers. For body segments, interpolation from old segment positions to target positions (the positions of previous head). Need handle length.
Another approach: store for each segment its current position and target position. At each step, compute targets: for head new; body target = previous segment current position? Then advance positions toward targets gradually each frame. But to ensure no overlap and smooth, can set progress timer and interpolate between old and new arrays.
Let's define:
logicalSnake = array of cells [head,...tail]. visualSnake = array of coordinates {x,y} same length as logicalSnake, representing current rendered positions (floating). At step:
oldVisual? Could set logicalSnake to new, then create targets for visual positions.
Simplest render interpolation with progress:
- logicalSnake current integer positions after latest step.
- prevSnake integer positions before latest step? Need same length for interpolation. If growth, lengths differ. But we can use previous positions array for current snake: for each i, prevPos[i]. Let's implement step by updating a `segments` array each element has `x,y` current logical, `px,py` previous logical for drawing interpolation.
At step:
if grow pending maybe insert new segment? Need preserve previous positions.
For classic grow, we add new head then remove tail unless grow. Let's design with `snake` array of {x,y,ox,oy} where x,y is new logical position, ox,oy previous logical position. On step, before moving, all segment.ox = segment.x? Actually need set each segment's old pos = current pos. Then move:
if growPending: we need create new head segment and shift tail? We can represent snake as ordered segments. To add one growth, we need append a segment at tail that stays at tail, and shift old segments to next positions.
Alternative: Use positions update:
const grow = growPending;
for i from snake.length-1 down to 1:
snake[i].ox = snake[i].x; snake[i].y = snake[i-1].x? That might lose previous for interpolation? Let's think.
Maybe better: Keep an array of cells; at step, compute newCells, compute prevCells aligned, render interpolation using progress between prevCells and newCells.
Before update: cells (len N). old = cells.map(c=>({x:c.x,y:c.y})). Compute new head. newCells = [head, ...old]. If !grow, pop tail -> newCells length N. If grow, length N+1.
For interpolation, need arrays same length. We can make prevForNew: for newCells length L:
if grow: prevForNew = [old[0], ...old] (length N+1). Because new[0] head from old[0]; new[i]=old[i-1] from old[i] (tail from old tail? Wait length N+1; for i=1..N: new[i]=old[i-1], prev should old[i]? for i=N: new[N]=old[N-1], prev should old[N-1]? Let's check: snake [head, b1, ..., tail], old length N. grow: new = [head', head, b1, ..., tail]. Segment 1 (old head) moves to new position head from old position head? It was old head, now becomes b1 at old head location? Actually if body is chain: previous head is old head, it does not move; new segment inserted behind head? Visual segments correspond to identity: head moves; second segment moves to previous head position; ..., tail stays same and new tail inserted? If new length N+1: new = [newHead, oldHead, oldB1,..., oldTail]. For segment identity: old head becomes segment 1 at oldHead (no move), but visually it was at oldHead before and remains? Wait the body segment that used to be segment 1 (behind head) now moves to oldHead. If segment identities shift? For drawing polyline, we only need positions sequence. If we interpolate sequence positions between old sequence and new sequence with same count, but length differs. Need handle.
If we render path of cells at each interpolation step from prev sequence to new sequence. We can resample? Simpler for growth: at moment of eating, don't interpolate last segment; can have prevForNew = old, and if new longer, duplicate old tail as prev for new tail so tail stays. For each i<old.length, prev=old[i], current? new[i] for i>=1 = old[i-1], not old[i]. If using prev=old[i], then interpolation from old body to position of segment ahead: segment i moves forward to previous segment's old position. That's correct if segment identity stays at index i? But head new, segment i moves from old[i] to old[i-1]. For i=0 head moves from old[0] to newHead. For tail i=N-1 old tail moves to old[N-2] unless growth: if growth, new length N+1; for i=0 head moves; i=1 moves from old[1]? But new[1]=old[0]. For i=N old? There's no old[N], but new[N]=old[N-1] should stay? If we duplicate old tail as prev for new tail: prev[N]=old[N-1], target new[N]=old[N-1], no move. Then other segments i=1..N-1: prev[i]=old[i]? target new[i]=old[i-1], so old segment i moves backward? This is okay if identities fixed by index, but the head becomes second segment? It would interpolate from old head to old head (no move) for i=1? prev[1]=old[1], target old[0], moves forward. Good. tail duplicate remains. For non-grow length N: prev[i]=old[i], target new[i]=old[i-1] for i>=1; tail new[N-1]=old[N-2], moves forward. Works. So we can render interpolation by having `fromCells` array aligned with current cells. For growth, fromCells = old cells length N + duplicate old tail -> length N+1. For non-grow, fromCells = old. Then progress t from 0 to 1 between steps, position i = lerp(fromCells[i], cells[i], t). But careful after step, fromCells should be set to old? Let's formalize:
cells = current integer snake after last step.
fromCells = positions before last step for corresponding indices.
step progress = elapsed / stepDuration.
At draw, render positions = lerp(fromCells[i], cells[i], progress).
On next step: old = cells.map(copy); newCells compute with possible grow. If grow: fromCells = old.concat([tail])? length old+1. If non-grow: fromCells = old. cells = newCells.
This creates smooth body movement. At progress 1, positions = cells integer. At t=0, positions = fromCells. Good.
Need also head facing eyes. Compute head direction at current interpolated? We know logicalDir. Eyes face movement direction. Draw head as circle with rounded body. Use path? For snake visuals, maybe draw each interpolated position as rounded rectangle/cell, plus connect? Could be nice: draw path using line segments between head/tail with rounded caps and joints. But if drawing segments individually may look smooth enough. Could draw continuous stroke: path through interpolated centers, stroke width cell*0.75, lineJoin/round, cap round. Then head on top with eyes. Food maybe circle. Particles.
Need self-collision: when new head equals any body cell excluding tail moving? Standard: If new head collides with any cell in current snake except tail if not growing? But our grid walls. For simplicity game over if newHead in cells except maybe tail when not growing. Since step updates tail moves away unless growing. We need check before applying. Use current cells: if !growPending and newHead == tail then safe; but if length maybe. However if using snake body cells (logical) before step: tail will move unless growing. We can check `if (growPending) collision = cells.some(c=> c.x==nx && c.y==ny)` (including tail? If tail stays? Actually if grow, tail remains and new head might hit old tail? That is impossible? It can hit current tail? If length >2 and head adjacent tail? If newHead equals tail before growth, eating causes collision? In classic if head moves into tail's old position while growing, tail doesn't move so collision. Yes if eat, tail remains, so any cell including tail collision. If no growth, tail cell will be vacated, so collision with tail okay. Implement with growPending boolean maybe if next food at newHead? We can compute growPending = (newHead == food). If growPending, check all cells. Else check all cells except last. Also if snake length 1? tail is head; but start length > maybe 3.
- Walls: if nx<0 or >=GRID or ny<0 or >=GRID game over. If newHead == food: score +=10, grow true, spawnFood, particles.
Input: queue directions. Requirement: quick successive key presses must both be honored, snake can never reverse. Need support queue of up to 2 maybe. Current dir can change when step occurs or immediately? Smooth movement: if change direction between ticks, could rotate head? Usually direction queue applies at next step. Quick presses should both be honored: if queue has [left, up], at next steps apply one by one. But if current moving right, pressing left ignored (reverse) not queued. Pressing up then left quickly while moving right: queue [up, left]; first applies at next step -> up, second -> left if valid relative to up. Need also if user presses two turns within one step, both queued. For visual smooth turning, could apply input immediately to current direction for rendering? But logical step occurs at grid boundaries. If input changes direction immediately, then interpolation? Better: maintain currentDir for movement. On keypress, push direction if valid compared to last queued/current and not reverse/same. At each step, if queue nonempty, currentDir = shift. This ensures no reversal. Quick presses honored. But if step interval long and multiple presses within same step, queue allows multiple. Could set max queue 2 or 3. Also if direction changes to vertical while horizontal, eyes may change only at step? For smoothness, maybe currentDir changes immediately to last queued? But then snake path would turn mid-cell? Hmm logical movement from current head to target depends on dir set at beginning of step. If we change currentDir after step started, interpolation would bend weird? We can keep `moveDir` for this step unchanged until step. For rendering eyes maybe use moveDir. Input queue processed at step start. Good.
Alternatively use continuous movement with substep: each cell step has direction; smooth path can turn only at cell centers. That's acceptable.
Need start: Requirement games must start in self-playing demo mode behind Click or press key to start message. So implement AI demo playing. The game should auto-demo at load. It can play itself with an AI until user starts. Click or key starts actual play? Need display overlay "Click or press any key to start". In demo mode, snake AI moves, eats, maybe restart on death. On first click or key, enter player mode with fresh game (or continue?) Requirement says behind message, game can be judged in action without playing. So start self-playing demo; when user interacts, start player game. Need handle Space/Enter restarts game; but initial interaction can be any key/click starts. Also P pauses. Need overlays: start, paused, game over. In demo mode, maybe overlay text with blinking. Need ensure no keyboard focus issues: click canvas or container starts. Add keydown listener. If state=DEMO, any key starts (except maybe P?). If state=PLAYING, arrows/wasd set directions, p pause. If GAMEOVER, space/enter restart. But initial any key should start; also P? Could start.
Self-playing AI: Simple BFS/path to food avoiding snake? For grid 24, can implement greedy with safety: choose valid direction that doesn't kill, maybe towards food, maybe with flood fill to ensure space. Demo should be visible long enough. We can design AI that plays reasonably. At 3 seconds, it can move. Need not perfect but should eat. Use pathfinding to food with current snake body as obstacles, choose first step. If no path, choose random safe direction maximizing tail distance/flood fill. To avoid too long step, speed maybe 8 moves/sec. Demo can play. If dies, restart automatically after short delay and continue demo. Need prevent AI reversing. Use same direction queue? Could directly set next dir at step if valid. For player mode, use queue.
But requirement says self-playing demo behind click start message. Need avoid input during demo? any interaction switches to player mode. Could implement state 'demo' and 'player' with mode. In demo, show message overlay. It plays; if user clicks, reset to player state and maybe show playing. Need if key arrow pressed in demo, start player mode. But if key is P maybe also start then pause? Not necessary.
Need visual polish:
- Background subtle gradient, grid lines? Maybe draw faint checkerboard/grid. Canvas centered with rounded panel. Score/best header. Overlays absolute over canvas.
- Particle burst: on eat spawn 12-18 particles at food with random velocity, life, color. Update using dt. Draw additive. Maybe small squares/circles.
- Snake head distinct: larger rounded cap, eyes facing dir. Maybe pupils shifted by dir. Add subtle shine.
- Smooth movement: interpolate as above. Use requestAnimationFrame with dt.
- Food: pulsing apple? Draw radial gradient, slight bounce. Could draw circular with highlight.
- Score display: HTML header or canvas overlay? Requirement display current score and best. Could HTML above canvas. Session best stored in memory. Game over overlay shows score/best.
Layout: Need grid at least 20x20 centered. Canvas maybe square. For 1280x800, viewport height limited by header/footer. Use CSS: body min-height 100vh, display flex center column. `#wrap` with header, canvas container, footer. Canvas CSS max width min(90vw, 78vh?) Need ensure square and no horizontal scrollbar. Use aspect-ratio: 1/1, width: min(92vw, 72vh, 640px). In 1280x800, 72vh=576px. Good. On 375, 92vw=345. Need high DPI: set canvas width/height = size*dpr. Use ResizeObserver to recompute. Grid logical 24 cells; cell = canvasSize / GRID. Draw in CSS pixels scaled.
Need careful with `cell` maybe integer. Use `canvas.width = Math.floor(css * dpr)`; set transform dpr. cell = css / GRID. Coordinates fractional.
Potential issue interpolation and drawing path when snake grows: length arrays mismatch after step until progress=1? At step event, set cells and fromCells. Need render progress maybe >1 if delay. Clamp 1. Good.
Need step timing: `stepDuration` e.g. 130ms for player, maybe 140ms demo. Could speed up slightly with score? But no need. Smooth movement with interpolation. If stepInterval too fast and rendering not synchronized? Use accumulator.
Game loop:
- last = performance.now(); accumulator=0;
- each frame: dt=now-last; last=now; if state not paused/over and mode demo: process input AI; accumulate += dt*speed? Need maybe step when accumulator >= stepDuration. But if dt large, clamp. For demo AI, choose move at step start.
- For player, process queue at step start.
- If state PLAYING or DEMO, `elapsed = now - lastStepTime`, progress = min(1, elapsed/stepDuration). But if not stepping? We'll set step when time reaches. Use `stepTime` = lastStepStart.
Simpler:
```
if (running) {
timeSinceStep += dt;
while (timeSinceStep >= stepDuration) {
timeSinceStep -= stepDuration;
doStep();
}
progress = timeSinceStep / stepDuration;
}
```
Need doStep may game over; then running false and progress? For overlay.
On start/reset:
- create snake in center: e.g. positions [[12,12],[11,12],[10,12]] moving right. fromCells same. score=0, best unchanged. timeSinceStep=0. spawnFood. particles reset. state PLAYING or DEMO. running true.
Direction validity:
```
const DIRS = {arrow keys/wasd}. function pushDir(d){
const last = queue.length? queue[queue.length-1]: dir;
if (d.x === -last.x && d.y === -last.y) return;
if (d.x === last.x && d.y === last.y) return;
if (queue.length < 2) queue.push(d);
}
```
At step, if queue.length: dir=queue.shift(). In demo AI can push safe dir? But direct set.
AI details:
Need BFS from head to food. Grid cells. Obstacles: snake segments except tail maybe? For movement next step, path can include tail? If no growth on first step maybe tail moves. But for simplicity obstacles all body cells; if path found choose next. If not, safe move with flood fill. But path with all body cells may fail near tail; choose fallback. We can implement BFS with `canMove` checking wall and not body except maybe tail? For AI, to be robust:
- At step start, candidate dirs = valid not reverse relative to current dir? It can consider queue? For demo no queue, just choose nextDir, if valid relative current and not reverse.
- For each candidate, next cell. If wall -> dead. If next == food -> good. Else if body collision except if grow? Need know grow = next == food. If not food, allow moving into tail if not growing. Use `isSnake(nx,ny, allowTail)`.
- Score candidates by BFS distance to food, or if next==food large. If no candidates, move maybe random.
But simple greedy can trap. We can use BFS shortest path from head's next candidates? Better: run BFS from head to food avoiding body (with dynamic tail?) and choose next cell along path. If no path, fallback to direction that maximizes flood fill reachable cells (safety). Also avoid immediate collisions.
Implement BFS:
```
function bfsPath(start, goal, blockedSet) { returns array of cells from start to goal? }
```
For snake obstacles, can treat all cells except tail if next movement no growth? But for path multiple steps, tail moves. Too complex. Use all body cells blocked for safety; if path to food not found, maybe chase tail. For demo okay.
Candidate selection:
```
function chooseAIDir(){
const safeDirs = dirs.filter(validImmediate);
if (!safeDirs.length) return null;
// try BFS to food using body cells (excluding head?)
const food = findPath(head, food, isBlocked = (x,y)=>body.contains(x,y) && !(x,y)==food? )
// For path, start=head. Goal=food. Need avoid current body except tail? We can mark all body cells blocked except head and maybe tail (tail will move). But if path enters tail okay? Use tail excluded if no growth? Not know. Let's exclude tail (unless length 1). Food not blocked.
if path length>1 and first cell != head: find dir to first cell among safeDirs. return dir.
// If no path, choose safe dir maximizing free space after move? Simulate next state and flood fill reachable from next cell avoiding body except tail? Or simple random safe preferring away from walls.
}
```
But BFS excluding tail might choose path that leads into body later, but immediate safe checked.
Alternative use `score` = BFS distance to food if safe immediate, else large. Could be simpler.
To ensure demo doesn't die quickly, implement a safe pathfinding with tail chasing fallback:
At AI turn, generate possible directions that are immediate safe (not reverse, not wall, not collision considering if eats). Sort by:
- if next == food: highest.
- else if there is a path from next cell to tail? Hmm.
- else use heuristic: BFS distance to food (obstacles body after potential move) lower better; if unreachable use flood size from next cell bigger.
But performance fine for 24 grid each step.
Could implement flood fill for free space from next cell with simulated snake after move. For each candidate, simulate cells: move snake (if grow add else remove tail), then compute reachable cells from next head? Actually if choose candidate, head is next cell; body includes previous cells. Flood fill from next cell to see open area. Score candidate by whether can reach food after move? Need BFS. Simpler:
- Candidate dirs immediate safe.
- For each, simulate newSnake (array with grow). Compute flood size from new head avoiding newSnake cells? But head occupies; use BFS from new head to count open cells (excluding body). If new head cannot reach tail? We need avoid closing off. Score = flood size + (distance to food if reachable else -1000). Also if next==food, high but still check space.
- Choose max score.
This can be okay. Flood size at most 576. 4 candidates per step -> 2k cells, step 8/sec = 16k operations okay.
Pseudo:
```
function simulate(newHead, grow){
const old = cells;
let next = [{x:newHead.x,y:newHead.y}];
let tailKeep = grow? old.length : old.length-1;
for (let i=0; i<tailKeep; i++) next.push({x:old[i].x,y:old[i].y});
return next;
}
function floodSize(start, body){
count cells not blocked and reachable 4-dir from start? start is head blocked but we count its adjacent reachable? Could count empty cells accessible from start excluding body except start? Use visited start. For neighbors not wall and not body (or body cells? tail maybe) count.
}
function distanceToFood(start, body) { BFS from start to food ignoring body except start, returns dist or Infinity }
```
For candidate, newSnake includes head and body; flood from head to count reachable empty cells, but head blocked. BFS: queue start, visited start; for neighbor, if not wall, not visited, not in newSnake, add count. If neighbor == food, allow (food not blocked). Count reachable empty incl food. If distance to food finite, score = 1000 - dist; else score = -1000 + floodSize*10? But if no path to food, maximize flood size to avoid trap. Need also immediate collision already ensures safe. If no candidate path to food, choose candidate with largest flood size. Maybe combine score = distance finite ? 2000 - distance + floodSize*5 : floodSize*10. But for long snake, flood size important to not trap. Could prefer path to food only if flood size after move > threshold? Use: if floodSize < snakeLength*? avoid. But demo okay.
Need not overcomplicate. We can create simple AI:
- Generate safe dirs.
- For each safe, compute `reachableCount` after moving; `distFood` after moving (if reachable). Score = reachableCount*100 + (distFood===Infinity?0:10000-distFood). Choose max. But this might choose big open space far from food, okay. To actually eat, need path. If path exists, dist term dominates maybe 10000 vs reachable 576*100=57600, so reachable dominates. Need tune. Maybe choose path with BFS from current head to food and follow first step if immediate safe; else fallback safety. Good.
Let's implement AI:
1. Determine immediate safe dirs from current head not reverse.
2. Try find path from head to food using BFS where blocked = snake cells except tail? (Because tail will move on first steps; but not exact). If path exists and path.length>1, get first step dir. If dir in immediate safe (should), return dir.
3. If no path, choose among immediate safe a dir maximizing flood fill from the resulting head, preferring cells with distance to tail? Could include if it can chase tail. But okay.
Need ensure food path doesn't use head? Path start at head, neighbors allowed if not body except head and maybe tail. If no path due to tail blocked, fallback. If path goes through food and eats, growth means tail not move; but immediate after first step might still safe? We check immediate. If path includes tail and grow not first step? Could cause collision later but AI reevaluates.
Potential infinite loop? Demo restarts on death after 1 sec. Good.
But requirement: games must start self-playing demo. If AI dies, auto restart and continue. Need state maybe DEMO and after game over in demo, wait then reset demo.
Overlay: For demo mode show "Click or press a key to start" plus maybe "Demo playing...". For player game over show "Game Over" score, best, restart instruction. For paused show paused.
Keyboard/click behavior:
- On container click: if mode DEMO -> start player game; if GAMEOVER -> restart player; if PLAYING -> maybe pause? Better not. If PAUSED -> resume.
- On keydown:
if code Space or Enter: if mode DEMO -> start player; else if state GAMEOVER -> reset player; else if PAUSED -> resume.
else if state DEMO and any key: start player? But arrow also needs set dir? Could ignore after start. Requirement says any key to start. We can set started = true, start game fresh. If key was arrow, maybe queue after start? Could do `startPlayer(); if dirKey then push`. But if game just started moving right, arrow up valid.
else if PLAYING: handle arrows/wasd; P pause; Space? If space during playing not used? Requirement space or enter restarts (after game over). Could use space to restart always? But might accidentally restart. Only on gameover/paused? But requirement says Space or Enter restarts, P pauses. Could if PLAYING and Space => restart? Maybe not. We can implement Space/Enter restart if gameover or paused; if playing, no effect.
P toggles pause. If paused, P resumes.
- Prevent default for arrows/space to avoid scroll.
Need ensure keyboard only after iframe receives keyboard events after user clicks. Show message.
Scoring/best: `score`, `best`. In demo, do we count score? Requirement best session. Should demo count? Maybe no, or display best from player only? If self-play eats, could display score, but when user starts reset. Best maybe player only to avoid inflated by demo. But overlay might show demo score. Better: in demo, score is shown but does not update best. On game over in demo, reset. When player mode starts, score resets. Best updated only on player game over? Or on eating in player. Could update best when `mode==='player'` and score>best. Session best of player. The requirement says best score of session, not necessarily excluding demo. But if demo plays high before start, user sees best maybe from demo, which might be weird. We can update best in demo too? If demo auto plays and restarts, best may become high; not unfair. But when user starts, best remains. Fine. Simpler update best on any score > best. But initial best maybe 0. Good.
But if demo eats many, best high. It might distract? Could keep best for player only and show `0` until user plays. But demo overlay score still visible. Requirement not precise. We'll update best only when not demo? Hmm session best could include demo? I'd say player best. But if demo self-play before start, the game hasn't started. To avoid confusing, set `best` only in player mode. Display best as `best`. Demo score shown but best remains 0 or previous. When player dies, update best. If user presses start after demo high, best still 0 then updates. That's okay. But requirement "best score of the session" maybe from actual play. Let's do best updated only on `mode==='player'`.
Canvas drawing:
- Resize function:
```
function fit() {
const containerSize = Math.floor(Math.min(window.innerWidth * 0.92, window.innerHeight * 0.68, 640)); // Need fit header/footer. But can use CSS width and clientWidth.
const rect = canvas.getBoundingClientRect(); const cssSize = Math.floor(rect.width); canvas.width=cssSize*dpr; canvas.height=cssSize*dpr; ctx.setTransform(dpr,0,0,dpr,0,0); cell = cssSize / GRID; }
```
CSS sets width aspect square. Use `canvas` style width 100%, container width min.
- `fit` called on load, resize, ResizeObserver maybe.
Draw background:
```
ctx.clearRect...;
// board panel: fill gradient. Maybe use rounded rect? Canvas itself rounded via CSS border-radius and overflow hidden? Canvas background. Draw board bg: subtle dark green/navy checker.
for i,j: fill = (i+j)%2 ? rgba(255,255,255,0.02) : rgba(0,0,0,0.04). Then grid lines faint.
```
Could draw only board, CSS canvas border-radius. Good.
Food pulse: `phase = performance.now()/400`, radius = cell*0.36*(1+0.08 sin). Draw radial gradient red/pink. Add highlight. If particle burst, draw after food.
Snake drawing:
- Need compute positions array:
```
function renderPositions(t) {
const pos=[];
for (let i=0; i<snake.length; i++) {
const from = fromSnake[i] || fromSnake[fromSnake.length-1];
const to = snake[i];
pos.push({x: lerp(from.x,to.x,t), y: lerp(from.y,to.y,t)});
}
return pos;
}
```
But if `fromSnake` length differs due to growth? We set accordingly. At start same length. After step with grow length N+1 and from length N+1. Good.
- Draw continuous body: if positions length 1 draw circle. Use `ctx.beginPath(); move to pos[0].x*cell + cell/2; lineTo each;` stroke with width `cell*0.72`, lineCap round, lineJoin round. Gradient? Could stroke solid, then maybe outline. Use shadow? Performance okay. Draw body with gradient along length? Canvas linear gradient from head to tail? Could create based on bounding? Simpler draw path with two strokes: outer dark, inner color.
```
ctx.strokeStyle = 'rgba(0,0,0,0.25)'; ctx.lineWidth = cell*0.78; stroke
ctx.strokeStyle = gradient? ctx.lineWidth = cell*0.7; stroke
```
Need colors: neon green/cyan gradient. Use head color bright. For body gradient along path? We can set gradient from head to tail coordinates, but path not straight. Could create `createLinearGradient(headPos.x, headPos.y, tailPos.x, tailPos.y)` with stops. Good enough.
- Draw scales? Maybe not.
- Draw head: at pos[0], radius = cell*0.45. Fill with color. Add eyes: offset perpendicular to dir. Eye whites radius cell*0.14, pupils radius cell*0.065 offset in dir * cell*0.07. If using logical dir or interpolated dir? Use `dir` at step. But when turning at path, head may bend. Could derive head dir from pos[0]-pos[1] if length>1, normalized. That makes eyes face path direction. At start use dir. We can compute `eyeDir`: if positions length>=2, vector from pos[1] to pos[0] (head to segment behind), but if segment behind not directly opposite? It is; at interpolation after turn, pos[0] moving new dir, pos[1] from old, vector gives head direction? Example head moving up, second moving right to old head: vector from second to head may diagonal early, okay. Use current logical dir maybe simpler. But eyes face direction: use `dir` (after step). If input queued not applied yet? At step current dir set. Good. During progress, head moves along dir. Use dir. However when turning at step, from step progress=0, dir is new. Eyes snap? Could smooth eye dir by lerping previous and current dir? Could store `renderDir` as vector normalized from head pos - pos[1] if length>=2. Better. At step progress 0, pos[0]=from head (old), pos[1]=? For non-grow pos[1]=from[1]? Wait if progress 0, positions = fromSnake, so head at old position and body at old positions; vector head-second might be old direction. At progress >0, head moves new dir, second moves old head, vector gradually turns. Good. Use positions vector for eyes. If length=1 use dir. Need ensure not zero; if equal positions (growth second stays? at start length>1). Could normalize.
- Maybe draw tongue when moving? optional.
Particles:
```
particles = [];
function spawnParticles(x,y){ for i 0..18: angle random, speed = cell*(1+rand*3) px/s? Need update dt. pos in pixel units? Use cell units. Store {x,y in cells, vx,vy, life, max, color,size}. }
function updateParticles(dt){ p.life += dt; p.x += p.vx*dt; ... remove}
function drawParticles(){ alpha = 1 - life/max; ctx.beginPath circle }
```
Use cells coordinates for scaling. velocities in cells/sec, e.g. 1.5-4.5.
Game over overlay: HTML overlay positioned absolute over canvas. States classes show/hide. Use `pointer-events:none` except start click? Need click anywhere on canvas area. Overlay could have text and `pointer-events:none` so container click handles. Use container `#stage` relative, click listener. Overlay text centered.
Start message: `#start-overlay` with title, subtitle. In demo mode visible. On click starts player -> hide. If paused/game over -> show relevant.
Need avoid text too high. Maybe header displays score/best and controls. Footer hints.
Detailed layout HTML:
```
<div id="app">
<header id="hud">
<div class="score">Score <strong id="score">0</strong></div>
<div class="title">SNAKE</div>
<div class="best">Best <strong id="best">0</strong></div>
</header>
<div id="stage">
<canvas id="game"></canvas>
<div class="overlay" id="startOverlay">...</div>
<div class="overlay" id="pauseOverlay">Paused</div>
<div class="overlay" id="gameOverOverlay">...</div>
</div>
<footer>...</footer>
</div>
```
Use `overlay` hidden with class `show`. Start overlay has "Click or press a key to start" and maybe "AI demo".
Game over overlay: `Game Over`, score line, best line, restart hint.
CSS:
```
body { margin:0; height:100vh; background: radial-gradient; font-family: system-ui; overflow:hidden? Need no horizontal scrollbar. But if content taller? use min-height:100dvh, flex center. Maybe overflow-x:hidden; overflow-y:auto? The requirement 1280x800 no horizontal scrollbar; vertical scroll okay? better no scroll. Use `height:100svh` and `min-height:100svh`; `overflow:hidden`? On small, footer may overflow. Use `min-height:100svh`, `display:flex; align-items:center; justify-content:center`; canvas sized by CSS based on available using `svh` maybe. `#stage { width: min(92vw, 70svh, 620px); aspect-ratio:1; }` Header/footer fixed size. At 375x800, width 345. At low height maybe. Use `min(90vw, calc(100svh - 170px), 560px)`. Need aspect-ratio square. `canvas { width:100%; height:100%; display:block; }`
```
Maybe `#app { padding: 12px; box-sizing:border-box; max-width:100vw; }`
Colors: dark blue-purple, neon green snake, red food. HUD glass panel.
Potential issue canvas getBoundingClientRect size when CSS aspect not loaded. Use ResizeObserver on stage.
Implementation skeleton:
```
const GRID=24;
let cell=20, dpr=1;
let state='demo'; // demo, playing, paused, over
let mode='demo'; // demo/player
let snake, fromSnake, dir, queue, food, score, best=0, timeSinceStep=0, stepDuration=130, paused=false, gameOver=false, overTimer?
let particles=[];
```
Maybe separate `mode` and `phase`: `phase='demo' | 'playing' | 'paused' | 'over'`. When phase='demo', AI runs. When user starts, phase='playing'. If user presses P, phase='paused'. If dies, phase='over'. If over in demo, maybe `demoWait` then reset demo. If over in player, wait restart.
State transition functions:
```
function newGame(isDemo){ mode = isDemo?'demo':'player'; snake = [...]; fromSnake = snake.map(copy); dir={x:1,y:0}; queue=[]; score=0; particles=[]; timeSinceStep=0; spawnFood(); phase = isDemo?'demo':'playing'; updateOverlays(); }
function startPlayer(){ newGame(false); }
function restartPlayer(){ newGame(false); }
```
But if phase='demo' and death, don't show game over? Could continue after short delay. Could set `phase='over'; mode='demo'; setTimeout newGame(true)`. But user might press key during demo game over and start. Need if over in demo, overlay might show? Requirement start message should always be visible until click? It says behind a "Click or press key to start" message. We can keep start overlay even during demo game over, with maybe demo restarting. Better not show game over overlay in demo; only start message. On AI death, auto restart after 900ms. In that time canvas maybe death animation? Keep phase demo. Could set `demoRestartTimer` and pause. But simple: if collision while phase demo, call newGame(true) after delay. Need game loop stop movement until restart. Use `state='demoDead'`? We can set `running=false` and timer.
Simpler: if AI dies, immediately reset demo with a particle explosion? Could be abrupt. Use `phase='over'; mode='demo'; overlay start still visible; after 1000ms reset demo`. But while over, if user click start player. Need handle. Use `overAt` timestamp; in loop if mode demo and phase over and now - overAt > 1000 newGame(true). `updateOverlays`: in demo mode, always show start overlay, hide gameover even if phase over.
Input start: if mode demo and user press key -> `startPlayer()` (phase playing). If game already over in player -> Space/Enter restart; arrow? Requirement space/enter restarts. Could any key start when initial demo. Good.
Keyboard handling:
```
window.addEventListener('keydown', e=>{
if (['Space','Enter','ArrowUp',...,'KeyP'].includes(e.code)) e.preventDefault();
if (mode==='demo') { startPlayer(); maybe process current dir key after start; return; }
const d = dirMap[e.code]; if (d) { pushDir(d); return; }
if (e.code==='KeyP') togglePause();
if ((e.code==='Space'||e.code==='Enter') && phase!=='playing') restartPlayer();
});
```
But after `startPlayer()`, if key was arrow, we should queue that direction. `startPlayer` resets dir right. Then `pushDir(d)` if d. Good. Need not return before? Do:
```
if (mode==='demo') { const d=dirMap[e.code]; startPlayer(); if(d) pushDir(d); return; }
```
If key P in demo starts then P might not pause; acceptable.
Click:
```
stage.addEventListener('click', ()=>{ if (mode==='demo') startPlayer(); else if (phase==='paused') phase='playing'; else if (phase==='over') restartPlayer(); });
```
Need not start on click inside button? No buttons. If click after game over player, restart. If playing, maybe pause? Better ignore to avoid accidental. Or clicking can pause? Not needed.
Pause:
```
function togglePause(){ if (phase==='playing') {phase='paused';} else if (phase==='paused') {phase='playing'; last=performance.now();} updateOverlays(); }
```
Need `last` time? If using accumulator timeSinceStep, paused time shouldn't advance steps. Use `running = phase==='playing' || phase==='demo'`. In loop if not running, skip accumulate.
Update loop:
```
function frame(now){ requestAnimationFrame(frame); const dt=Math.min(0.05,(now-last)/1000); last=now; if(phase==='playing'||phase==='demo'){
timeSinceStep += dt*1000;
while(timeSinceStep >= stepDuration){ timeSinceStep -= stepDuration; doStep(); if (phase!=='playing' && phase!=='demo') break; }
} else if (phase==='over' && mode==='demo'){ maybe timer? }
updateParticles(dt); render(now); }
```
But doStep may game over set phase over; break.
If phase='over' player, still render death flash.
`doStep`:
```
if (phase==='demo') { const aiDir = chooseAIDir(); if (aiDir && (aiDir.x != -dir.x || ...)) dir=aiDir; }
else if (queue.length) dir = queue.shift();
let nx = head.x + dir.x, ny=...
if wall or self { endGame(); return; }
const eat = (nx===food.x && ny===food.y);
old = snake.map(...)
newSnake = [{x:nx,y:ny}] + old; if (!eat) newSnake.pop();
// set fromSnake aligned:
if (eat) fromSnake = old.concat([old[old.length-1]]); else fromSnake = old;
snake = newSnake;
if (eat) { score +=10; if mode player update best? or all? spawnParticles(food.x, food.y); spawnFood(); stepDuration maybe max(70, 130 - score/??) }
```
Need self-collision before moving:
```
function isColliding(nx,ny, eat){ for i=0..snake.length-1; if (!(i===snake.length-1 && !eat)) check } // skip tail if not eat. But if length==1? skip head? Snake length 3.
```
If eat, include tail. Also if newHead equals any segment. If not eat and newHead equals tail, okay. But if newHead equals other segment including if tail length? Good.
- Wall: if nx<0 etc.
- Need after eat, food may be on snake? spawnFood before update? We need food currently at newHead. After moving, spawn new food not on snake (new snake). Good.
`spawnFood`: collect all snake cells, random empty. Use list of available for efficiency.
```
function spawnFood(){ const occupied=new Set(snake.map(c=>c.y*GRID+c.x)); const empty=[]; for y... if !occupied push; pick empty random }
```
If snake fills board, win? Could game over? Requirement not. If no empty, win overlay? Could set score. Implement if empty length 0 -> win game over? Maybe show "You win!". Not required but robust. Could treat as game over with score. If snake fills grid no food. Add phase over, overlay title "Perfect!"? But grid 24^2, not needed.
`endGame`: set phase='over', overAt=now; if mode==='player', update best; show overlay; maybe flash. If mode==='demo', keep start overlay but set `demoDeadAt`? If `mode==='demo'`, `phase='over'` but not show gameover. In loop if mode demo and over for 800ms reset.
Overlays update:
```
function updateOverlays(){ startOverlay.classList.toggle('show', mode==='demo'); pauseOverlay.classList.toggle('show', phase==='paused'); gameOverOverlay.classList.toggle('show', mode==='player' && phase==='over'); }
```
Score elements updated in render or on event.
Need initial demo start: call newGame(true); update overlays. Maybe show start message.
Self-play demo speed and smooth: Step 125ms; AI choose. Need if AI uses path BFS every step maybe okay. Need implement path function robust.
BFS path:
```
function bfsPath(head, goal) {
const key=(x,y)=>y*GRID+x;
const blocked = new Set(snake.map(c=>key(c.x,c.y)));
// allow tail to move: remove tail key? If goal is tail? Food can't be tail. Remove tail. If eating, tail may not move first step; but path first step not food? okay.
blocked.delete(key(snake[snake.length-1].x, snake[snake.length-1].y));
// goal not blocked
const prev = new Map();
const q=[head]; visited = new Set(key(head));
while(q){ const c=q.shift(); if(c.x==goal.x && c.y==goal.y) reconstruct; for neighbors if in bounds, not blocked, not visited add. }
return null;
}
```
Need if path length 1 (head at food impossible), return null. For path first step maybe direction.
Flood count for fallback:
```
function simulateNext(nx,ny,eat){ const next=[{x:nx,y:ny}]; const keep = eat? snake.length: snake.length-1; for i=0; i<keep; i++ next.push(snake[i]); return next; }
function floodCount(nextBody, start, goal){ // count reachable from start excluding body but allow goal? Actually start in body, BFS from start. blocked set nextBody except start? Use nextBody key. For neighbor if not wall and not in blocked (or neighbor==goal), count. }
```
Could combine distance to food and flood. Use for candidate score:
```
function scoreDir(dx,dy){
const eat = (head+dir)==food;
if wall collision invalid;
next = simulate
dist = bfsDistanceFrom(head? or next head?)? For candidate, use distance from next head to food using nextBody blocked (except next head and tail). reachable = floodCount(nextBody, nextHead, food)
score = 100000 + (dist===Infinity?0:10000-dist) - (nextBody.length? maybe) + reachable*10
```
Need BFS distance similar. Could choose based on reachable first to avoid traps. Maybe:
- If `dist !== Infinity`, candidate score = 200000 + reachable*50 - dist; else score = 100000 + reachable*100. But if dist finite but reachable low trap, might still die; immediate re-evaluate. Better avoid small flood: if reachable < snake.length? maybe penalty. Use `safety = reachable`; `pathScore = dist`; final `score = safety*1000 + (path finite ? 1000 - path : -100000?)` Need prioritize food but not trap. For demo, simple path BFS maybe enough.
Alternative AI: follow BFS path to food if immediate safe; if no path, follow tail? But not necessary.
Let's implement candidate scoring:
```
function chooseAIDir() {
const head=snake[0];
const dirs=[...];
const valid=[];
for d of dirs:
if (d.x == -dir.x && d.y == -dir.y) continue;
nx=...; if wall continue; eat=...
if (selfCollide(nx,ny,eat)) continue;
// simulate body
nextBody = simulate;
// immediate safety: if nextBody length < GRID*GRID? count reachable from nextHead using nextBody blocked, allow food. Use BFS count & dist.
const info = evaluateState(nextBody, {x:nx,y:ny});
let base = info.reachable * 20;
if (info.dist !== Infinity) base += 1000 - info.dist;
if (eat) base += 500;
// prefer center? base += (center distance? no)
valid.push({d, score:base});
choose max, random tie
```
Evaluate BFS from newHead:
```
function evaluateState(body, start) {
const blocked=new Set(body.map(...)); blocked.delete(startKey);
// BFS from start over cells not blocked, including food. Count visited cells except start? Include.
const q=[start], visited=new Set(startKey), distMap? count=1;
while... for neigh if not wall and not visited and not blocked? If neighbor == food, not blocked anyway; if neighbor == tail? blocked? body tail may be obstacle; but tail will move later, but for safety count. Count all open cells.
// distance to food: store distance if neighbor==goal. Could do same BFS, if x==food return distance.
}
```
This counts open area. Since start is body, blocked deleted start; good. Count includes start and maybe body? no. If snake long, reachable decreases. For food path, distance. This is O(576). Good.
Maybe choose candidate with food reachable but very small area? Since base reachable*20 vs distance: max distance 20, 1000. reachable*20 for full 576 =11520, so open area can dominate. If candidate has reachable 50 but path short, score 1000+1000=2000; candidate no path reachable 60 score1200; okay. If food path exists, distance 1000 but reachable maybe 10 =>1200; fallback reachable 50 =>1000; choose path. Good.
Potential AI dies due to path using current tail? Immediate selfCollide ensures safe for current step. Future steps re-evaluate.
Need ensure demo doesn't get stuck choosing reverse. It uses current `dir`. Good.
Rendering death: On game over, maybe snake fades? Keep last positions. Could overlay dim.
`render` uses positions interpolation for phase playing/demo. For paused/over, use current positions with progress timeSinceStep/stepDuration? If paused after step? We can draw positions at current `progress` = phase paused? If paused mid-step, keep progress frozen. Use `progress = Math.min(1, timeSinceStep / stepDuration)` always (when paused timeSinceStep not advancing). For over, maybe set to 1? Could draw final integer positions or frozen. Since endGame before applying move? We don't apply move on death; maybe show snake at previous positions with a red flash. If collision due to nextHead, snake hasn't moved? Could still draw previous step progress? Maybe set `deadHead`? Not necessary.
At step: we check collision before applying. If collision, do not update snake, phase over. So positions remain last step, progress maybe 1 if step reached. Good.
Smooth path drawing: Use positions array of cell centers. Need if progress=1, exact. If fromSnake length mismatch? Should match. Ensure after growth fromSnake length = snake length. In non-grow, old length N, new length N. Good.
Potential bug with initial fromSnake after eating: Suppose snake length 3, eat. old length 3. new length 4. fromSnake=old + [old tail] length4. positions at progress 0: from[3] tail duplicate = old tail, target new[3]=old tail no movement. segment1 old[1]? new[1]=old head, from[1]=old body -> moves to old head; good. At progress=1 positions new. At next step non-grow: old=positions? We use `snake.map(copy)` of integer positions, not visual. Good. fromSnake=old. new length=old length. Segment 0 head old to newHead, segment1 old[1] to old[0], etc. Good.
Need note: `snake` positions integer current target. At step, if previous step was growth, fromSnake has duplicate tail. At end progress, positions integer. `snake` already integer; fromSnake irrelevant. Good.
Drawing path: If snake path has segment from duplicate tail same as target, no issue. Could include duplicate point; stroke handles.
Eyes: Compute `headDir` from `renderPos[0] - renderPos[1]` normalized. But if at progress 0, head and second may same? For growth second target old head, from second old body, so at progress 0 positions old: head old, second old body, vector old dir. Good. If length 1, use dir. If vector length very small (due to growth second = head? at start no). For growth at progress 1, head new, second old head, vector new-old = dir. Good. Normalize.
Eye offset: `perp = {x: -dir.y, y: dir.x}`. Draw whites at head center + dir*cell*0.12 ± perp*cell*0.15. Pupils at head center + dir*cell*0.18 ± perp*cell*0.13 maybe. Need radius cell*0.12. If cell small, okay.
Maybe draw head as rounded circle not stroke cap to avoid cap too large. Stroke path already gives round head cap. Add extra head circle slightly larger and color, then eyes.
Colors:
- Board bg: #0f172a, grid #1e293b. Snake gradient: head #4ade80 to tail #059669. Outer stroke dark #065f46 maybe. Food: red #f87171, center #fee2e2. Particles: #fbbf24, #f87171, #4ade80.
HUD: Title with gradient text. Score boxes glass.
Need check horizontal scrollbar: body overflow-x hidden. Use no fixed widths. Fine.
Testing mentally:
- Load: newGame demo. loop last set. demo runs. startOverlay show.
- Press ArrowUp: mode demo -> startPlayer (reset snake, dir right, phase playing); pushDir up (queue [up]); no return? Need if startPlayer sets phase, then push. Good. If key was KeyP, startPlayer no pause. Fine.
- Arrows while playing push queue. If queue length 2. At each step, dir shift. Quick presses honored. If press left while right ignored. If press up then right quickly while moving right: queue [up]; second right vs up not reverse, queue [up, right]. At next step dir up; next dir right. Good. If press right then up then left while moving right? queue [up]; left vs up valid queued [up,left]; next up then left. Good.
- Wall: game over.
Need ensure no reversal during step by queue last comparison. But if current dir right, queue [up, right]? last up, right okay. At step shift up. Good. If queue empty and press left while dir right: ignored. If queue has [up], press right valid. If press down while queue [up]? down reverse of up ignored. Good.
Potential issue if step occurs before queue length >1, current dir changes to up, and queue contains right. The key press right was valid vs up. Good.
AI uses `dir` current. It returns immediate safe dir not reverse relative to dir? In doStep, if mode demo choose `aiDir`, if not null and not reverse? choose already not reverse. Then dir=aiDir. Queue not used.
Demo if user starts after demo has high score: best maybe unchanged; score resets. Start overlay hidden. Good.
Potential performance: ResizeObserver calls fit; if cell changes no issue.
Need code length but okay.
Let's craft code carefully.
Variables:
```
const GRID = 24;
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
let cssSize=0, cell=0, dpr=1;
let phase='demo', mode='demo';
let snake=[], fromSnake=[], dir={x:1,y:0};
let dirQueue=[];
let food={x:0,y:0};
let score=0, best=0;
let timeSinceStep=0, stepDuration=125;
let particles=[];
let last=0, overAt=0, demoRestartAt=0;
```
Use `phase`: demo, playing, paused, over. Mode: demo/player. `newGame(isDemo)` sets mode, phase.
`copySnake = a => a.map(p=>({x:p.x,y:p.y}))`
`selfCollide`:
```
function willCollide(nx, ny, eats) {
if(nx<0||ny<0||nx>=GRID||ny>=GRID) return true;
const tail = snake[snake.length-1];
for(let i=0;i<snake.length;i++){
if(!eats && i===snake.length-1) continue;
if(snake[i].x===nx && snake[i].y===ny) return true;
}
return false;
}
```
But if newHead equals food and food may equal tail? Food not on snake; okay.
`doStep`:
```
function doStep(){
const head=snake[0];
if(mode==='demo') { const d=chooseAIDir(); if(d) dir=d; }
else if(dirQueue.length) dir=dirQueue.shift();
const nx=head.x+dir.x, ny=head.y+dir.y;
const eats = nx===food.x && ny===food.y;
if(willCollide(nx,ny,eats)){
endGame(); return;
}
const old=copySnake(snake);
const newSnake=[{x:nx,y:ny}];
for(let i=0;i<old.length;i++) newSnake.push(old[i]);
if(!eats) newSnake.pop();
if(eats){
fromSnake = old.concat([old[old.length-1]]);
} else {
fromSnake = old;
}
snake = newSnake;
if(eats){
score+=10;
if(mode==='player' && score>best) best=score;
spawnParticles(food.x, food.y);
spawnFood();
// slight speed up? stepDuration = Math.max(75, 125 - Math.min(40, score/10*1.5)) maybe.
if(mode==='player') stepDuration = Math.max(75, 128 - Math.min(36, Math.floor(score/10)*1.2));
}
updateHUD();
}
```
Need if eats and old empty? no.
Speed up maybe also demo constant. Could define `baseStep`. On newGame, stepDuration=baseStep. In demo no speed up.
`endGame`:
```
function endGame(){ phase='over'; overAt=performance.now(); if(mode==='player'){ if(score>best) best=score; updateOverlays(); } else { demoRestartAt=performance.now()+900; updateOverlays(); } }
```
In loop if mode demo and phase over and now>=demoRestartAt -> newGame(true). If user interaction during that, start player. Good.
`startPlayer`:
```
function startPlayer(){ newGame(false); }
```
But if called from keydown demo with dir push after. If keydown also space/enter? newGame. Good.
`newGame`:
```
function newGame(isDemo){
mode = isDemo ? 'demo':'player';
phase = isDemo ? 'demo':'playing';
const cy=Math.floor(GRID/2);
snake=[{x:cy+1,y:cy},{x:cy,y:cy},{x:cy-1,y:cy}];
fromSnake=copySnake(snake);
dir={x:1,y:0}; dirQueue=[]; score=0; timeSinceStep=0; stepDuration=isDemo?125:125; particles=[]; spawnFood(); updateOverlays(); updateHUD();
}
```
If start from demo death with timer, newGame(true) sets phase demo.
`togglePause` only mode player.
`updateHUD`: set score/best text.
`spawnFood`: as above. Need use snake current. If no empty: phase over? For mode demo restart; player best update.
```
function spawnFood(){ const occ=new Set(snake.map(c=>c.y*GRID+c.x)); const empty=[]; for y.. if(!occ.has(...)) empty.push; if(empty.length===0){ food={x:-1,y:-1}; endGame(); return; } const p=empty[Math.floor(Math.random()*empty.length)]; food={x:p.x,y:p.y}; }
```
But endGame uses food maybe -1. If snake fills board, score? Maybe add win. Could just `phase='over'; food=null;` But need not.
`chooseAIDir`:
```
const ALL_DIRS=[{x:1,y:0},{x:-1,y:0},{x:0,y:1},{x:0:-1}];
function chooseAIDir(){
const head=snake[0];
const options=[];
for(const d of ALL_DIRS){
if(d.x === -dir.x && d.y === -dir.y) continue;
const nx=head.x+d.x, ny=head.y+d.y;
const eats = nx===food.x && ny===food.y;
if(willCollide(nx,ny,eats)) continue;
const nextSnake=simulate(nx,ny,eats);
const info=evaluate(nextSnake,{x:nx,y:ny});
let s = info.reachable * 18;
if(info.dist < 10000) s += 1000 - info.dist;
if(eats) s += 500;
// prefer moving toward center slightly
s += ((GRID - Math.abs(head.x + d.x - GRID/2)) + (GRID - Math.abs(head.y + d.y - GRID/2))) * 0.2;
options.push({d,s});
}
if(!options.length) return null;
options.sort((a,b)=>b.s-a.s);
// add tiny random among top if tied? Could pick random weighted. For demo natural, add Math.random()*10.
return options[0].d;
}
```
`willCollide` uses current snake. In AI candidate, `eats` includes possible food; if eats, no tail skip.
`simulate`:
```
function simulate(nx,ny,eats){ const next=[{x:nx,y:ny}]; const keep = eats ? snake.length : snake.length-1; for(let i=0; i<keep; i++) next.push({x:snake[i].x,y:snake[i].y}); return next; }
```
If eats and newHead may equal old? not, safe. If eats length +1. Good.
`evaluate`:
```
function evaluate(body, start){
const startKey=start.y*GRID+start.x;
const blocked=new Set(); body.forEach(c=>blocked.add(c.y*GRID+c.x)); blocked.delete(startKey);
const q=[start]; const seen=new Set([startKey]); let reachable=1; let dist=Infinity;
let qi=0;
while(qi<q.length){ const c=q[qi++]; const key=c.y*GRID+c.x; if(c.x===food.x && c.y===food.y && dist===Infinity) dist = distArr? Need store distance. Use queue of {x,y,d}. }
```
Better store distance separately:
```
const queue=[{...start, d:0}]; seen.add(startKey);
while... if(c.x==food.x && c.y==food.y) dist=c.d;
for neigh: if in bounds, nKey not seen, not blocked, add. reachable++;
```
Start may not food. If neighbor blocked? okay. Count includes start and reachable open cells. If food blocked? blocked from body no food on snake. Good.
Return {reachable, dist: dist===Infinity?99999:dist}.
This evaluate does not consider food cell as blocked, so reachable includes food. Good.
Potential AI too slow? BFS for each option each step. 4*576 okay.
`spawnParticles`:
```
function spawnParticles(gx,gy){ const colors=['#ff6b6b','#ffd93d','#ff9f43','#4ade80']; for(let i=0;i<20;i++){ const a=Math.random()*Math.PI*2; const sp=(0.8+Math.random()*3)*cell? Wait cell changes. Store velocities in cells/sec: 1.5-5.5. const life=0.35+Math.random()*0.5; particles.push({x:gx+0.5, y:gy+0.5, vx:Math.cos(a)*(1+Math.random()*3), vy:Math.sin(a)*(1+Math.random()*3), life:0,max:life,size:cell*0.08... But size depends on cell at draw? Store size relative cell: 0.08+rand*0.16. Color. } } }
```
Use coordinates in cell grid units (0..GRID). Draw x*cell. Update p.x += p.vx*dt, p.y += p.vy*dt; vx, vy in cells/sec. Life += dt. Remove if life>=max.
Add gravity? maybe slight. Could make them drift outward and fade.
```
p.vx *= 0.96? But with dt maybe p.vx*=Math.pow(0.9,dt*60). Simpler no.
```
Size: draw radius `p.size * cell`, but p.size stored 0.08+rand*0.12.
`updateParticles(dt)`: for p: p.life+=dt; p.x+=p.vx*dt; p.y+=p.vy*dt; p.vx*=Math.pow(0.93,dt*60); p.vy*=...; filter.
Need if particles created before cell set? newGame after fit? cell set. But size not needed in logic.
`render(now)`:
```
ctx.clearRect(0,0,cssSize,cssSize);
drawBoard();
drawFood(now);
drawParticles();
drawSnake(now);
if(phase==='over') drawDeathTint? overlay HTML handles.
```
Order: particles maybe after snake? Food then snake then particles? Eating burst should be visible above snake maybe. Draw particles after snake. But food under snake? okay.
`drawBoard`:
```
const bg=ctx.createLinearGradient(0,0,cssSize,cssSize); ... fill.
for y,x: fill checker: maybe only if (x+y)%2. Use globalAlpha.
```
Performance drawing 576 rects each frame okay. Could pre-render board to offscreen canvas for polish? But simple. Maybe use faint grid lines. Need no too heavy. 576 rects*60 = 34k fills ok. Could optimize with pattern? Fine.
`drawFood`: if food.x<0 return. Compute pulse. Need `food.x+0.5`. Draw shadow/glow. Maybe radial gradient:
```
const r=cell*0.38*(1+0.08*Math.sin(now/250));
const glow=ctx.createRadialGradient(... r*0.2,r); add color stops; fill circle.
```
Add small leaf? Maybe simple.
`drawSnake`:
```
const t = Math.min(1, timeSinceStep/stepDuration); // if paused/over maybe keep? For demo/playing t maybe less; for over after collision no update maybe t=1 if reached? Could if phase='over' use 1? Actually endGame at step time t reset to 0? In loop after doStep phase over, timeSinceStep remains small (after subtraction). If we use progress <1, snake remains at old positions (not final step) which is okay (didn't move). But if want flash at collision, could draw head to next? Not necessary.
const pos=snake.map((seg,i)=>{ const f=fromSnake[i]||fromSnake[fromSnake.length-1]; return {x: lerp(f.x,seg.x,t)+0.5, y: ...} });
if(pos.length>1){ ctx.beginPath(); ctx.moveTo(pos[0].x*cell,pos[0].y*cell); for i=1 lineTo; ctx.strokeStyle=... lineWidth=cell*0.8; stroke; inner lineWidth=cell*0.64 gradient; stroke; }
else circle.
draw head circle.
```
Need if positions path has collinear? fine. But if snake has sharp turns, lineJoin round. Outer dark stroke then inner. Could add highlight stroke top? maybe `cell*0.2` light along path with lower alpha.
Gradient: from head to tail pixel positions:
```
const g=ctx.createLinearGradient(pos[0].x*cell,pos[0].y*cell, pos[pos.length-1].x*cell,pos[pos.length-1].y*cell);
g.addColorStop(0,'#4ade80'); g.addColorStop(0.55,'#22c55e'); g.addColorStop(1,'#0f9b5f');
```
If head and tail same? okay. For path stroke, gradient linear may not reflect along path but fine.
Outer stroke dark `rgba(3,28,16,0.8)` or `#064e3b`.
Inner stroke gradient.
Could draw shadow: `ctx.shadowBlur=cell*0.4; shadowColor='rgba(34,197,94,0.25)'` on inner? Might be costly but okay.
Head drawing:
```
const hx=pos[0].x*cell, hy=pos[0].y*cell;
// determine eyeDir from pos[0]-pos[1]
let ed = {x:dir.x,y:dir.y};
if(pos.length>1){ dx = pos[0].x-pos[1].x; dy=...; len; if len>0 ed={dx/len,dy/len}; }
ctx.fillStyle=g? bright; ctx.beginPath(); ctx.arc(hx,hy,cell*0.46,0,2π); ctx.fill();
ctx.fillStyle=rgba white eye whites; ctx.beginPath arcs; ctx.fill(); pupils dark shifted.
```
If eyeDir from path at t=0 maybe old dir; good. Need if pos[0]==pos[1] due to growth? At progress 1 head new, second old head, length one cell. At t=0 head old, second old body, length one. Not same. If duplicate tail? Not head.
Maybe draw head scale with two small nostrils.
`drawParticles`: use screen blend? `ctx.globalCompositeOperation='lighter'` maybe. Need reset.
HTML overlays CSS: `.overlay { position:absolute; inset:0; display:flex; align-items:center; justify-content:center; text-align:center; background:rgba(6,10,20,0.58); backdrop-filter blur(2px); opacity:0; pointer-events:none; transition:opacity .2s; border-radius: inherit; } .overlay.show{opacity:1}`. Start overlay should not block clicks? pointer-events none so stage click works. If we want button, could pointer-events auto but no. Need stage click catches. Use `cursor:pointer`. Start overlay maybe pointer-events none. Pause/over also none.
Title: large with letter-spacing. Hint. Game over score.
Need `best` update on player death even if score lower. `if (mode==='player' && score>best) best=score;`
But in doStep when eat and mode player, update best immediately? If update best while alive, best shows current if exceeds; okay. Requirement best of session. Could do that. But if user dies, already best. Do it in doStep for player. If demo, no. Good.
Need if user presses Enter when player over: restart. But initial demo any key starts. If key is Enter in demo: startPlayer. If after player over: restart. Implementation keydown:
```
const dirMap = {ArrowUp:{x:0,y:-1}, KeyW:..., ArrowDown, KeyS, ArrowLeft, KeyA, ArrowRight, KeyD};
window.addEventListener('keydown', e=>{
const code=e.code;
if(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight','Space','Enter','KeyP'].includes(code)) e.preventDefault();
const d=dirMap[code];
if(mode==='demo'){ startPlayer(); if(d) pushDir(d); return; }
if(phase==='playing'){ if(d) pushDir(d); }
if(code==='KeyP'){ togglePause(); return; }
if(code==='Space' || code==='Enter'){ if(phase==='over' || phase==='paused') startPlayer(); else if(phase==='playing'){ // maybe restart? requirement says space/enter restarts. Could restart if playing? But pressing Space while playing might restart inadvertently. But maybe okay? It says Game over... Space or Enter restarts. So only when over.
}}
});
```
Need P pause even when paused. If phase over and P? not. If phase playing and P. If phase paused and P. Good.
What about P while demo? mode demo branch startPlayer then return before P, so P starts but not pause. Fine.
What about arrow after gameover? Requirement Space/Enter restarts; maybe arrow keys should not restart but maybe user expects? Could allow any key after gameover? But might accidental. Space/Enter required. We can also allow `d` direction? Not. Keep.
But if phase='over' mode player and user presses arrow, nothing. Could if d push? no.
Potential issue `startPlayer` from demo on every key. If keydown repeats (holding key) after start? Mode changes so subsequent arrow moves. Good.
Click: If stage click during player over restart. If paused resume. If demo start. If playing ignore.
Need `ResizeObserver` but may trigger before fonts? fine. `fit`:
```
function fit(){ const rect=canvas.getBoundingClientRect(); const css=Math.max(200, Math.round(rect.width)); const dpr=Math.max(1, Math.min(3, window.devicePixelRatio||1)); if(canvas.width !== Math.round(css*dpr)... ) ... ctx.setTransform(dpr,0,0,dpr,0,0); cell=css/GRID; }
```
Need `canvas.style.width` is 100% of stage. `rect.height` should equal due aspect. Use `css=rect.width`. If stage width includes border? Canvas inside stage maybe. `canvas` width 100%; if CSS aspect on stage. Good.
Could use `window.addEventListener('resize', fit)`, and requestAnimationFrame loop calls fit occasionally? Use ResizeObserver if available. `new ResizeObserver(fit).observe(stage)`.
Need handle if `canvas.getContext('2d')` not? okay.
Let's consider visual size: On 1280x800, app header + footer ~140px, stage width `min(92vw, calc(100svh - 180px), 620px)`. If height 800-180=620, width 620. Good. On 375x800, min(345,620,620)=345. Header wraps? Use clamp font sizes. Footer hint may wrap. No horizontal scrollbar. `body` overflow-x hidden, overflow-y hidden? Could be hidden if too small. Maybe `overflow:hidden` but if small phone landscape height 400? Stage calc(100svh - 180)=220; still fits. If 375x300? not specified. Use `body { overflow: auto }`? But no horizontal. Better `overflow: hidden` to avoid scroll; if vertical content too large, maybe clipped. Need adapt to 375 wide, likely height >=600. Use `min-height: 100svh; display:flex; align-items:center; justify-content:center;`. If too small, might overflow. Could `overflow-y:auto` to be safe but vertical scroll okay? "No horizontal scrollbar" only. Use `overflow-x:hidden; overflow-y:auto`. Stage max size. Good.
Need not use external fonts.
Let's think about "Quick successive key presses must both be honored" - queue max 2 works. But if stepDuration long (125ms) and presses more than 2 within one step, last ignored. "Both" means if quick two presses, both honored. Max 2 enough. Could queue max 4 but no need. If first direction equals current? ignored. If second direction same as first? ignored. If user presses up then down quickly while moving right: up queued; down reverse of up ignored. So both? One honored, one not because reversal. That's correct. If press up then right, both.
But if queue has [up] and user presses right before next step, queue [up,right]. At next step current dir set up. At following step right. Good. What if user presses up then left then down before any step? With max 2 queue [up,left], down ignored? Down reverse of left ignored. Good.
Need ensure if dir changes at step to up, and queue has left, left is not reverse of up. Good.
Smooth movement with input queue: If pressing up while moving right but next step hasn't happened, head will continue moving right until next grid boundary then turn up. That's standard. If user expects immediate? It's okay. Could change `dir` immediately for visual bend? No.
Need ensure "smooth movement" not just interpolation. Good.
Potential bug: At step, `doStep` processes demo AI and sets `dir`. Then next step uses new `dir`. If AI choose dir that would collide? `chooseAIDir` uses current `dir` to prevent reverse and checks collisions. Good. If AI returns null, snake continues same dir; if that collides, game over. But `willCollide` in choose would have found current dir unsafe if not reverse? Current dir might be reverse? no. If all candidates unsafe, current dir likely unsafe too? If snake length 2? Maybe current dir blocked by body? It would return null and doStep same dir causes death. okay.
Need if AI candidate uses `willCollide` with current snake, but if `dir` reverse? skip. Good.
Potential bug with `fromSnake` after non-eat and `snake` length: For length 3, old fromSnake length 3. At t=1, positions target snake. Good. But path line between interpolated points for a body segment moving from old body to old head, and head to new head. If t=0.5, positions may have overlapping? Not too bad. At sharp turn, segment positions diagonal. Good.
Could make snake path thickness and eyes with head at current. If progress not integer, head path from old to new; eyes direction from head - pos[1] may not align with logical dir during straight? For straight, pos[1] is behind head; vector dir. Good.
Need draw board under canvas with CSS border. Maybe add scanline vignette. Could use `box-shadow` on stage.
Let's write final HTML. Need ensure JS no syntax errors.
Detailed code:
```
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Neon Snake</title>
<style>
:root{...}
*{box-sizing:border-box}
html,body{margin:0;padding:0;overflow-x:hidden;background:#071020;}
body{font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;color:#e2e8f0; min-height:100svh; display:flex; align-items:center; justify-content:center; padding:12px;}
#app{width:min(92vw, 780px); display:flex; flex-direction:column; align-items:center; gap:10px;}
#hud{width:100%; display:flex; justify-content:space-between; align-items:center; gap:8px;}
.pill{...}
.title{font-weight:800; letter-spacing:.18em; ...}
#stage{position:relative; width:min(92vw, calc(100svh - 165px), 620px); aspect-ratio:1/1; border-radius:22px; box-shadow:0 20px 60px rgba(0,0,0,.45), inset?; overflow:hidden; border:1px solid rgba(148,163,184,.18); background:#08101f;}
canvas{display:block; width:100%; height:100%; touch-action:none;}
.overlay{position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; text-align:center; gap:8px; padding:24px; background:rgba(4,9,20,.62); backdrop-filter:blur(4px); opacity:0; pointer-events:none; transition:opacity .22s ease; }
.overlay.show{opacity:1}
...
@media (max-width:560px){ title font, footer smaller }
</style>
</head>
<body>
...
<script>
...
</script>
</body>
</html>
```
Need footer maybe controls. `#hint` with arrows/WASD, P pause, Space restart.
Overlay content:
```
<div id="startOverlay" class="overlay show">
<div class="overlay-title">Click or press a key to start</div>
<div class="overlay-sub">Neon Snake — AI demo running</div>
</div>
<div id="pauseOverlay" class="overlay"><div class="overlay-title">Paused</div><div class="overlay-sub">Press P to resume</div></div>
<div id="gameOverOverlay" class="overlay"><div class="overlay-title">Game Over</div><div id="finalScore" class="big"></div><div id="finalBest" class="overlay-sub"></div><div class="overlay-hint">Press Space or Enter to restart</div></div>
```
Need startOverlay initially show. JS updates.
Need update final score text when over.
JS:
```
const el = id => document.getElementById(id);
const scoreEl=el('score'), bestEl=el('best'), ...
```
Careful id `best` maybe. Use `currentScore`, `bestScore`.
Constants.
Let's code in final with all functions.
Potential issue `100svh` support Chrome yes. Could fallback `100vh`. Use `calc(100vh - 165px)` maybe. `svh` good. If no, maybe fallback? Can write `height: min(92vw, calc(100vh - 165px), 620px);` safer. Use `vh` in JS? fine. `100svh` modern. For iframe latest Chrome. okay.
Need no external network.
Let's refine drawing board. To improve performance, maybe draw board to offscreen canvas on resize. Could implement `boardCanvas` and pattern. But not necessary. But 3 sec screenshot with animation. Board checker using 24x24 fill rects; okay. Could draw each cell with subtle gradient? Might be heavy but still fine. Let's optimize: use offscreen `board` canvas created on fit, draw once. Then `render` draws `ctx.drawImage(board,0,0,cssSize,cssSize)`. Nice. Need boardCanvas size cssSize*dpr. But after transform, we can draw at cssSize. Implement `makeBoard()` on resize:
```
let boardCanvas = document.createElement('canvas');
function makeBoard(){ const size=canvas.width; const bctx=boardCanvas.getContext('2d'); bctx.setTransform(dpr,0,0,dpr,0,0); ... draw checker lines; boardCanvas.width=size; }
```
But if draw at 0,0 with CSS units after setTransform on main. `ctx.setTransform(dpr...)` then `ctx.drawImage(boardCanvas,0,0,cssSize,cssSize)`. Good. BoardCanvas width cssSize*dpr, height. Draw background gradient, checker, grid lines. Need avoid scaling artifacts. Fine.
Alternatively draw each frame no boardCanvas. Simpler but boardCanvas polish. Let's implement offscreen. On fit: set main canvas size, cell = cssSize/GRID, makeBoard(). But makeBoard uses cssSize and dpr.
Board drawing:
```
function makeBoard(){
const b=boardCanvas, bx=b.getContext('2d');
b.width=canvas.width; b.height=canvas.height;
bx.setTransform(dpr,0,0,dpr,0,0);
bx.clearRect(0,0,cssSize,cssSize);
const g=bx.createLinearGradient(0,0,cssSize,cssSize); g.addColorStop(0,'#0b1324'); g.addColorStop(1,'#06101c'); bx.fillStyle=g; bx.fillRect(0,0,cssSize,cssSize);
for y,x: bx.fillStyle=((x+y)&1)?'rgba(255,255,255,.018)':'rgba(0,0,0,.08)'; bx.fillRect(x*cell,y*cell,cell,cell);
bx.strokeStyle='rgba(148,163,184,.055)'; bx.lineWidth=1;
for i=0..GRID: line x=i*cell; ...
// vignette radial
const v=bx.createRadialGradient(center, center, cssSize*0.25, center, center, cssSize*0.7); v.addColorStop(0,'rgba(0,0,0,0)'); v.addColorStop(1,'rgba(0,0,0,.35)'); bx.fillStyle=v; bx.fillRect(...)
}
```
Need if cssSize not integer, cell fractional. draw lines with translate(0.5,0.5)? Since transform dpr, crisp maybe. But okay. Use `bx.lineWidth=1` and lines at integer maybe not. Board not important.
Render board: `ctx.save(); ctx.setTransform(dpr,0,0,dpr,0,0); ctx.drawImage(boardCanvas, 0, 0, cssSize, cssSize); ctx.restore();` Main ctx transform already set. Actually if main transform set, drawImage with width cssSize scales correctly. Good.
Need `fit` sets main transform each time.
Particles with grid coords. Draw:
```
ctx.globalCompositeOperation='lighter';
for p: const a=1-p.life/p.max; ctx.globalAlpha=a*a; ctx.fillStyle=p.color; ctx.beginPath(); ctx.arc(p.x*cell,p.y*cell,p.size*cell,0,tau); ctx.fill();
ctx.globalAlpha=1; ctx.globalCompositeOperation='source-over';
```
Use p.size in cell units (0.05-0.15). Could be tiny. For 24 cells, cell ~25px, p.size*cell ~2.5px; okay. Maybe 0.08-0.2 ->2-5px.
`spawnParticles` uses cell? p.size = 0.08 + Math.random()*0.14.
Food: Draw pulse with now (performance time). If phase over? food still. If food.x<0 return. Need maybe food on old position if eaten? `spawnFood` after eating updates food before rendering next frames. Good.
Snake drawing if phase demo and just reset at t=0: from=snake positions same? Actually initial fromSnake same. If timeSinceStep 0 progress 0; path at old positions. Good.
Need draw path using points in pixels. Add slight offset to center: positions in grid units where `x+0.5`. In interpolation we do `f.x + 0.5` and `s.x+0.5`. But if lerp cell centers: use positions as grid unit center. `x = lerp(f.x, s.x, t) + 0.5`. Good.
Potential bug with gradient stroke: If pos length >1, start/end coordinates may be zero if fromSnake length mismatch? Use `f = fromSnake[i] ?? fromSnake[fromSnake.length-1]`. Should match but safe.
Need draw body with round caps. If path length >1 but two consecutive points same (growth tail duplicate), stroke okay.
Let's implement `lerp = (a,b,t)=>a+(b-a)*t`.
Need `round` helper.
Pause behavior: When paused, overlay show; timeSinceStep frozen; particles still update? Could freeze particles too? Requirement just pause game. We can not update particles when paused? Maybe update? It's okay either. To be consistent, only update if phase playing/demo. But visual freeze maybe nice. In loop:
```
if(phase==='playing' || phase==='demo') { step; particles update; }
else { particles update maybe slow? }
```
Could update particles even paused? No need. But if game over, particles continue? Maybe stop except fade? Could continue fade for polish. Use update particles if not paused? If over, let particles finish. If paused, freeze? We can update always except paused? Simpler update always.
But if paused mid-step, timeSinceStep frozen; progress frozen. Good.
`loop`:
```
function loop(now){
requestAnimationFrame(loop);
const dtMs = Math.min(100, now - last); last=now;
const dt=dtMs/1000;
if(phase==='playing' || phase==='demo'){
timeSinceStep += dtMs;
while(timeSinceStep >= stepDuration){
timeSinceStep -= stepDuration;
doStep();
if(!(phase==='playing' || phase==='demo')) break;
}
} else if(phase==='over' && mode==='demo'){
if(now >= demoRestartAt) newGame(true);
}
updateParticles(dt);
render(now);
}
```
If doStep sets phase over player, loop stops stepping; timeSinceStep maybe small but no matter. If endGame sets `timeSinceStep=stepDuration` to show final position? Could. In doStep collision, we might want snake remains at previous integer positions (progress 0 after subtraction). Since timeSinceStep after while subtract could be leftover 0..step. If collision at step, no movement; if progress leftover from previous step? Suppose step interval 125, collision occurs exactly at step. After subtraction maybe timeSinceStep 0-10. render positions with t 0 => old positions. Good. But could set timeSinceStep=0 in endGame to freeze old positions. Fine. Add in endGame `timeSinceStep=0`.
Need if stepDuration changes after eating, timeSinceStep maybe > new stepDuration, while loop could immediately do next step. Good.
`updateHUD` call in loop? Score changes in doStep. Use updateHUD on doStep and newGame. best update maybe immediate. Fine.
Potential issue demo restart while start overlay visible. If user clicks during demo over, startPlayer. `stage click` if mode demo -> startPlayer regardless phase. Good.
Now AI BFS details:
```
function key(x,y){return y*GRID+x;}
function evaluateState(body, start) { ... }
```
Need `body` as array of {x,y}. `start` {x,y}. It will count reachable from start using blocked body except start. But if start at old head? For candidate nextHead. Good. It uses current global `food`.
Distance if start at food? impossible? candidate eat sets start food. We allow dist 0. evaluate start food -> if start is food and blocked deleted start, BFS returns dist=0, reachable includes surrounding. Good.
Implementation:
```
function evaluateState(body, start){
const seen = new Uint8Array(GRID*GRID)? To speed. But each eval create array. Use Int32Array with stamp? Could simple Set. Grid small. For clarity use arrays:
const blocked = new Uint8Array(GRID*GRID); body.forEach(...); blocked[startKey]=0;
const distArr = new Int32Array(GRID*GRID).fill(-1); distArr[startKey]=0;
let q=[startKey], head=0, reachable=1, dist=Infinity;
while(head<q.length){ k=q[head++]; x=k%GRID; y=Math.floor(k/GRID); d=distArr[k]; if(x===food.x&&y===food.y && d<dist) dist=d; for neighbors: nk; if in bounds and !blocked[nk] and distArr[nk]<0: distArr[nk]=d+1; reachable++; q.push(nk); }
return {reachable, dist: dist===Infinity ? 99999 : dist};
}
```
If `food.x=-1` on no food, not found. But if no food endgame. Fine.
Candidate `info.dist < 10000`. Use 99999. Score tuning:
```
let score = info.reachable * 16;
if(info.dist !== 99999) score += 1000 - info.dist;
if(eats) score += 500;
score += Math.random()*8;
```
If full board reachable 576*16=9216. distance max ~46, 1000 - dist ~954. So reachable dominates. Maybe AI avoids eating if path small? It still chooses path if reachable comparable. If a short path leads to area 20 -> 320+1000=1320, other safe area 100 no path ->1600, so may avoid food to stay safe. That's fine but might starve? It will eventually choose food if safe. Could increase distance weight: `10000 - dist*10`, but reachable weight maybe `10`. Let's tune: `reachable*12` + `dist finite? 5000 - dist*10`. Full board open 6912. Food distance 20 ->4800. So open still dominates. Use `reachable*5` + `dist finite? 5000 - dist*10` + eats 1000. Full open no path 2880; path short 4800 -> choose food. Trap path reachable 10 ->4850, open 2880 -> might choose trap. Need avoid trap: require reachable after move > snake.length? Add penalty if reachable < body.length? Could.
Maybe score = (reachable * 8) + (distFinite ? 3000 - dist*12 : 0) + (reachable < snake.length*1.5 ? -5000:0). For demo. Good.
Let's set:
```
let score = info.reachable * 8;
if(info.dist !== 99999) score += 4000 - info.dist * 8;
if(eats) score += 600;
if(info.reachable < snake.length + 4) score -= 3000;
if(info.reachable < 10) score -= 5000;
```
This balances. For short snake length 3, reachable >7 no penalty. Full open 4608, distance 4000-160=3840; path chosen. If candidate open area 500 no path score4000? reachable*8=4000; path short 3840+400? tie. okay.
Maybe add center bias.
Could instead choose candidate with direct path if exists, else safety. Simpler and more food oriented. Let's implement:
- Generate safe dirs with immediate check.
- For each dir, compute `info`.
- If any `eats`, prefer eat only if reachable not too small? If eat reachable > length+2 else maybe avoid? But game over maybe. Choose max.
- Score = `(info.dist!==99999 ? 10000 - info.dist*20 : 0) + info.reachable*10 + (eats?500:0) + random`. This prioritizes distance moderately. Full open no path reachable576 ->5760; path dist20 ->9600 + maybe. Good. If trap dist5 reachable10 ->9900+100-? Need penalty. Add `reachable < snake.length? -10000`. Let's choose:
```
if (info.reachable < snake.length + 3) score -= 8000;
if (info.dist < 100) score += 10000 - info.dist*20;
score += info.reachable*12;
if(eats) score += 1000;
```
This should find food while avoid small spaces.
Need ensure `reachable` counts cells including start but body length counts head+body; If reachable less than snake length means trapped. Good.
`chooseAIDir` tie random by adding `Math.random()*5`.
Could AI path using `evaluate` which BFS distance from next head to food with next body blocked. If next body includes tail not moved? For non-eat simulate removes tail, good. For eat simulate keeps tail, good. So distance accurate for next state. Good.
Now input direction queue and AI. If mode demo, no queue. `dirQueue=[]` after newGame. Good.
Potential bug: If user clicks canvas after game over player, `stage.addEventListener('click')`. But overlay pointer-events none so click on overlay passes? If overlay has pointer-events none, yes stage receives. Good.
Need maybe add button? No.
`preventDefault` for arrows/space only when not in input (no inputs). Good. Could prevent F5? no.
`document.addEventListener('keydown', handler, {passive:false})`. Need if iframe focus after click. We show message.
Now exact final code.
Double-check syntax.
CSS `backdrop-filter` maybe supported. Good.
JS `new ResizeObserver` if available; otherwise resize event.
```
if('ResizeObserver' in window){ new ResizeObserver(()=>fit()).observe(stage); }
window.addEventListener('resize', fit);
```
`fit` could be called before boardCanvas defined. Define `const board = document.createElement('canvas')`.
`makeBoard` uses `ctxMain`? no.
Need draw `ctx.drawImage(board, 0,0, cssSize, cssSize)`. If board width/height set to main canvas width; when transform dpr, drawing at cssSize works.
Potential issue after `fit`, `cssSize` could be 0 if hidden. But stage visible. Use Math.max(1, Math.floor(rect.width)). If 0, set 1.
Let's detail code:
```
function fit() {
const rect = canvas.getBoundingClientRect();
const w = Math.max(1, Math.floor(rect.width));
const h = Math.max(1, Math.floor(rect.height));
// use min to be safe? rect width and height equal due aspect. Use w.
cssSize = w;
dpr = Math.min(window.devicePixelRatio || 1, 2.5);
canvas.width = Math.round(cssSize * dpr);
canvas.height = Math.round(cssSize * dpr);
ctx.setTransform(dpr,0,0,dpr,0,0);
cell = cssSize / GRID;
makeBoard();
}
```
If stage aspect ratio not applied? canvas height maybe same. Could use `cssSize = Math.min(w, Math.max(1,Math.floor(h)));`.
`makeBoard`:
```
function makeBoard(){
board.width=canvas.width; board.height=canvas.height;
const bx=board.getContext('2d');
bx.setTransform(dpr,0,0,dpr,0,0);
bx.clearRect(0,0,cssSize,cssSize);
...
}
```
Need `board.getContext('2d')` each time. okay.
`drawBoard` uses `ctx.drawImage(board, 0, 0, cssSize, cssSize);`
`drawFood`: if no food, return. `now` passed. Use `const px=(food.x+0.5)*cell; py=...`;
```
const pulse = Math.sin(now/280)*0.06+1;
const r=cell*0.36*pulse;
const glow=ctx.createRadialGradient(px,py,r*0.1,px,py,r*2.1);
glow.addColorStop(0,'rgba(255,107,107,.5)'); glow.addColorStop(1,'rgba(255,107,107,0)');
ctx.fillStyle=glow; ctx.beginPath(); ctx.arc(px,py,r*2.1,0,tau); ctx.fill();
const bodyGrad=ctx.createRadialGradient(px-r*0.25,py-r*0.3,r*0.08,px,py,r);
bodyGrad.addColorStop(0,'#fff1f2'); bodyGrad.addColorStop(0.25,'#fecdd3'); bodyGrad.addColorStop(0.7,'#f43f5e'); bodyGrad.addColorStop(1,'#9f1239');
ctx.fillStyle=bodyGrad; ctx.beginPath(); ctx.arc(px,py,r,0,tau); ctx.fill();
ctx.fillStyle='rgba(255,255,255,.55)'; ctx.beginPath(); ctx.arc(px-r*0.28,py-r*0.3,r*0.17,0,tau); ctx.fill();
```
Leaf? optional:
```
ctx.fillStyle='#22c55e'; ctx.beginPath(); ctx.ellipse(px+r*0.1,py-r*0.75,r*0.25,r*0.14,-0.7,0,tau); ctx.fill();
```
Good.
`drawSnake`: Need progress. If phase over and mode demo? use frozen. Use:
```
const t = Math.max(0, Math.min(1, timeSinceStep / stepDuration));
```
If timeSinceStep > stepDuration (possible if tab inactive and loop clamp? while handles before render; if game over, set 0). Good.
`pos` length. For each i:
```
const f = fromSnake[i] || fromSnake[fromSnake.length-1] || snake[i];
return {x: lerp(f.x, s.x, t)+0.5, y: ...};
```
Need if `snake.length` 0? no.
`if(pos.length){}`.
Outer stroke: Use `ctx.lineCap='round'; ctx.lineJoin='round';`
```
ctx.save();
ctx.beginPath();
ctx.moveTo(pos[0].x*cell, pos[0].y*cell);
for(let i=1;i<pos.length;i++) ctx.lineTo(pos[i].x*cell,pos[i].y*cell);
if(pos.length===1){ // just arc }
ctx.strokeStyle='rgba(3,28,16,0.85)'; ctx.lineWidth=cell*0.86; ctx.stroke();
const gradient=ctx.createLinearGradient(pos[0].x*cell,pos[0].y*cell, pos[pos.length-1].x*cell,pos[pos.length-1].y*cell);
gradient.addColorStop(0,'#86efac'); gradient.addColorStop(0.4,'#22c55e'); gradient.addColorStop(1,'#047857');
ctx.strokeStyle=gradient; ctx.lineWidth=cell*0.7; ctx.stroke();
// sheen
ctx.strokeStyle='rgba(255,255,255,0.12)'; ctx.lineWidth=cell*0.18; ctx.stroke();
ctx.restore();
```
If pos length 1, moveTo then stroke no visible line? lineWidth round cap draws point? Some browsers may not; draw arc fallback. But snake length >1. Good.
Head:
```
const hx=pos[0].x*cell, hy=...
let ed=dir; if(pos.length>1){ dx=pos[0].x-pos[1].x; dy=...; len=Math.hypot(dx,dy); if(len>0.0001) ed={x:dx/len,y:dy/len}; }
const headR=cell*0.46;
ctx.save(); ctx.shadowColor='rgba(34,197,94,.45)'; ctx.shadowBlur=cell*0.5; ctx.fillStyle='#86efac'; ctx.beginPath(); ctx.arc(hx,hy,headR,0,tau); ctx.fill(); ctx.restore();
// dark eye sockets
const perp={x:-ed.y,y:ed.x};
const eyeOffset=cell*0.16; const eyeForward=cell*0.12; const eyeR=cell*0.12;
ctx.fillStyle='#f8fafc'; draw two.
ctx.fillStyle='#052e16'; pupils forward cell*0.06; radius cell*0.055.
// Maybe mouth? skip.
```
If eyeR small, okay. Use helper `circle(x,y,r)`.
Draw particles after snake. But particles at food position before moving? Good.
`drawDeathTint`: if phase over, flash? In overlay HTML dims. Could draw red vignette on canvas when over. Maybe:
```
if(phase==='over'){ const flash = Math.max(0,1-(now-overAt)/500); ctx.fillStyle=`rgba(244,63,94,${0.25*flash})`; ctx.fillRect(0,0,cssSize,cssSize); }
```
But `overAt` set on performance.now. In loop `now`. Good. Only if mode player? Demo maybe flash but start overlay already. Fine.
`spawnParticles` color maybe use food red. Could create 16.
```
const count=16+Math.floor(Math.random()*8);
for... particles.push({x:gx+0.5,y:gy+0.5,vx:...,vy:...,...})
```
`vx` cells/sec `Math.cos(angle)*(0.8+Math.random()*2.5)`.
Need `updateParticles` dt in seconds. If dtMs clamped 100 -> dt .1. Good.
Potential issue: `stepDuration` can be less than `timeSinceStep` after pause? When pause, timeSinceStep frozen. resume no immediate step unless enough time. Good.
Now overlay update:
```
function updateOverlays(){
startOverlay.classList.toggle('show', mode==='demo');
pauseOverlay.classList.toggle('show', phase==='paused');
gameOverOverlay.classList.toggle('show', mode==='player' && phase==='over');
if(mode==='player'&&phase==='over'){ finalScore.textContent='Score: '+score; finalBest.textContent='Best: '+best; }
}
```
Need if demo phase over, startOverlay show. Good.
If player pause after eating, best may already current. HUD update.
`togglePause`:
```
if(mode!=='player') return; if(phase==='playing') phase='paused'; else if(phase==='paused') phase='playing'; updateOverlays();
```
If over, P no. Good.
`startPlayer`:
```
newGame(false); updateOverlays(); // phase playing
```
But `newGame` already updateOverlays. Good.
`pushDir`:
```
function pushDir(d){ if(!d) return; const last = dirQueue.length ? dirQueue[dirQueue.length-1] : dir; if(last.x === d.x && last.y === d.y) return; if(last.x === -d.x && last.y === -d.y) return; if(dirQueue.length >=2) { // if queue full maybe replace last if different? Requirement quick two both honored. Could max 2. If third, ignore or replace last? Replace might honor latest but lose second. Better if full ignore? For quick successive, 2 enough. But if queue [up,right] and press left? left vs right reverse? ignore. If press up? same last? ignore. If full and press down? not reverse of right? queue full ignored. Could if queue length 2, replace last if valid and not same? That would honor latest but break previous? For 2 quick both honored, if third maybe ignore. Good.
} else dirQueue.push(d);
}
```
But if queue length 2 and new direction valid relative to last, maybe we could set queue[1]=d? This would still honor first and latest but lose second. Not required. Maybe keep max 3? To honor quick successive more. Set max 3. But if queue too long, reversal check relative to last. Fine. Use max 4? But if user presses many, snake follows path; if too long can create self collision due to planned turns? The queue could cause snake to turn back after 2 steps into itself? It checks only reverse, not future collision. That's normal. Max 2 or 3. Let's use max 3 so two successive definitely. If full, maybe if valid replace last? Not needed. Use max 3 and ignore fourth.
Actually if current right, press up then left: queue [up,left] (2). Press down: down vs left reverse, ignored. Good. If press up, left, down: queue [up,left], down reverse left ignored. If press up, right, down: queue [up,right], down not reverse right? down valid, queue [up,right,down] max3. At steps right then up? Wait queue sequence applied after current dir: step1 up, step2 right, step3 down. This makes a U-turn that could self-collide? It's allowed if snake moves around. Could be too many. Max 2 enough for classic. But requirement quick successive both honored. If two, max2 enough. If queue length=2 and new key is a valid continuation, maybe replace last? That would honor latest but not second. Better max3. Use max3. Good.
`doStep` for player: if dirQueue length >0 shift. If queue empty, dir remains. Good.
Potential bug: If press direction same as current but queue has a turn, e.g moving right, press up (queue up), then press right (queue up,right). Good. If press right again while queue empty same as current ignored. Good.
`chooseAIDir`: It uses `ALL_DIRS` maybe random order. Need not use queue.
Let's implement `ALL_DIRS = [{x:1,y:0}, {x:-1,y:0}, {x:0,y:1}, {x:0,y:-1}]`.
Now check code for `simulate` uses `snake.length`; if `snake` empty no. Good.
Potential issue `willCollide` for AI candidate after `dir` reverse: uses current snake. If candidate eats, check tail. Good. If candidate not eats, skip tail. But what about moving into the cell of the body segment that will be removed? tail. Good.
`doStep` after eating updates fromSnake and snake before spawnFood. `spawnFood` uses new snake. Good.
Score display: `currentScore.textContent = score; bestScore.textContent = best;` maybe `pad`. Use `String(score)`. Add `best` session. If mode demo best maybe 0. If player best current.
Need final answer exactly one fenced code block. No extra text.
Let's maybe include subtle win text if no food. In `spawnFood`:
```
if(empty.length===0){ food={x:-1,y:-1}; gameOverTitle.textContent='You Win!'; phase='over'; ... return; } else gameOverTitle.textContent='Game Over';
```
Need handle `endGame` for win. But requirement only game over wall/self. Not necessary. But avoid spawn error. Could if no empty, `endGame(true)` with title. Implement simple:
```
let win=false;
function endGame(isWin=false){ ... finalTitle.textContent = isWin ? 'You Win!' : 'Game Over'; }
function spawnFood(){ ... if(!empty.length){ food={x:-1,y:-1}; endGame(true); return; } ... }
```
`doStep` after eating calls spawnFood; if spawnFood ends game, we already moved snake to fill board. Should set phase over. Need avoid further collision? okay. But `endGame` may be called twice? if no empty. We can in doStep after eating, if phase already over, skip? Not needed.
But `spawnFood` called from newGame when snake length 3, empty plenty. If no empty after eating, phase over. Good.
Need `endGame` if already over return?
```
function endGame(isWin=false){ if(phase==='over') return; ... }
```
For win, mode demo restart; player update best.
Potential issue `doStep` after eating: `spawnFood()` may set phase='over'; then loop while checks `if(!(phase==='playing'||phase==='demo')) break;`. Good. But it might set `timeSinceStep=0`.
Now code for keydown. Need use `e.repeat`? Could allow repeat? If holding arrow, repeated queue would fill. Usually ignore keydown repeat to avoid queue spam. But requirement quick successive, not hold. Add `if(e.repeat) return`? But if holding, maybe continuous turn? Not needed. Could ignore repeat. But after demo start, holding arrow? no. Add `if(e.repeat && code!=='P'?)`? Simpler ignore all repeats? If user holds space, don't restart repeatedly. Good: `if (e.repeat) return;` at top. But if demo mode, repeat ignored until first keydown. Good.
Need if focus? Keydown on window.
`dirMap`:
```
const DIR_MAP = {ArrowUp:{x:0,y:-1}, KeyW:{x:0,y:-1}, ...};
```
Handling key:
```
if(e.code==='KeyP'){ ... } // Should P pause during playing, resume paused. But if mode demo, first branch start player before P. Good.
```
Order: prevent default. If mode demo start. Then if P toggle. Then directions. Then space/enter. If over/paused and direction key, maybe restart? Requirement says Space/Enter restart. Could also allow any key? Might cause accidental with arrow if user over. Keep space/enter.
Need after `startPlayer()` from demo, if code is P, it will return so no pause. Good.
Click on stage: if mode demo start. Else if phase paused resume. Else if phase over start. Good. Could `touchstart`? click works.
Need mobile controls? Not required. But touch swipe could be nice? Could implement simple swipe? Not necessary but maybe polish. But keyboard required. Since iframe click receives keyboard after click. On mobile no keyboard. Could add swipe controls for touch? It won't hurt. But maybe no. We can add touchstart/touchend to stage to swipe directions. But requirement quick key presses? No. Could add to improve mobile. Need avoid interfering with click. Add `let touchStart=null;` on touchstart record, touchend calculate swipe; if mode demo start; else if over restart? Actually touch swipe after over? Could. But overlay start click. Might add complexity but okay.
Touch:
```
let touch=null;
stage.addEventListener('touchstart', e=>{ if(e.touches.length){ touch={x:e.touches[0].clientX,y:e.touches[0].clientY}; } }, {passive:true});
stage.addEventListener('touchend', e=>{ if(!touch) return; const t=e.changedTouches[0]; dx=...; if(Math.hypot(dx,dy)>24){ if(mode demo start; else if phase over start; else push dir; touch=null; } });
```
Need prevent default? passive true maybe page scroll; body overflow hidden. okay. But click also fires after touchend, could start twice? If mode demo, start on touch; click may start again? If phase player, click ignores. Fine. But on demo, touchend starts; click later mode not demo, phase playing -> ignores. Good.
Potential issue on desktop click, no touch.
Now final code should be within one `<html>`. Need ensure no text outside code block.
Let's write final code mentally and check syntax.
Potential variable conflicts: `score` variable and `scoreEl`. Use `let score = 0;` and `const scoreEl`. In `doStep`, `score += 10`. Good. `updateHUD` sets `scoreEl.textContent = score; bestEl.textContent = best;`
`bestEl` id `best`. `scoreEl` id `score`.
`finalScoreEl.textContent = 'Score: ' + score; finalBestEl.textContent = 'Best: ' + best;`
`currentBest`? okay.
`makeBoard` uses `bx.strokeStyle`; Need draw lines from 0 to cssSize inclusive. Lines at i*cell. For i=0..GRID: vertical. Since transform dpr, maybe blurry; okay. Could use `bx.lineWidth=1`.
`fit`: Need call after DOM load. Script at end of body.
`requestAnimationFrame` loop: initialize last after newGame.
```
let last=performance.now();
function loop(now){ ... last=now; }
requestAnimationFrame(loop);
```
Set last in start? `last=performance.now()` before rAF.
If tab inactive long, dtMs clamped 100. But `timeSinceStep += dtMs` clamped; if tab inactive, won't catch up many steps. Good.
Potential problem with `while(timeSinceStep >= stepDuration)` if `stepDuration` is 0? no.
`newGame`: after spawnFood, if phase over? no.
`spawnFood`: uses `snake`. It can be called when phase already over? not. If no empty, `endGame(true)`. But if `endGame` mode player update best; if spawnFood after eat sets phase over, okay. Need ensure `food.x` maybe -1 for drawing. Good.
`willCollide`: If `snake.length===0` no. Good.
`simulate`: If `keep = eats ? snake.length : snake.length - 1;` For non-eat length 3, keep 2, next [newHead, oldHead] length 3? Wait old length 3, remove tail means keep first 2 old positions. `newSnake=[head,new, old[0], old[1]]` length 3. Yes. For eat keep 3, new length 4. Good.
`evaluateState`: If `body` contains start and food? If eating, body includes start at food. We set blocked[startKey]=0; dist 0. Good. Count reachable includes start and all open. If food start, dist 0. Good.
`chooseAIDir`: For each d, `willCollide` uses current snake and eats. Then simulate. Need for immediate safety: if `nx==head.x`? dirs no zero.
Potential issue `willCollide` skip tail if not eats. But for candidate not eat, moving into tail safe. However simulate removes tail. Good. If candidate eats, moving into tail collision; willCollide includes tail. Good.
Score:
```
const info=evaluateState(nextBody,{x:nx,y:ny});
let sc = info.reachable * 9;
if(info.dist !== 99999) sc += 4000 - info.dist * 12;
if(eats) sc += 500;
if(info.reachable < nextBody.length + 2) sc -= 6000;
sc += (GRID - Math.hypot(nx-GRID/2, ny-GRID/2))*0.4? maybe.
sc += Math.random()*4;
```
But if no path, dist 99999 -> no path bonus. Good. If path but reachable low, penalty.
Could choose direction with current dir if no better? sort max. Good.
`evaluateState` allocates arrays per candidate. Each step 4 candidates -> fine. But if stepDuration 125 and 8 sec, okay.
Potential issue if AI chooses to not eat because safety penalty, demo may stall? It may avoid tail? But eventually. If all options have low reachable, still choose max. If current dir is safe but low path? It may choose side. Good.
Now CSS: Need ensure overlays border-radius matches stage. `#stage` has overflow hidden, border-radius. overlay border-radius? inherits? Set `border-radius: inherit`. Good.
`body` background and app width. `#app` maybe max width 780; stage width min. HUD pills. Use `gap`.
`#hint`: font-size clamp. Use spans with kbd.
Let's draft final code now. Ensure no missing semicolons.
Need no text after code block. final only code block.
Double-check `ctx.createLinearGradient` if head/tail coords same? If all zero? It returns gradient with same start/end; okay? Could throw? No. But if cssSize=0? no.
`drawSnake`: For path length >1, after outer stroke, gradient uses pos[0], pos[last]. If same, maybe okay. But if snake length 1? not.
`circle` helper? Use local.
```
function circle(x,y,r){ ctx.beginPath(); ctx.arc(x,y,r,0,Math.PI*2); }
```
Need use `ctx.fill()` after.
`drawParticles`: If `particles` many, okay. Need `globalAlpha` reset. Use `ctx.globalCompositeOperation='lighter';`.
`updateParticles`: Need filter. Use new array:
```
particles = particles.filter(p => { ... return p.life < p.max; });
```
Could mutate. Good.
`render`: If phase over and mode demo, timeSinceStep maybe 0; draw old positions. Good.
`fit`: If canvas not laid out, cssSize=0; but `#stage` has CSS. okay. Need call `fit()` after DOM. Since script at bottom, layout available. But `getBoundingClientRect` might be 0 if CSS aspect-ratio unsupported? Chrome supports. Good.
Potential issue `ResizeObserver` callback calls `fit` many times; `makeBoard` sets board size to canvas width. Good.
Keyboard: Need if mode demo and code is 'Space' start. `startPlayer` newGame, then if d undefined. Good. If code is 'KeyP' start but return before toggle. Good.
`stage.addEventListener('click')` after key start: If user clicks, starts player. If user clicks while player playing, no pause. Could accidentally clicking overlay? overlay pointer-events none, stage click. Good. If user clicks while paused, resumes. Good.
Need maybe `window` keydown after click? The iframe needs click to get keyboard; our click on stage also may be outside? If user clicks body but not stage? We only keydown window. In sandbox, key events after click somewhere in iframe. Message visible. Fine. Could add click on app/document to start. Better add `window.addEventListener('click', startIfDemo)`? But if user clicks outside stage? The overlay message on stage. Requirement click start. If key events after click in iframe, clicking anywhere should start. Let's add `document.addEventListener('click', handler)` instead of stage? But if click on text? no buttons. Use `app` or `document`. Need not prevent. We can add `window.addEventListener('pointerdown', ...)`. If user clicks HUD, start too. Good. But if user clicks while player playing, ignore. Let's use `window.addEventListener('click', onClick)`. Ensure not interfering with keyboard. Good.
`onClick` if mode demo start; else if paused resume; else if over start.
But if user clicks on page after demo death while phase over mode demo, start. Good.
`stage` click maybe unnecessary. But overlay pointer-events none. Use window click. Need if clicking to focus iframe; after keydown? Good.
Potential issue after player game over, user clicks restart, then keydown space? click already restarted. Good.
Touch: If we add window click, touch tap may fire click. We can skip touch swipe? Maybe not needed. But for mobile, arrow not possible. Could add swipe via touchstart/end. But if body overflow hidden, no scroll. Add only for `stage`. Need not conflict with click. Could implement to improve. Keep optional. Ensure if demo tap, click also starts. If swipe starts and click later ignore. Good.
But code complexity. Could include swipe. Need `let touchStart=null;`
```
stage.addEventListener('touchstart', e=>{ touchStart={x:e.touches[0].clientX,y:e.touches[0].clientY}; }, {passive:true});
stage.addEventListener('touchmove', e=>{ e.preventDefault(); }, {passive:false}); // prevent scroll while swiping. But if passive false, okay. If body overflow hidden, no scroll. Maybe skip.
stage.addEventListener('touchend', e=>{ if(!touchStart)return; ... if mode demo start; else if phase over start; else if phase playing push dir; touchStart=null; });
```
But window click may also start. If on demo, touchend start, click later not demo? mode player, phase playing -> ignore. Good. On gameover, touch swipe restarts, click later mode player over? If touch swipe after over: mode not demo, phase over -> `onClick` would restart again if not already? We need set phase playing. click then restart again? Actually click after touchend will run because click synthesized; mode player phase playing (because touch restarted), so ignores. Good. On touchend if phase playing swipe direction. Good.
Potential issue if touch start and small tap without move: dx<24, we don't start; click does. Good.
Need maybe `e.preventDefault()` on touchend? Not.
Now final answer. Ensure no comments? Comments okay. Need code not too verbose? okay.
Let's assemble.