Flint (free)
by Recursivereasoning defaultClean
Running live in a sandboxed frame. Click inside it before using the keyboard.
How it went
- Total time
- 2 min 46 s
- Cost
- $0
- Output tokens
- 28,902
- Spent thinking
- 24,106 tokens
- Speed
- 175 tok/s
- Console errors
- 0
- First token after
- 586 ms
- First visible text after
- 2 min 23 s
- Prompt tokens
- 483
- Code size
- 12.2 KB
- Finish reason
- stop
- Served by
- Recursive
- Model id
recursive/flint-v1.0:free- Reasoning sent
model default- Max output
- 32,000 tokens
- Generated
- Nov 9, 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 · 415 lines
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Snake</title>
<style>
html, body { margin:0; padding:0; height:100%; background:#0b1020; color:#e5e7eb; font-family:system-ui, -apple-system, sans-serif; overflow-x:hidden; }
#app { min-height:100vh; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:12px; padding:16px; box-sizing:border-box; }
#hud { width:min(94vw, 560px); display:flex; justify-content:space-between; font-size:clamp(14px, 2.5vw, 20px); font-weight:700; letter-spacing:1px; text-transform:uppercase; }
#hud .label { opacity:0.7; font-size:12px; }
#hud .value { font-size:24px; }
#wrap { position:relative; width:min(94vw, 60vh, 560px); aspect-ratio:1/1; }
canvas { width:100%; height:100%; display:block; background:#0b1220; border-radius:16px; border:1px solid #1e293b; }
#overlay { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:12px; background:rgba(2,6,23,0.72); border-radius:16px; text-align:center; padding:16px; box-sizing:border-box; }
#overlay.hidden { display:none; }
#overlay h1 { margin:0; font-size:clamp(20px,4vw,32px); }
#overlay p { margin:0; opacity:0.8; font-size:clamp(12px,2.5vw,16px); }
#overlay .final-score { font-size:clamp(28px,6vw,44px); font-weight:800; }
</style>
</head>
<body>
<div id="app">
<div id="hud">
<div>Score <span id="scoreValue" class="value">0</span></div>
<div>Best <span id="bestValue" class="value">0</span></div>
</div>
<div id="wrap">
<canvas id="game"></canvas>
<div id="overlay">
<h1>🐍 Snake</h1>
<p id="overlayMessage">Click or press a key to start</p>
<p id="finalScore" class="final-score" style="display:none;"></p>
<p id="overlayHint">Space/Enter restart · P pause</p>
</div>
</div>
</div>
<script>
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const overlay = document.getElementById('overlay');
const overlayMessage = document.getElementById('overlayMessage');
const finalScoreEl = document.getElementById('finalScore');
const scoreValue = document.getElementById('scoreValue');
const bestValue = document.getElementById('bestValue');
const COLS = 24;
const ROWS = 24;
const tickInterval = 130;
let snake, food, prevSnake, score = 0, bestScore = 0, gameOver = false, paused = false, started = false, mode = 'ai';
let dir, inputQueue, particles;
let running = false;
function resizeCanvas() {
const w = canvas.clientWidth;
const sz = Math.max(240, Math.floor(w));
canvas.width = sz;
canvas.height = sz;
}
window.addEventListener('resize', resizeCanvas);
resizeCanvas();
function resetGame() {
snake = [{x:8,y:12},{x:7,y:12},{x:6,y:12}];
prevSnake = snake.map(p=>({...p}));
dir = {x:1,y:0};
score = 0;
gameOver = false;
paused = false;
mode = 'ai';
inputQueue = [];
particles = [];
scoreValue.textContent = '0';
bestValue.textContent = bestScore;
overlay.classList.add('hidden');
overlayMessage.style.display = 'none';
finalScoreEl.style.display = 'none';
}
function startGame() {
started = true;
running = true;
resetGame();
}
function updateBest() {
if (score > bestScore) bestScore = score;
bestValue.textContent = bestScore;
}
function spawnFood() {
let ok = false;
let f;
do {
f = {x: Math.floor(Math.random()*COLS), y: Math.floor(Math.random()*ROWS)};
ok = !snake.some(s => s.x === f.x && s.y === f.y);
} while (!ok);
food = f;
}
function endGame() {
gameOver = true;
running = false;
updateBest();
finalScoreEl.textContent = score;
finalScoreEl.style.display = 'block';
overlayMessage.style.display = 'none';
overlay.classList.remove('hidden');
}
function spawnParticles(pos, cell) {
const colors = ['#ef4444','#f59e0b','#facc15','#4ade80','#60a5fa'];
for (let i=0; i<14; i++) {
const angle = Math.random()*Math.PI*2;
const speed = 2 + Math.random()*3;
particles.push({
x: pos.x, y: pos.y,
vx: Math.cos(angle)*speed,
vy: Math.sin(angle)*speed,
life: 30,
maxLife: 30,
size: cell*0.08 + Math.random()*cell*0.06,
color: colors[Math.floor(Math.random()*colors.length)]
});
}
}
function updateParticles(dt) {
for (let i=particles.length-1; i>=0; i--) {
const p = particles[i];
p.life -= dt/16.7;
p.x += p.vx;
p.y += p.vy;
p.vx *= 0.95;
p.vy *= 0.95;
if (p.life <= 0) particles.splice(i,1);
}
}
function drawParticles() {
for (const p of particles) {
const alpha = Math.max(0, p.life / p.maxLife);
ctx.globalAlpha = alpha;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size * alpha, 0, Math.PI*2);
ctx.fillStyle = p.color;
ctx.fill();
}
ctx.globalAlpha = 1;
}
function getAIInput() {
const start = {x: snake[0].x, y: snake[0].y};
const goal = {x: food.x, y: food.y};
const blocked = snake.length > 1 ? snake.slice(0, -1) : snake;
const key = (x,y) => x+','+y;
const queue = [start];
const visited = new Set([key(start.x, start.y)]);
const parent = new Map();
while (queue.length) {
const cur = queue.shift();
if (cur.x === goal.x && cur.y === goal.y) {
const path = [];
let curKey = key(cur.x, cur.y);
let prev = parent.get(curKey);
while (prev) {
path.unshift({x: prev.dir.x, y: prev.dir.y});
curKey = prev.key;
prev = parent.get(curKey);
}
if (path.length > 0) return path[0];
break;
}
const dirs = [{x:1,y:0},{x:-1,y:0},{x:0,y:1},{x:0,y:-1}];
for (const d of dirs) {
if (d.x === -dir.x && d.y === -dir.y) continue;
const nx = cur.x + d.x;
const ny = cur.y + d.y;
if (nx < 0 || ny < 0 || nx >= COLS || ny >= ROWS) continue;
const k = nx+','+ny;
if (visited.has(k)) continue;
if (blocked.some(p => p.x === nx && p.y === ny)) continue;
visited.add(k);
parent.set(k, {key: key(cur.x, cur.y), dir: d});
queue.push({x:nx, y:ny});
}
}
const safe = [{x:1,y:0},{x:-1,y:0},{x:0,y:1},{x:0,y:-1}].filter(d => !(d.x === -dir.x && d.y === -dir.y));
return safe.length ? safe[Math.floor(Math.random()*safe.length)] : dir;
}
function processInput() {
if (inputQueue.length === 0) return false;
const candidate = inputQueue.shift();
const opposite = {x: -dir.x, y: -dir.y};
if (candidate.x === opposite.x && candidate.y === opposite.y) return processInput();
dir = candidate;
return true;
}
function doTick() {
if (mode === 'ai') {
dir = getAIInput();
} else {
processInput();
}
const newHead = {x: snake[0].x + dir.x, y: snake[0].y + dir.y};
const willEat = newHead.x === food.x && newHead.y === food.y;
const body = willEat ? snake : (snake.length > 1 ? snake.slice(0, -1) : snake);
if (newHead.x < 0 || newHead.x >= COLS || newHead.y < 0 || newHead.y >= ROWS) {
endGame();
return;
}
for (const seg of body) {
if (seg.x === newHead.x && seg.y === newHead.y) {
endGame();
return;
}
}
prevSnake = snake.map(p=>({...p}));
if (willEat) {
score += 10;
const cell = canvas.width / COLS;
const px = newHead.x*cell + cell/2;
const py = newHead.y*cell + cell/2;
spawnParticles({x: px, y: py}, cell);
snake.unshift(newHead);
spawnFood();
} else {
snake.pop();
snake.unshift(newHead);
}
}
let lastTime = performance.now();
let lastTickTime = performance.now();
let accumulator = 0;
function loop(now) {
requestAnimationFrame(loop);
const nowMs = performance.now();
let dt = nowMs - lastTime;
lastTime = nowMs;
if (dt > 100) dt = 100;
if (!paused && running && !gameOver) {
accumulator += dt;
while (accumulator >= tickInterval) {
accumulator -= tickInterval;
doTick();
lastTickTime = performance.now();
if (gameOver) break;
}
}
const t = Math.min(1, (performance.now() - lastTickTime) / tickInterval);
if (!paused) updateParticles(dt);
render(t);
}
function getPositions(t) {
const res = [];
for (let i=0; i<snake.length; i++) {
const curr = snake[i];
const prev = prevSnake[i] || curr;
const x = prev.x + (curr.x - prev.x) * t;
const y = prev.y + (curr.y - prev.y) * t;
res.push({x, y});
}
return res;
}
function render(t) {
const cell = canvas.width / COLS;
ctx.fillStyle = '#0b1220';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = 'rgba(148,163,184,0.08)';
ctx.lineWidth = 1;
ctx.beginPath();
for (let i=1; i<COLS; i++) {
ctx.moveTo(i*cell + 0.5, 0);
ctx.lineTo(i*cell + 0.5, canvas.height);
}
for (let j=1; j<ROWS; j++) {
ctx.moveTo(0, j*cell + 0.5);
ctx.lineTo(canvas.width, j*cell + 0.5);
}
ctx.stroke();
drawParticles();
const pulse = 1 + Math.sin(performance.now() / 250) * 0.05;
const fr = cell * 0.3 * pulse;
const fx = food.x*cell + cell/2;
const fy = food.y*cell + cell/2;
ctx.beginPath();
ctx.arc(fx, fy, fr, 0, Math.PI*2);
ctx.fillStyle = '#ef4444';
ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.2)';
ctx.lineWidth = 1;
ctx.stroke();
ctx.beginPath();
ctx.arc(fx - fr*0.3, fy - fr*0.3, fr*0.25, 0, Math.PI*2);
ctx.fillStyle = 'rgba(255,255,255,0.35)';
ctx.fill();
const positions = getPositions(t);
const headRadius = cell * 0.42;
const bodyRadius = cell * 0.36;
for (let i=positions.length-1; i>=0; i--) {
const p = positions[i];
const px = p.x*cell + cell/2;
const py = p.y*cell + cell/2;
const color = i === 0 ? '#4ade80' : '#22c55e';
ctx.beginPath();
ctx.arc(px, py, i === 0 ? headRadius : bodyRadius, 0, Math.PI*2);
ctx.fillStyle = color;
ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.25)';
ctx.lineWidth = 1;
ctx.stroke();
}
const head = positions[0];
const hx = head.x*cell + cell/2;
const hy = head.y*cell + cell/2;
const d = dir;
const forward = {x: d.x, y: d.y};
const side = {x: -d.y, y: d.x};
const eyeForward = {x: forward.x * cell*0.12, y: forward.y * cell*0.12};
const eyeSide = {x: side.x * cell*0.15, y: side.y * cell*0.15};
const eye1 = {x: hx + eyeForward.x + eyeSide.x, y: hy + eyeForward.y + eyeSide.y};
const eye2 = {x: hx + eyeForward.x - eyeSide.x, y: hy + eyeForward.y - eyeSide.y};
ctx.fillStyle = '#fff';
for (const eye of [eye1, eye2]) {
ctx.beginPath();
ctx.arc(eye.x, eye.y, cell*0.07, 0, Math.PI*2);
ctx.fill();
ctx.beginPath();
ctx.arc(eye.x + forward.x*cell*0.035, eye.y + forward.y*cell*0.035, cell*0.035, 0, Math.PI*2);
ctx.fillStyle = '#0b1220';
ctx.fill();
ctx.fillStyle = '#fff';
}
scoreValue.textContent = score;
if (score > bestScore) bestScore = score;
bestValue.textContent = bestScore;
}
function togglePause() {
if (gameOver) return;
if (!started) return;
paused = !paused;
if (paused) {
overlay.classList.remove('hidden');
overlayMessage.textContent = 'Paused';
overlayMessage.style.display = 'block';
finalScoreEl.style.display = 'none';
} else {
overlay.classList.add('hidden');
overlayMessage.style.display = 'none';
finalScoreEl.style.display = 'none';
}
}
function restartGame() {
startGame();
}
function handleDir(newDir) {
if (gameOver || paused) return;
if (mode === 'ai') {
mode = 'player';
inputQueue = [];
}
inputQueue.push({...newDir});
}
window.addEventListener('click', () => {
if (!started) startGame();
});
window.addEventListener('keydown', (e) => {
const key = e.key.toLowerCase();
if (!started) {
e.preventDefault();
startGame();
return;
}
if (e.key === 'ArrowUp' || key === 'w') {
e.preventDefault();
handleDir({x:0,y:-1});
} else if (e.key === 'ArrowDown' || key === 's') {
e.preventDefault();
handleDir({x:0,y:1});
} else if (e.key === 'ArrowLeft' || key === 'a') {
e.preventDefault();
handleDir({x:-1,y:0});
} else if (e.key === 'ArrowRight' || key === 'd') {
e.preventDefault();
handleDir({x:1,y:0});
} else if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
if (gameOver) restartGame();
} else if (key === 'p') {
e.preventDefault();
togglePause();
}
});
resetGame();
spawnFood();
overlayMessage.textContent = 'Click or press a key to start';
overlayMessage.style.display = 'block';
overlay.classList.remove('hidden');
requestAnimationFrame(loop);
</script>
</body>
</html>Reasoning trace (24,106 tokens)
We need answer with exactly one fenced code block containing complete HTML. Need craft game. Must satisfy constraints. We need output only code block no text. Need ensure self-contained, vanilla JS canvas, no localStorage. Grid at least 20x20 centered. Arrow/WASD. Quick successive key presses honored, no reverse. Eating +10 grow. Food not on snake. Game over wall/self overlay score. Space/Enter restart, P pause. Polish smooth movement, head eyes facing direction, particle burst eating. Display current score and best score of session. Best score session maybe local variable. No localStorage. Start self-playing demo mode behind "Click or press a key to start" message, so can judge. Iframe keyboard only after click; display clear message. Page must look right 1280x800, adapt 375. No horizontal scrollbar. First paint meaningful, screenshot 3 sec after load no interaction. Everything interesting visible first seconds, nothing hidden behind high score/later level/long play. Games start in self-playing demo mode (simple AI) behind click/key start. So on load maybe overlay message, game starts after click/press key. But screenshot after 3 sec without interaction? Requirement conflict: game must start behind message; screenshot after load without interaction maybe should show message and maybe demo not started. But "Something meaningful must be visible on first paint: a screenshot is taken automatically about 3 seconds after load, without any interaction." Could have initial state before start maybe idle snake? But games must start in self-playing demo mode behind "Click or press a key to start". Maybe overlay covers? For screenshot no interaction, overlay visible and game idle behind? Need meaningful visible. Could render a demo snake even before start? Or start self-playing automatically? They said behind a "Click or press a key to start" message, so likely game scene visible, overlay message at center. We can have game initialized with snake, food, score, running demo? If overlay prevents interaction until click. But screenshot after 3 sec without interaction should show something meaningful; overlay text okay? "Something meaningful must be visible on first paint" likely gameplay visible. We can render initial snake/food even before start, overlay message semi-transparent. Game maybe not running until start. But self-playing demo mode behind message: after start, AI plays. Before start, static scene.
Need no alert etc. No localStorage. Best score session: variable bestScore, update when game over maybe. Display current score and best score. Could show in HUD.
Need smooth movement. Grid 24x24 maybe. Smooth interpolation between ticks. Snake movement vector. Head direction. Eyes facing direction. Food particle burst. Game over overlay score. Space/Enter restart, P pause.
Keyboard: Arrow keys and WASD. Quick successive key presses honored, snake can never reverse. Need buffer for keys. If key pressed, set direction if not reverse of last. For quick successive, buffer maybe queue? Simpler: on keydown set pendingDirection; each tick validate against last. But quick successive: if press up then left quickly, should honor both. If set direction directly, pressing left after up before tick could reverse? Snake moving right, last=Right, new=Left -> reverse invalid. But if quick successive intended up then left: first press Up valid, second Left valid (not reverse of Up). If we update last immediately on key, moving state? Better store direction and queue. For each tick, take next input direction if valid relative to current movement direction and not reverse; apply. That honors quick successive as long as no reverse. But if user presses Up then Left while moving Right, queue [Up, Left]; tick applies Up, next tick applies Left. Good. If presses Left while moving Right, queue Left invalid because reverse of current? We can just ignore reverse. But after moving Up, Left valid.
AI demo mode: simple AI. Need start after click/key. AI should play reasonably. Could use direction towards food with safe choice. Grid 24. Snake length maybe 3 initial. Food random not on snake. AI tick interval maybe 120ms. It should avoid collisions? Simple: choose axis with larger distance, prefer axis not reverse; move. Could get stuck? Maybe simple BFS? Not necessary but polished. For demo, simple greedy with random safe. Need game over if wall/self.
Game loop: requestAnimationFrame with accumulator. Tick interval maybe 140ms. AI controls when demo mode and running. Player controls after start? The task: games must start in self-playing demo mode behind start message. Could after start, maybe player controls? But for judging action, likely should be self-playing. Could have demo mode always AI, ignores player? But requirement arrow keys/WASD control snake. Maybe after start, if user presses arrow/WASD, switch to player control? But demo should be self-playing. To satisfy, maybe start in AI mode; user key can take control? But judge may expect arrows control. However self-playing demo behind message. We can have mode 'demo'; AI drives until start? Actually start button triggers demo AI. Arrow keys/WASD can control after start? Might conflict. Better: overlay says click/key start; on start, begin AI demo. Arrow keys/WASD also set player direction and mode='player'? But if judge presses arrow, they control. But requirement games start in self-playing demo mode. Hmm.
Maybe initial state: mode 'demo', running=false, overlay. When start pressed, running=true and mode='ai'. Keyboard listener: if mode ai and running, AI. If user presses arrow/WASD, set mode='player' and direction. But then self-playing demo only until user interacts. That's okay? Might be seen as not purely self-playing. Could ignore player input during demo, but then arrow keys requirement not fulfilled in demo. The requirement says Arrow keys and WASD control the snake. So after start, player should control? But start must be self-playing demo. Could start in AI and user can take over via arrows. But for judging, they might press start then watch AI. The overlay message says click or press key to start. Pressing arrow after could control? Might be ambiguous. Maybe overlay start button/click starts AI; arrow keys during AI set direction? No.
Maybe design: Start overlay: "Click or press a key to start". On start, mode='ai', running=true. AI controls. If user presses arrow/WASD after start, we set mode='player' and direction. But then it's not self-playing. However initial demo is self-playing. To keep self-playing, maybe only AI controls until game over? But then arrows don't control in demo. Requirement likely expects player controls snake. But task explicitly says games must start in self-playing demo mode behind start message, so in this context demo overrides? Could still allow player to take control after start. Hmm.
Maybe after start, AI controls, but if keyboard input received, switch to player. The initial self-playing demo is behind overlay. Once started, AI. If user presses arrows, they control. That satisfies both partially.
Need no external fonts. Use system fonts. Canvas.
Visual polish: smooth movement, distinct head and eyes facing direction, particle burst. Grid centered. Canvas maybe 900x900? Grid 21x21? Requirement at least 20x20. Use COLS=24, ROWS=24, cell size = min(canvas width/COLS, ...). Center on page. Canvas 640x640? At 1280x800, centered. Use responsive. Could set canvas size based on viewport: size = min(window.innerWidth-32, window.innerHeight-32, 640). Grid 24. Center. On 375 width, cell ~14px. Good.
Score and best score display: HTML overlay or canvas HUD? Use HTML/CSS above/below canvas. Must be inline. Could have header with Score and Best. No external. Use divs.
Game over overlay: on canvas or HTML? Requirement overlay showing score. Could be HTML overlay centered on canvas. Space/Enter restarts, P pauses. Overlay visible when gameOver. Score current and best. Restart resets.
Pause: P toggles. Overlay maybe "Paused". Space/Enter restart. Space also start? If overlay start, Space/Enter starts. During game, Space/Enter restart? Requirement: Game over overlay ... Space or Enter restarts, P pauses. So after game over, Space/Enter restart. During play, maybe Space pause? P pauses. Space/Enter on game over restart. Could Space also start/restart. Need avoid page scroll. keydown preventDefault for arrows/space.
Best score session: variable bestScore = 0; update when game over if score > best. Display. Maybe new best flash.
Particles: array {x,y,vx,vy,life,color}. On eat, spawn 12 particles at cell center. Update in world coords. Draw circles fading.
Smooth movement: snake cells with interpolation. We can store previous snake positions and current. For each segment, interpolate between prev and curr based on tick progress. Head position lerp. Direction. For tail maybe not needed. Simpler: store snake array of points (grid coords) and prevSnake. On tick, prevSnake = snake copy, snake shift/push. Interpolate each index: if prev exists, pos = prev + (curr-prev)*t, else curr. But for tail growth, prev missing means curr. For movement, tail segment maybe stays? Actually snake moves, tail moves. If growing, tail stays? We can handle interpolation per index: find prev corresponding. If snake length grew, last new segment should be at curr? Maybe simpler: interpolate head and body by index using prev array. For last index if no prev, use curr. But when not growing, prev length = curr length, tail moves. When growing, prev length = curr length -1? Actually on eat, we don't shift last, just push new head; prevSnake should be copy of snake before adding? Let's design.
State: snake array of {x,y} in grid. On tick:
- compute newHead = head + dir.
- check collision before move? If newHead out/wall/self -> game over. Self includes tail? Usually if not eating, tail moves away, so can eat through tail. But simple collision check against snake excluding last if not growing? For simplicity check against all snake; might cause early death. Better: if willEat, check against all; else check against snake without last. But for polished, okay? Let's do proper: willEat = newHead equals food. collision check: body = willEat ? snake : snake.slice(0,-1). If willEat, after move push newHead and don't remove tail; else shift and push newHead. But interpolation with prev.
Maybe store snake as array of grid points. prevSnake = snake.map(p=>({...p})) before move. On tick, determine willEat. Then move. For rendering, for each segment i, curr = snake[i], prev = prevSnake[i] (if i < prevSnake.length else curr? But if growing, prevSnake length = old length, curr length = old+1; index old is new tail, no prev -> curr. For i < old length, prev exists). t = accumulator / tickInterval clamped. pos = lerp(prev, curr, t). This gives smooth.
But if not growing, prev length = curr length, tail moves. If growing, prev length = curr length-1, indices < old length have prev, new tail at old length no prev -> curr. Good.
Head direction: dir. Eyes facing direction. Draw head as rounded rect/circle at interpolated pos. Eyes offset perpendicular. Distinct head maybe lighter/darker border.
Food: grid cell center. Draw apple-ish circle. Maybe pulsing.
Grid: draw subtle lines. Canvas background. Snake colors gradient? Head #4ade80, body #22c55e? Distinct head. Maybe head dark green, body green. Eyes white with black pupil. Direction facing.
Smooth movement: cell size maybe 30. Draw rounded rect segments. For smooth, draw circles? Could draw snake as connected rounded path. Simpler: draw each cell as rounded rect with slight overlap? For smooth, interpolate positions and draw thick line/ellipse? Polished: draw as series of circles? Better: draw thick polyline through interpolated head/body points with lineCap round, lineWidth cell*0.8. But need distinct head. Could draw body circles at interpolated positions, head circle with eyes. That looks smooth. Use circle radius cell*0.38. Body color. Head radius cell*0.44 with eyes.
But snake as circles may overlap. Fine.
Canvas coordinate: origin top-left. Grid cell (col,row) center = (col*cell + cell/2, row*cell + cell/2). Rows maybe y down. Food random.
Collision: check newHead x<0 etc. Snake self: if willEat, check against snake; else check against snake minus tail. But with interpolated movement, okay.
AI: function getAIInput(): choose direction. Need avoid immediate collision. Simple: possible directions not reverse of current. Score each: distance to food in Manhattan, prefer larger. Avoid walls maybe. Choose best. If no safe, maybe random. Could implement BFS to food avoiding walls/body? Might be overkill but polished. Simple greedy might get stuck. For demo, okay. But to avoid self-collision, choose direction that doesn't collide with next positions. We can compute newHead for each candidate and check collision with snake (willEat logic). Score by distance to food and safety. Since food random, greedy.
Tick logic: accumulator += dt. While acc >= tickInterval and !gameOver && running: process tick. tickInterval maybe 130ms for AI, 140 player? Use 0.13. For player, maybe same. Acc clamp to avoid multiple.
Input handling: keydown. Map arrows/WASD to dir. If mode === 'player' and running and !gameOver? Actually after start mode maybe 'ai'. If key is arrow/wasd, set mode='player', queue direction. If mode==='ai', maybe AI already. To let AI start, mode='ai'. But arrow keys control? Maybe if user presses arrow before start, overlay? The requirement: iframe only receives keyboard after user clicks it; display clear "Click or press a key to start". So keydown when overlay visible should start. We can handle: if startOverlay visible, any key/click starts. Then mode='ai'. After started, if key is arrow/wasd, set mode='player'? But if AI mode, arrows might be ignored? Better: after start, arrow/WASD set player direction and mode='player'. But then AI demo only until user presses arrow. For judging, likely they won't press arrow, so AI plays. Good.
But if mode='ai', AI updates direction each tick. If mode='player', use queued player direction.
Queue: inputQueue array. On player dir, push if valid? We can push all, and on tick shift next valid. To honor quick successive, push. On tick, while queue length: candidate = queue.shift(); if candidate is opposite of current dir? Actually valid if not reverse of last applied direction. Since snake moves dir, reverse is {x:-dir.x, y:-dir.y}. If candidate valid, set dir = candidate; break. If invalid, ignore. This allows up then left while moving right: first up valid, second left valid? Wait after applying up, dir=up; left is not reverse of up, so valid. But queue shift happens per tick, so first tick applies up, second tick applies left. Good. If user presses left while moving right, candidate left is reverse of dir right, invalid, ignore. Good.
But if mode='ai', clear queue.
Game over: running=false, gameOver=true, update best. Overlay shows score. Space/Enter restarts. P pauses. When gameOver, overlay covers canvas. Score current and best. Restart resets snake, food, score, particles, gameOver false, running true, mode maybe 'ai'? Or 'player'? For restart, maybe return to AI demo? Could set mode='ai' so self-playing. Or keep player? Requirement restart. For demo, set to ai maybe. But if player was controlling, restart maybe player. Simpler: restart resets and sets mode='ai', running=true. But arrow keys control? Hmm.
Pause: if paused, overlay "Paused". P toggles. Space/Enter restarts? If paused, P unpause; Space/Enter maybe resume? Requirement: P pauses. Space or Enter restarts. On game over, Space/Enter restarts. During play, Space maybe pause? Could implement: if gameOver and (Space/Enter) restart; if !gameOver and P toggle pause; Space maybe pause/resume? But requirement says Space or Enter restarts on game over, P pauses. We can make Space/Enter restart only when gameOver; P pause during play. Space during play maybe pause? Not required. To avoid conflict, Space/Enter when gameOver restart; P toggles pause; Space during play maybe pause? Might be okay. But keydown Space might scroll; preventDefault.
Start overlay: when !started, show "Click or press a key to start". Game not running, but scene visible. On click or keydown, start. After start, running=true, mode='ai'. Overlay hidden.
Need first paint: canvas draws initial snake, food, grid, score HUD. Overlay start visible. Screenshot after 3 sec no interaction: if not started, running=false, snake static, overlay visible. That's meaningful? Probably. But game must start in self-playing demo mode behind message. Before start, not running. After start, AI. The screenshot without interaction might capture overlay. Could maybe start running automatically? But requirement says behind message. If overlay covers, screenshot sees overlay. To be safe, maybe initial state running=false, overlay visible, but canvas shows snake. The screenshot might include overlay text, still meaningful. Alternatively, start AI immediately but overlay? No, must behind message.
Maybe set started=false, running=false, gameOver=false, paused=false. Overlay start. Canvas renders initial. After user click/key, started=true, running=true, mode='ai'. Good.
Best score session: bestScore variable. Display. On game over, if score > bestScore, bestScore = score, newBest=true. Reset newBest false on start.
Score: eating +10. Food spawn not on snake.
Game over overlay: show final score and best. Space/Enter restarts. P pauses.
Visual polish: smooth movement, head eyes, particle burst. Particle burst when eating: on tick if willEat, spawn particles at food center or new head. Update particles. Draw squares/circles. Life fade.
Need no console errors. Use requestAnimationFrame, handle resize maybe. No localStorage. No alert/confirm/prompt. No window.open. No storage. Good.
Canvas sizing: Let COLS=24, ROWS=24. Canvas CSS width maybe min(92vw, 60vh, 560px). Internal resolution same? For crisp, set canvas.width = size * dpr? Simpler set canvas.width = size; canvas.height = size; style width/height. Use ctx.scale? Could just use size. For retina maybe not needed. Use canvas.width = size; canvas.height = size. cell = size / COLS.
Center on page: wrap in div flex center. HTML layout: body margin 0, background gradient, display flex column center, min-height 100vh, color. Header top? Score display. Canvas centered. Overlay absolute. On 375 width, no horizontal scrollbar. Use width: min(94vw, 60vh, 520px). Canvas width 100%. Cell size computed from canvas.width/COLS.
No external fonts. Use system-ui.
First paint: score HUD visible. Canvas draws grid, snake, food.
Game loop: function tick() { ... }. Need dt in ms. Use performance.now. lastTime. In rAF, dt = now - last; last=now; accumulator += dt; update particles; update ai maybe; while accumulator >= tickInterval && running && !gameOver: doTick(); accumulator -= tickInterval; if gameOver break. Render.
When paused, don't accumulate? Could still render. In rAF, if paused, skip accumulator update maybe. Simpler: if !paused && running && !gameOver, accumulate. If paused, just render.
doTick:
- if mode === 'ai' and started: dir = getAIInput(dir)
- willEat = snake[0].x == food.x && snake[0].y == food.y? Actually newHead check. Compute newHead = {x: snake[0].x+dir.x, y: snake[0].y+dir.y}. willEat = newHead equals food.
- collision: if out of bounds -> gameOver. body = willEat ? snake : snake.slice(0,-1). Check newHead against body. If collision -> gameOver, running=false, update best, save final score. Return.
- prevSnake = snake.map(p=>({...p}))
- if willEat: score += 10; spawnParticles(newHead, food); snake.push(newHead); food = spawnFood(); // grow
else: snake.shift(); snake.push(newHead);
- Wait for willEat, we need newHead. For collision, body check. For move, if willEat push newHead, else shift/push. But prevSnake should be old snake. Good.
Actually if willEat, snake.push(newHead) without shift, so length increases. prevSnake length old. Good.
Food spawn: random empty cell not on snake. Loop.
Game over: overlay with score. bestScore update. running=false.
AI input: current dir. possible dirs: up, left, right, down excluding reverse. For each, compute newHead and check if willEat? Actually check collision with body (willEat? snake: snake.slice(0,-1)). If safe, score = manhattan distance to food + random? Prefer larger distance? Actually to get closer, smaller distance. Choose min distance, tie random. Avoid immediate reverse. If no safe, return dir? Or random safe? Could just return dir.
Manhattan distance from newHead to food. Choose candidate with smallest distance. But if food behind? Reverse excluded. Could still choose. For demo, okay.
Need AI not to get stuck. Could use simple BFS to food. But maybe not necessary. Let's implement simple BFS? Might be more polished and self-playing. BFS on grid avoiding walls and snake body (considering willEat). Find path to food, return first direction. Since grid 24x24, BFS fast. But snake body changes. For AI each tick, BFS could be okay. Let's implement BFS to be robust.
BFS function: given start head, dir? Actually find path from snake[0] to food. Avoid walls and body. If willEat? The body for movement: if target food equals newHead? Hmm BFS should find path to food cell, but snake cannot move into its body unless eating? Actually food is not on snake, so path to food won't go through body? It might. We can treat body cells as blocked except maybe tail if not eating. Simpler: blocked = willEat ? snake : snake.slice(0,-1). For BFS, start at head, goal food. Check neighbors not out of bounds, not blocked, not reverse? Reverse is just previous pos, which is in body likely, so blocked. BFS returns dir. If no path, return random safe direction.
BFS implementation: queue = [{x: head.x, y: head.y, dir: null}]. visited set. While queue. If current is food? Actually start not food. For each neighbor, if valid and not visited, set prev. If neighbor is food, reconstruct path and return first dir. Reconstruct: from food back to start, collect dirs. Simpler: store parent and dir. Use arrays. Grid small, easy.
But do we need? Could just greedy. Polished demo should play. BFS might be better but more code. We can write clean BFS.
Let's outline code.
HTML:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Snake</title>
<style>
html, body { margin:0; padding:0; height:100%; background: #0b1020; color:#e5e7eb; font-family: system-ui, sans-serif; overflow-x:hidden; }
#app { min-height:100vh; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:12px; padding:16px; box-sizing:border-box; }
#hud { width: min(94vw, 560px); display:flex; justify-content:space-between; font-size: clamp(14px, 2.5vw, 20px); font-weight:700; letter-spacing: 1px; text-transform: uppercase; }
#hud .label { opacity:0.7; font-size: 12px; }
#hud .value { font-size: 24px; }
#wrap { position:relative; width: min(94vw, 60vh, 560px); aspect-ratio: 1/1; }
canvas { width:100%; height:100%; display:block; background:#0f172a; border-radius:16px; border:1px solid #1e293b; }
#overlay { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:12px; background: rgba(2,6,23,0.72); border-radius:16px; text-align:center; padding:16px; box-sizing:border-box; }
#overlay.hidden { display:none; }
#overlay h1 { margin:0; font-size: clamp(20px, 4vw, 32px); }
#overlay p { margin:0; opacity:0.8; font-size: clamp(12px, 2.5vw, 16px); }
#overlay .final-score { font-size: clamp(28px, 6vw, 44px); font-weight:800; }
</style>
</head>
<body>
<div id="app">
<div id="hud">
<div>Score <span id="scoreValue" class="value">0</span></div>
<div>Best <span id="bestValue" class="value">0</span></div>
</div>
<div id="wrap">
<canvas id="game" width="560" height="560"></canvas>
<div id="overlay">
<h1>🐍 Snake</h1>
<p id="overlayMessage">Click or press a key to start</p>
<p id="finalScore" class="final-score" style="display:none;"></p>
<p id="overlayHint">Space/Enter restart · P pause</p>
</div>
</div>
</div>
<script>
...
</script>
</body>
</html>
Need overlay hide/show. finalScore display none.
JS:
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const overlay = document.getElementById('overlay');
const overlayMessage = document.getElementById('overlayMessage');
const finalScoreEl = document.getElementById('finalScore');
const scoreValue = document.getElementById('scoreValue');
const bestValue = document.getElementById('bestValue');
const COLS=24, ROWS=24;
const canvasSize = Math.min(560, window.innerHeight - 160, window.innerWidth - 32); // but on load. Could set in resize.
let size = 560;
function resizeCanvas() {
const max = Math.min(window.innerWidth - 32, window.innerHeight - 160, 560);
size = Math.max(240, Math.floor(max));
canvas.width = size;
canvas.height = size;
canvas.style.width = size + 'px';
canvas.style.height = size + 'px';
}
window.addEventListener('resize', resizeCanvas);
resizeCanvas();
But on 1280x800, max = min(1248, 640, 560)=560. Good. On 375x667, max = min(343, 507, 560)=343. Good. Canvas CSS width 100%? If we set style width px, okay. But #wrap width min(94vw, 60vh, 560px). If canvas style width px, wrap might stretch? Better: #wrap width: min(94vw, 60vh, 560px); canvas width:100%; height:100%; and canvas.width = size. But size based on window. Use CSS width. Simpler: set canvas.width = size; canvas.height = size; canvas.style.width = '100%'; canvas.style.height='100%'; and wrap has aspect ratio. But internal resolution should match displayed size for crisp. We can compute size from wrap client? On resize, set canvas.width = canvas.clientWidth? Might be okay. Simpler: fixed internal 560, CSS max-width 100%. But for 375, cell size smaller, okay. Use canvas width=560 height=560, CSS width:100%, height:100%. ctx uses canvas.width for cell. That works. #wrap width min(94vw, 60vh, 560px). canvas width 100%. Good.
So no resize needed except maybe for dpr. Keep simple.
let snake, food, score, bestScore, gameOver, paused, started, mode, dir, inputQueue, particles, lastTime, accumulator, tickInterval = 130;
function resetGame() {
snake = [{x:8,y:12},{x:7,y:12},{x:6,y:12}];
dir = {x:1,y:0};
score = 0;
gameOver = false;
paused = false;
started = true;
mode = 'ai';
inputQueue = [];
particles = [];
scoreValue.textContent = '0';
bestValue.textContent = bestScore;
overlay.classList.add('hidden');
overlayMessage.style.display = 'none';
finalScoreEl.style.display = 'none';
spawnFood();
}
function spawnFood() {
let valid;
do {
valid = true;
food = {x: Math.floor(Math.random()*COLS), y: Math.floor(Math.random()*ROWS)};
if (snake.some(s => s.x===food.x && s.y===food.y)) valid = false;
} while (!valid);
}
function updateBest() {
if (score > bestScore) bestScore = score;
bestValue.textContent = bestScore;
}
function gameOver() {
gameOver = true;
running = false; // need running variable
updateBest();
finalScoreEl.textContent = score;
finalScoreEl.style.display = 'block';
overlayMessage.style.display = 'none';
overlay.classList.remove('hidden');
}
Need running variable. Add let running = false;
Reset: running = true.
function start() {
started = true;
running = true;
mode = 'ai';
paused = false;
inputQueue = [];
overlay.classList.add('hidden');
overlayMessage.style.display = 'none';
finalScoreEl.style.display = 'none';
resetGame(); // but reset sets started true etc. Better separate.
}
Let's structure:
let started = false;
let running = false;
let paused = false;
let gameOver = false;
let mode = 'ai'; // 'ai' or 'player'
let dir = {x:1,y:0};
let inputQueue = [];
let particles = [];
let score = 0;
let bestScore = 0;
let food = {x:5,y:12};
let snake = [];
function initGame() {
snake = [{x:8,y:12},{x:7,y:12},{x:6,y:12}];
dir = {x:1,y:0};
score = 0;
gameOver = false;
paused = false;
mode = 'ai';
inputQueue = [];
particles = [];
scoreValue.textContent = '0';
bestValue.textContent = bestScore;
overlay.classList.add('hidden');
overlayMessage.style.display = 'none';
finalScoreEl.style.display = 'none';
spawnFood();
}
function startGame() {
started = true;
running = true;
mode = 'ai';
paused = false;
inputQueue = [];
initGame();
}
function resetGame() {
// after game over
startGame();
}
Actually initGame can set started? Better:
function resetGame() {
snake = [...];
dir = {x:1,y:0};
score = 0;
gameOver = false;
paused = false;
mode = 'ai';
inputQueue = [];
particles = [];
scoreValue.textContent = '0';
bestValue.textContent = bestScore;
overlay.classList.add('hidden');
overlayMessage.style.display = 'none';
finalScoreEl.style.display = 'none';
spawnFood();
}
function startDemo() {
started = true;
running = true;
mode = 'ai';
paused = false;
resetGame();
}
On load: resetGame(); bestScore=0; bestValue.textContent=0; running=false; started=false; overlay visible.
startDemo sets started=true, running=true, resetGame.
When game over: running=false; started true; overlay visible.
Restart: resetGame(); startDemo? But if player mode? For simplicity restart sets mode='ai'? Hmm. Could set mode = 'player'? But demo. Let's set mode='ai' for self-playing. But arrow keys control? If restart from game over, maybe player expects control? Not critical. To satisfy restart, just reset.
Pause: if !gameOver && started && running: paused = !paused; overlay maybe show Paused. If paused, overlay visible with message. P toggles. Space/Enter when gameOver restarts.
Overlay logic:
function showOverlayMessage(msg) {
overlayMessage.textContent = msg;
overlayMessage.style.display = 'block';
finalScoreEl.style.display = 'none';
}
function showGameOverOverlay() {
overlay.classList.remove('hidden');
overlayMessage.style.display = 'none';
finalScoreEl.textContent = score;
finalScoreEl.style.display = 'block';
}
function hideOverlay() {
overlay.classList.add('hidden');
overlayMessage.style.display = 'none';
finalScoreEl.style.display = 'none';
}
Start: hideOverlay, overlayMessage text "Click or press a key to start"? Actually hide. Maybe overlay hidden.
Pause overlay: overlayMessage.textContent = 'Paused'; finalScore display none; overlay remove hidden.
Game over: overlayMessage display none; finalScore display block; overlay remove hidden.
When game over, overlay covers canvas. Score shown.
Keyboard:
window.addEventListener('keydown', (e) => {
const key = e.key.toLowerCase();
if (e.key === 'ArrowUp' || key === 'w') { e.preventDefault(); handleDir({x:0,y:-1}); }
else if (e.key === 'ArrowDown' || key === 's') { e.preventDefault(); handleDir({x:0,y:1}); }
else if (e.key === 'ArrowLeft' || key === 'a') { e.preventDefault(); handleDir({x:-1,y:0}); }
else if (e.key === 'ArrowRight' || key === 'd') { e.preventDefault(); handleDir({x:1,y:0}); }
else if (key === ' ') { e.preventDefault(); handleSpace(); }
else if (key === 'enter') { e.preventDefault(); handleEnter(); }
else if (key === 'p') { e.preventDefault(); togglePause(); }
});
function handleDir(newDir) {
if (!started) { startDemo(); }
if (gameOver || paused) return;
if (mode === 'ai') {
// If player takes control? Maybe if key pressed, switch to player.
mode = 'player';
inputQueue = [];
}
// push if not reverse of last queued/applied? We can just push and validate on tick.
// But avoid immediate reverse of current dir? If newDir is opposite of dir, ignore.
const opposite = {x: -dir.x, y: -dir.y};
if (newDir.x === opposite.x && newDir.y === opposite.y) return;
inputQueue.push({...newDir});
}
But if mode is ai and user presses arrow, we switch to player. Good. For demo, AI controls until user presses arrow.
handleSpace: if (!started) startDemo(); else if (gameOver) restartGame(); else if (paused) togglePause(); else maybe pause? Not required. Could if !gameOver and !paused togglePause? But P pauses. Space might restart only on game over. To avoid conflict, Space on game over restarts; Space during play maybe pause? The requirement says Space or Enter restarts, P pauses. So on game over, Space/Enter restart. During play, P pauses. Space maybe nothing. But to be safe, if !gameOver and !paused, togglePause? Might be unexpected. I'll make Space only restart when gameOver, and P pause. Enter same.
handleEnter: if (!started) startDemo(); else if (gameOver) restartGame(); else if (paused) togglePause(); else startDemo? Not needed.
togglePause: if (gameOver) return; if (!started) return; paused = !paused; if (paused) { overlay.classList.remove('hidden'); overlayMessage.textContent='Paused'; overlayMessage.style.display='block'; finalScoreEl.style.display='none'; } else { overlay.classList.add('hidden'); overlayMessage.style.display='none'; finalScoreEl.style.display='none'; }
restartGame: resetGame(); startDemo? Actually resetGame sets mode ai, running? resetGame doesn't set running. Let's have resetGame set running = true? But initial reset should running=false. Hmm.
Define:
function resetGame() {
snake = [...];
dir = {x:1,y:0};
score = 0;
gameOver = false;
paused = false;
mode = 'ai';
inputQueue = [];
particles = [];
scoreValue.textContent = '0';
bestValue.textContent = bestScore;
overlay.classList.add('hidden');
overlayMessage.style.display = 'none';
finalScoreEl.style.display = 'none';
}
function startGame() {
started = true;
running = true;
resetGame();
}
function restartGame() {
resetGame();
startGame(); // sets started true, running true, reset again? Better just call startGame after reset.
}
Actually startGame calls resetGame. restartGame can just call startGame(). But startGame sets mode? reset sets mode ai. Good.
Initial load: resetGame(); running = false; started = false; overlay visible; overlayMessage text start; finalScore display none; bestScore=0; bestValue.textContent=0; scoreValue.textContent=0; spawnFood.
startGame: started=true; running=true; resetGame();
restartGame: startGame();
When game over: running=false; updateBest(); finalScore... overlay visible.
When startDemo via key/click: startGame();
Click on overlay? The overlay covers canvas; if hidden after start, clicks pass? Overlay has display none when hidden. Before start, overlay visible; clicking it should start. Add click listener on overlay? Or window click. Since overlay covers, add document click listener: if (!started) startGame(); But after started, overlay hidden. During pause, overlay visible; clicking should not start. So only if (!started) startGame(). Add window.addEventListener('click', (e) => { if (!started) startGame(); }); But after started, overlay hidden, okay. During game over, overlay visible but started true, so click won't restart; keyboard will.
Need prevent context menu? Not necessary.
Render function:
function render() {
const cell = canvas.width / COLS;
// background
ctx.fillStyle = '#0b1220';
ctx.fillRect(0,0,canvas.width, canvas.height);
// grid lines
ctx.strokeStyle = 'rgba(148,163,184,0.08)';
ctx.lineWidth = 1;
for (let i=1; i<COLS; i++) { line vertical; }
for (let j=1; j<ROWS; j++) { line horizontal; }
// food
drawFood();
// particles
drawParticles();
// snake
drawSnake();
// overlays? HTML overlay.
}
Coordinate: canvas.width = 560. cell = 560/24 = 23.333. Grid lines at i*cell.
Food: center x = food.x*cell + cell/2; y = food.y*cell + cell/2. Draw circle radius cell*0.32, color #f87171 or #ef4444, with glow. Maybe draw leaf? Keep simple.
Snake: Need interpolated positions. We have snake array and prevSnake? For smooth movement, we need prevSnake. Let's store prevSnake in state. On reset, prevSnake = snake.map(...). On tick, before move, set prevSnake = snake.map(p=>({...p})). Then move. Render uses prevSnake and snake.
But for initial render, prevSnake = snake copy.
In doTick:
prevSnake = snake.map(p=>({...p}));
// compute newHead, willEat, collision
if (willEat) { score+=10; spawnParticles(newHead, food); snake.push(newHead); food = spawnFood(); }
else { snake.shift(); snake.push(newHead); }
Wait: if willEat, we push newHead without shift, so snake length grows. prevSnake is old snake. Good.
If not willEat, shift then push newHead. prevSnake is old snake, same length as new snake? Old length = new length (shift+push). Yes.
Render interpolation:
function getInterpolatedPositions() {
const t = Math.min(1, accumulator / tickInterval); // accumulator ms since last tick? Actually after while, accumulator < tickInterval. For rendering between ticks, use accumulator. But if we just processed tick, accumulator was reduced. Better use a separate renderT = accumulator / tickInterval before processing? In rAF, we update accumulator, process while, then render. If we processed, accumulator is leftover. To render at current movement, we need progress since last tick. Could store lastTickTime and compute t = (now - lastTickTime)/tickInterval. Simpler: in rAF, before processing, compute t = (now - lastTickTime)/tickInterval, clamp; after processing, update lastTickTime? Let's do:
let lastTickTime = performance.now();
In rAF:
const now = performance.now();
let dt = now - lastTime; lastTime = now;
if (!paused && running && !gameOver) {
accumulator += dt;
while (accumulator >= tickInterval) {
accumulator -= tickInterval;
doTick();
lastTickTime = performance.now();
if (gameOver) break;
}
}
const t = Math.min(1, (performance.now() - lastTickTime) / tickInterval);
render(t);
But if paused, accumulator not updated. Fine.
In doTick, set lastTickTime? Actually after doTick, set lastTickTime = performance.now() to make t=0 for next. Or compute t before doTick. Simpler: in rAF, compute t = (now - lastTickTime)/tickInterval; then while accumulator >= tickInterval: accumulator -= tickInterval; doTick(); lastTickTime = now; t = 0? Hmm.
Let's do:
let lastTickTime = performance.now();
function doTick() { ... }
In loop:
let now = performance.now();
let dt = now - lastTime; lastTime = now;
if (!paused && running && !gameOver) {
accumulator += dt;
if (accumulator >= tickInterval) {
accumulator -= tickInterval;
doTick();
lastTickTime = performance.now();
if (gameOver) { /* maybe break */ }
}
}
const t = Math.min(1, (performance.now() - lastTickTime) / tickInterval);
render(t);
If multiple ticks, lastTickTime updates each, t will be small. Good.
In doTick, after moving, set lastTickTime? Actually rAF sets after doTick. Fine.
Interpolated positions:
function getPositions(t) {
const result = [];
for (let i=0; i<snake.length; i++) {
const curr = snake[i];
const prev = prevSnake[i] || curr;
const x = prev.x + (curr.x - prev.x) * t;
const y = prev.y + (curr.y - prev.y) * t;
result.push({x, y});
}
return result;
}
But grid coords can be fractional. For drawing, convert to pixel: px = x*cell + cell/2. Head direction from dir. For body, okay.
Draw snake:
const positions = getPositions(t);
const head = positions[0];
const headPx = {x: head.x*cell + cell/2, y: head.y*cell + cell/2};
// draw body from tail? Maybe draw circles for each segment.
ctx.fillStyle = '#16a34a'? Let's choose palette:
bg: #0b1220
grid: rgba(148,163,184,0.07)
snake body: #22c55e
snake head: #4ade80? Distinct head: #86efac? Or head darker green #15803d, body #22c55e. Eyes white. Let's do head #4ade80, body #22c55e, outline rgba(0,0,0,0.2). Distinct head slightly larger and eyes.
For i from positions.length-1 down to 0? Draw head last on top. Body circles radius cell*0.36. Head radius cell*0.42. Colors: if i==0 head #4ade80 else #22c55e. Maybe gradient? Not necessary.
Draw circle at interpolated pos:
ctx.beginPath();
ctx.arc(px, py, radius, 0, Math.PI*2);
ctx.fillStyle = color;
ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.25)';
ctx.lineWidth = 1;
ctx.stroke();
Head eyes: direction dir. Eye positions: forward = dir, side = perpendicular. For dir {x:1,y:0}, side {y:1}? Actually perpendicular. eye offset forward = dir * cell*0.12, side = side * cell*0.14. Eye radius cell*0.07, pupil radius cell*0.035. Eyes white with black pupil. Facing direction. Good.
Maybe draw head as rounded rect? Circles fine.
Particles: array of {x,y,vx,vy,life,maxLife,color}. In update: life--, pos += vel, vel *= 0.95. Draw circle radius random. Colors ['#f87171','#fbbf24','#4ade80','#60a5fa']. On eat: spawn at newHead pixel center, random velocity. 14 particles.
Food: draw circle with pulse: radius = cell*0.3 + Math.sin(performance.now()/200)*0.02*cell. Color #ef4444. Maybe draw small eyes? Not needed.
HUD: scoreValue.textContent = score; bestValue.textContent = bestScore. Update in render or on change.
Game loop start: function loop(now) { ... requestAnimationFrame(loop); }
Initial lastTime = performance.now(); lastTickTime = performance.now();
Need handle visibility? Not necessary.
AI BFS:
function getAIInput() {
// find direction from head to food
const head = snake[0];
const willEat = {x: head.x + dir.x, y: head.y + dir.y}; // actually dir is current movement direction. But AI should choose new dir.
// For BFS, target food. Blocked body: if moving to food? Actually food not on snake, so path to food can go through empty cells. Snake body cells are blocked except maybe tail. We'll use snake as blocked (or without tail? For path to food, tail may move, but okay).
const blocked = snake.map(p=>({...p})); // maybe exclude tail? Not critical.
// BFS from head to food
const queue = [{x: head.x, y: head.y, dir: null}]; // dir from parent? Actually store first move direction.
const visited = new Set();
visited.add(head.x + ',' + head.y);
while (queue.length) {
const cur = queue.shift();
if (cur.x === food.x && cur.y === food.y) {
// need direction from start to this. We can store parent.
}
}
}
Simpler BFS with parent map:
function bfs(start, goal, blocked) {
const key = (x,y) => x+','+y;
const queue = [start];
const visited = new Set([key(start.x,start.y)]);
const parent = new Map(); // key -> {key: parentKey, dir: {x,y}}
while (queue.length) {
const cur = queue.shift();
if (cur.x === goal.x && cur.y === goal.y) {
// reconstruct
let path = [];
let keyCur = key(cur.x,cur.y);
let prev = parent.get(keyCur);
while (prev) {
path.unshift({x: prev.dir.x, y: prev.dir.y});
keyCur = prev.key;
prev = parent.get(keyCur);
}
return path[0] || dir;
}
const dirs = [{x:1,y:0},{x:-1,y:0},{x:0,y:1},{x:0,y:-1}];
for (const d of dirs) {
const nx = cur.x + d.x;
const ny = cur.y + d.y;
if (nx < 0 || ny < 0 || nx >= COLS || ny >= ROWS) continue;
const k = nx+','+ny;
if (visited.has(k)) continue;
if (blocked.some(p => p.x===nx && p.y===ny)) continue;
visited.add(k);
parent.set(k, {key: key(cur.x,cur.y), dir: d});
queue.push({x:nx, y:ny});
}
}
return null;
}
But blocked includes head? start is head, visited so okay. Tail might block path; okay.
getAIInput:
const next = bfs({x: snake[0].x, y: snake[0].y}, food, snake);
if (next) {
// next is first direction. Check not reverse of dir? BFS won't go back because blocked? It might if tail moved, but reverse is previous cell which is in snake except maybe tail. Should be blocked or visited. Fine.
return next;
}
// fallback: choose direction not reverse with max distance? Just return dir? Better random safe.
const safe = [{x:1,y:0},{x:-1,y:0},{x:0,y:1},{x:0,y:-1}].filter(d => !(d.x === -dir.x && d.y === -dir.y));
return safe[Math.floor(Math.random()*safe.length)] || dir;
But BFS goal food; if food is adjacent, returns direction. Good.
Need blocked for BFS: if snake length > 1, maybe allow tail movement? Not necessary. But if snake is long, tail blocks path maybe causing no path. Could pass body = willEat? snake : snake.slice(0,-1). But AI choosing direction for next tick, willEat depends on new head. Hard. Use snake.slice(0,-1) maybe. Actually for movement, if not eating, tail moves, so can pass snake without tail. If eating, tail doesn't move, so full snake. Compute willEat for candidate? BFS doesn't know. Simple: blocked = snake.slice(0, -1) if snake.length > 1? But if food is at tail? Food not on snake. For path to food, tail may be in way but will move. Use snake.slice(0, -1) to be safe. But if snake length 1? Not possible. Fine.
Actually BFS from head to food, if path goes through tail, tail will move if not eating, so okay. If eating, tail won't move, but food not on tail, so path to food likely won't need tail. Use snake.slice(0,-1).
Now game loop and AI:
function doTick() {
if (mode === 'ai') {
dir = getAIInput();
}
const newHead = {x: snake[0].x + dir.x, y: snake[0].y + dir.y};
const willEat = newHead.x === food.x && newHead.y === food.y;
const body = willEat ? snake : (snake.length > 1 ? snake.slice(0, -1) : snake);
// check wall
if (newHead.x < 0 || newHead.x >= COLS || newHead.y < 0 || newHead.y >= ROWS) {
endGame();
return;
}
// check self
for (const seg of body) {
if (seg.x === newHead.x && seg.y === newHead.y) {
endGame();
return;
}
}
prevSnake = snake.map(p => ({...p}));
if (willEat) {
score += 10;
spawnParticles(newHead, food);
snake.push(newHead);
food = spawnFood();
} else {
snake.shift();
snake.push(newHead);
}
}
Wait: prevSnake should be set before move. I set before if? Yes need before shift/push. In code above, I computed body, then set prevSnake, then move. Good.
But for willEat, snake.push(newHead) without shift, so prevSnake length old, snake length old+1. For not willEat, shift+push, prevSnake old length = new length. Good.
endGame:
function endGame() {
gameOver = true;
running = false;
updateBest();
finalScoreEl.textContent = score;
finalScoreEl.style.display = 'block';
overlayMessage.style.display = 'none';
overlay.classList.remove('hidden');
}
Need running variable.
spawnFood uses snake. After eating, snake includes newHead, so food not on snake.
Particles spawn: function spawnParticles(pos, foodPos) { for 12... pos pixel center? Use grid coords. vx random. life 30. }
Update particles: for each, life--; x += vx; y += vy; vx *= 0.95; vy *= 0.95; filter alive.
Draw particles: alpha = life / maxLife; circle radius 3 * alpha.
Now HUD update: scoreValue.textContent = score; bestValue.textContent = bestScore; in render.
Overlay: HTML overlay div. Initially visible with message. When start, hide. When game over, show final score. When pause, show Paused.
Need overlay not intercept clicks after start? It's hidden via display none. Before start, visible. Add pointer-events? When visible before start, clicks should start. The click listener on window will start if !started. But overlay covers, so click hits overlay, bubbles to window, starts. Good. After started, overlay hidden, so no issue. During pause, overlay visible but started true, so click won't start; but pause overlay should maybe not restart. That's okay. Could add click on overlay during pause to resume? Not required.
Keyboard events: window keydown. If !started, any key starts? The requirement: "Click or press a key to start" message. So pressing any key should start. But we also handle arrows etc. If !started, start on keydown. Then maybe handle the key? For simplicity, in keydown, if (!started) { startGame(); } then continue? But if we continue, arrow might set player mode. Better: if (!started) { startGame(); } and return? But then pressing space starts, not restart. That's okay before start. After started, handle.
Let's write keydown:
window.addEventListener('keydown', (e) => {
const key = e.key.toLowerCase();
if (!started) {
e.preventDefault();
startGame();
// still allow? Probably return to avoid controlling immediately.
return;
}
// handle keys...
});
But if started, handle arrows etc.
Space/Enter: if (gameOver) { e.preventDefault(); restartGame(); return; } else if (key === 'p') { togglePause(); } else if (key === ' ') { e.preventDefault(); togglePause();? } Hmm. Requirement: Space or Enter restarts on game over, P pauses. So:
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
if (gameOver) { restartGame(); }
else if (paused) { togglePause(); } // maybe Enter resumes? Not required. Could just if gameOver restart; if !gameOver and !paused togglePause? Let's keep: if gameOver restart; else togglePause? But P pauses, so Space shouldn't pause? To avoid conflict, Space: if gameOver restart; else togglePause? Might be okay. Enter: if gameOver restart; else togglePause? Hmm.
Better:
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
if (gameOver) { restartGame(); }
else { togglePause(); } // Space/Enter toggles pause? But requirement says P pauses. Might be considered wrong. Let's not make Space pause. Instead: if gameOver restart; else if (paused) togglePause? No.
}
Let's just implement:
- P key: togglePause (only if started and !gameOver maybe).
- Space/Enter: if gameOver -> restartGame(); else -> togglePause()? The requirement only says Space/Enter restarts, P pauses. So Space/Enter should restart only on game over. During play, they can do nothing or start. To be safe: if gameOver restart; if !gameOver do nothing. P toggles pause. That satisfies. But if user presses Space during play, nothing. Fine.
So:
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
if (gameOver) { restartGame(); }
}
else if (key === 'p') { e.preventDefault(); togglePause(); }
Arrows/WASD: if (gameOver || paused) return? If paused, ignore. If gameOver, ignore. If mode player, set queue.
When mode is ai, and user presses arrow, set mode player and queue. But if mode is ai, AI will set dir on next tick. If we set mode player and queue, AI stops. Good.
function handleDir(newDir) {
if (gameOver || paused) return;
if (mode === 'ai') { mode = 'player'; inputQueue = []; }
const opposite = {x: -dir.x, y: -dir.y};
if (newDir.x === opposite.x && newDir.y === opposite.y) return;
inputQueue.push({...newDir});
}
In doTick, if mode === 'player': process inputQueue:
function processInput() {
if (inputQueue.length === 0) return false;
const candidate = inputQueue.shift();
const opposite = {x: -dir.x, y: -dir.y};
if (candidate.x === opposite.x && candidate.y === opposite.y) return processInput(); // invalid, skip
dir = candidate;
return true;
}
Then in doTick: if (mode === 'player') { processInput(); }
For AI, mode ai, getAIInput sets dir.
When switching to player, clear queue? We set inputQueue=[] before pushing? In handleDir, if mode ai, mode='player'; inputQueue=[]; then push. Good.
But if player presses two quick keys, queue works.
Now, when game over, mode maybe player or ai. Restart sets mode ai.
Now, AI demo start: startGame sets mode ai, running true. AI controls.
Need ensure snake moves smoothly and AI doesn't reverse. BFS returns direction maybe same as current? If food is ahead, could return same direction? BFS from head to food, first step could be same direction if moving towards food. That's okay, no reverse. But if dir already towards food, BFS might return same dir? BFS neighbors include current direction, but visited? Start is head, neighbor in dir is not visited, so could return same dir. That's fine, no change.
But AI might choose direction that is reverse? BFS avoids because reverse is previous cell which is in blocked (snake without tail? maybe tail moved, reverse might be tail? Actually reverse is where snake came from, which is body except maybe tail, so blocked). Good.
Now, render details.
Canvas drawing:
function render(t) {
const cell = canvas.width / COLS;
// bg
ctx.fillStyle = '#0b1220';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// grid
ctx.strokeStyle = 'rgba(148,163,184,0.08)';
ctx.lineWidth = 1;
ctx.beginPath();
for (let i=1; i<COLS; i++) { ctx.moveTo(i*cell + 0.5, 0); ctx.lineTo(i*cell + 0.5, canvas.height); }
for (let j=1; j<ROWS; j++) { ctx.moveTo(0, j*cell + 0.5); ctx.lineTo(canvas.width, j*cell + 0.5); }
ctx.stroke();
// particles
drawParticles();
// food
drawFood();
// snake
drawSnake(t);
// score? HUD HTML.
}
drawFood:
const pulse = 1 + Math.sin(performance.now() / 250) * 0.05;
const r = cell * 0.3 * pulse;
const x = food.x*cell + cell/2;
const y = food.y*cell + cell/2;
ctx.beginPath();
ctx.arc(x,y,r,0,Math.PI*2);
ctx.fillStyle = '#ef4444';
ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.2)';
ctx.lineWidth = 1;
ctx.stroke();
// maybe small shine
ctx.beginPath();
ctx.arc(x - r*0.3, y - r*0.3, r*0.25, 0, Math.PI*2);
ctx.fillStyle = 'rgba(255,255,255,0.35)';
ctx.fill();
drawSnake(t):
const positions = getPositions(t);
const cell = canvas.width / COLS;
const radius = cell * 0.36;
for (let i = positions.length - 1; i >= 0; i--) {
const p = positions[i];
const px = p.x * cell + cell/2;
const py = p.y * cell + cell/2;
const color = i === 0 ? '#4ade80' : '#22c55e';
ctx.beginPath();
ctx.arc(px, py, i === 0 ? cell*0.42 : radius, 0, Math.PI*2);
ctx.fillStyle = color;
ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.25)';
ctx.lineWidth = 1;
ctx.stroke();
}
// head eyes
const head = positions[0];
const hx = head.x * cell + cell/2;
const hy = head.y * cell + cell/2;
const d = dir;
const forward = {x: d.x, y: d.y};
const side = {x: -d.y, y: d.x}; // perpendicular
const eyeForward = {x: forward.x * cell*0.12, y: forward.y * cell*0.12};
const eyeSide = {x: side.x * cell*0.15, y: side.y * cell*0.15};
const eye1 = {x: hx + eyeForward.x + eyeSide.x, y: hy + eyeForward.y + eyeSide.y};
const eye2 = {x: hx + eyeForward.x - eyeSide.x, y: hy + eyeForward.y - eyeSide.y};
ctx.fillStyle = '#fff';
for (const eye of [eye1, eye2]) {
ctx.beginPath();
ctx.arc(eye.x, eye.y, cell*0.07, 0, Math.PI*2);
ctx.fill();
ctx.beginPath();
ctx.arc(eye.x + eye.x? wait pupil offset along eye direction? Eye direction is forward+side normalized? Actually pupil should face forward. So pupil offset = forward * cell*0.035.
ctx.arc(eye.x + forward.x*cell*0.035, eye.y + forward.y*cell*0.035, cell*0.035, 0, Math.PI*2);
ctx.fillStyle = '#0b1220';
ctx.fill();
}
ctx.fillStyle = '#fff';
for eye...
Better: draw white circles, then dark pupils offset forward.
Head eyes facing direction. Good.
Maybe draw head slightly darker outline? Fine.
drawParticles:
for (const p of particles) {
const alpha = Math.max(0, p.life / p.maxLife);
ctx.globalAlpha = alpha;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size * alpha, 0, Math.PI*2);
ctx.fillStyle = p.color;
ctx.fill();
}
ctx.globalAlpha = 1;
Particle positions in pixel coords. spawn at food center or newHead pixel. Use grid coords? Let's store pixel coords. On eat, const px = newHead.x*cell + cell/2; py = ...; spawnParticles(px,py).
Update particles in loop: p.x += p.vx; p.y += p.vy; p.vx *= 0.95; p.vy *= 0.95; p.life--; if (p.life <= 0) remove.
Now, HUD: scoreValue.textContent = score; bestValue.textContent = bestScore; in render.
Best score: updateBest on game over. Display current score and best score. During game, bestScore maybe max? Could update best when score exceeds? Session best maybe final best. Could set bestScore = Math.max(bestScore, score) on eat? Requirement "best score of the session" likely highest score reached. We can update bestScore whenever score > bestScore, and display. On game over, final. Let's do updateBest() in render? Or on eat. In spawn? Simpler: in render, if (score > bestScore) bestScore = score; bestValue.textContent = bestScore; But best should be max. Do that.
Now, initial state: resetGame sets snake, food. running=false, started=false. Overlay visible. scoreValue 0, bestValue 0.
Need call spawnFood in resetGame.
Let's write full code carefully.
Potential issues:
- BFS uses snake array of objects; body check. In getAIInput, if mode ai, dir may be set. BFS returns first direction. But if snake is long, BFS each tick might be okay.
- In doTick, for mode ai, dir = getAIInput(); getAIInput uses dir? It uses snake and food, not dir except fallback. BFS doesn't need dir. It might return a direction that is reverse? Shouldn't. But to be safe, after getting next, check not reverse of dir? Actually dir is current movement. BFS from head to food, first step could be reverse? Reverse is previous cell, which is in snake (unless tail moved). Since blocked uses snake.slice(0,-1), reverse might be tail? For a snake, previous cell is second segment, not tail unless length 2? If length 2, reverse is tail, which is allowed to move into? Actually when length 2, tail moves, so reverse might be allowed? But snake shouldn't reverse. To be safe, BFS can exclude the opposite of dir. Add check: if (d.x === -dir.x && d.y === -dir.y) continue; in BFS neighbors. Good.
BFS implementation:
function getAIInput() {
const start = {x: snake[0].x, y: snake[0].y};
const goal = {x: food.x, y: food.y};
const blocked = snake.length > 1 ? snake.slice(0, -1) : snake;
const key = (x,y) => x+','+y;
const queue = [start];
const visited = new Set([key(start.x, start.y)]);
const parent = new Map();
while (queue.length) {
const cur = queue.shift();
if (cur.x === goal.x && cur.y === goal.y) {
let path = [];
let curKey = key(cur.x, cur.y);
let prev = parent.get(curKey);
while (prev) {
path.unshift({x: prev.dir.x, y: prev.dir.y});
curKey = prev.key;
prev = parent.get(curKey);
}
if (path.length > 0) return path[0];
break;
}
const dirs = [{x:1,y:0},{x:-1,y:0},{x:0,y:1},{x:0,y:-1}];
for (const d of dirs) {
if (d.x === -dir.x && d.y === -dir.y) continue;
const nx = cur.x + d.x;
const ny = cur.y + d.y;
if (nx < 0 || ny < 0 || nx >= COLS || ny >= ROWS) continue;
const k = nx+','+ny;
if (visited.has(k)) continue;
if (blocked.some(p => p.x === nx && p.y === ny)) continue;
visited.add(k);
parent.set(k, {key: key(cur.x, cur.y), dir: d});
queue.push({x:nx, y:ny});
}
}
// fallback
const safe = [{x:1,y:0},{x:-1,y:0},{x:0,y:1},{x:0,y:-1}].filter(d => !(d.x === -dir.x && d.y === -dir.y));
return safe.length ? safe[Math.floor(Math.random()*safe.length)] : dir;
}
Note: BFS may return a direction that is not directly towards food? It returns first step on shortest path, which is optimal. Good.
Now, game loop:
let lastTime = performance.now();
let lastTickTime = performance.now();
let accumulator = 0;
function loop(now) {
requestAnimationFrame(loop);
const nowMs = performance.now();
let dt = nowMs - lastTime;
lastTime = nowMs;
if (dt > 100) dt = 100; // clamp
if (!paused && running && !gameOver) {
accumulator += dt;
while (accumulator >= tickInterval) {
accumulator -= tickInterval;
doTick();
lastTickTime = performance.now();
if (gameOver) break;
}
}
const t = Math.min(1, (performance.now() - lastTickTime) / tickInterval);
updateParticles(dt);
render(t);
}
But updateParticles should use dt. Pass dt.
In doTick, after moving, lastTickTime updated in loop. Good.
Initial lastTickTime = performance.now(); lastTime = performance.now();
Now, when paused, t uses lastTickTime, so snake stays. updateParticles maybe should not update when paused? Could update particles even when paused? Better only when !paused? Or just update with dt, but if paused, dt from rAF might be 16, particles would move. Not big. Could skip if paused.
Now, overlay HTML: need finalScore element. In pause, overlayMessage text Paused, finalScore display none.
Let's write final code.
Check no external requests. No fonts. No localStorage. No alert. No confirm. No prompt. No window.open. No storage. Good.
No horizontal scrollbar: body overflow-x hidden, canvas max width. Use box-sizing.
Adapt down to 375px: #wrap width min(94vw, 60vh, 560px). On mobile, 60vh maybe 400px, 94vw 352px, so min 352. Canvas internal 560, CSS width 100%. Cell size computed from canvas.width (internal) / COLS = 23.33, but displayed at 352, so scales? Actually canvas internal 560, CSS width 100% means displayed 352, but internal coordinate still 560, so it scales down? Canvas width attribute sets internal resolution, CSS width scales. That means cell size in internal units is 23.33, but displayed size is 352, so scale factor ~0.62, meaning circles drawn at internal radius 8.4 displayed as 5.2? Wait, if canvas internal is 560 and CSS width is 352, the canvas is scaled by browser. The drawing coordinates are in internal units, so it scales. That might make it blurry? Actually it's scaled, okay. But cell size = canvas.width / COLS = 23.33 internal. Displayed cell = 352/24 = 14.67 CSS px. The drawing scales accordingly. Fine.
But for crispness, maybe set canvas.width = size based on display. To avoid scaling, set canvas.width = Math.floor(canvas.clientWidth) etc. But on initial load, clientWidth might be 0? The wrap has aspect ratio, canvas width 100%. Could set in resize. Simpler: keep internal 560 and CSS width 100%, it will scale. At 1280x800, wrap max 560, so no scale. At 375, wraps to 352, internal 560, scale 0.628, might look slightly blurry but okay. To be polished, set internal resolution to display size. Let's implement resizeCanvas:
function resizeCanvas() {
const rect = canvas.getBoundingClientRect();
let w = rect.width;
if (w === 0) w = 560;
canvas.width = Math.max(240, Math.floor(w));
canvas.height = canvas.width;
}
window.addEventListener('resize', resizeCanvas);
But canvas CSS width is 100% of wrap, height auto? With aspect-ratio on wrap, canvas height might not be set. Better set canvas style width:100%; height:100%; and wrap aspect-ratio. Then getBoundingClientRect works. On load, call resizeCanvas.
In CSS: #wrap { position:relative; width: min(94vw, 60vh, 560px); aspect-ratio: 1/1; } canvas { width:100%; height:100%; display:block; }
JS: function resizeCanvas() { const w = canvas.clientWidth; const size = Math.max(240, Math.floor(w)); canvas.width = size; canvas.height = size; } window.addEventListener('resize', resizeCanvas); resizeCanvas();
Then cell = canvas.width / COLS. Good.
Now, overlay position absolute inset 0. Canvas is inside wrap. Overlay covers canvas.
Now, final code. Let's assemble.
We need to be careful with JS syntax and no errors.
Let's draft:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Snake</title>
<style>
html, body { margin:0; padding:0; height:100%; background:#0b1020; color:#e5e7eb; font-family:system-ui, -apple-system, sans-serif; overflow-x:hidden; }
#app { min-height:100vh; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:12px; padding:16px; box-sizing:border-box; }
#hud { width: min(94vw, 560px); display:flex; justify-content:space-between; font-size: clamp(14px, 2.5vw, 20px); font-weight:700; letter-spacing:1px; text-transform:uppercase; }
#hud .label { opacity:0.7; font-size:12px; }
#hud .value { font-size:24px; }
#wrap { position:relative; width:min(94vw, 60vh, 560px); aspect-ratio:1/1; }
canvas { width:100%; height:100%; display:block; background:#0b1220; border-radius:16px; border:1px solid #1e293b; }
#overlay { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:12px; background:rgba(2,6,23,0.72); border-radius:16px; text-align:center; padding:16px; box-sizing:border-box; }
#overlay.hidden { display:none; }
#overlay h1 { margin:0; font-size:clamp(20px,4vw,32px); }
#overlay p { margin:0; opacity:0.8; font-size:clamp(12px,2.5vw,16px); }
#overlay .final-score { font-size:clamp(28px,6vw,44px); font-weight:800; }
</style>
</head>
<body>
<div id="app">
<div id="hud">
<div>Score <span id="scoreValue" class="value">0</span></div>
<div>Best <span id="bestValue" class="value">0</span></div>
</div>
<div id="wrap">
<canvas id="game"></canvas>
<div id="overlay">
<h1>🐍 Snake</h1>
<p id="overlayMessage">Click or press a key to start</p>
<p id="finalScore" class="final-score" style="display:none;"></p>
<p id="overlayHint">Space/Enter restart · P pause</p>
</div>
</div>
</div>
<script>
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const overlay = document.getElementById('overlay');
const overlayMessage = document.getElementById('overlayMessage');
const finalScoreEl = document.getElementById('finalScore');
const scoreValue = document.getElementById('scoreValue');
const bestValue = document.getElementById('bestValue');
const COLS = 24;
const ROWS = 24;
const tickInterval = 130;
let size = 560;
function resizeCanvas() {
const w = canvas.clientWidth;
const sz = Math.max(240, Math.floor(w));
canvas.width = sz;
canvas.height = sz;
}
window.addEventListener('resize', resizeCanvas);
resizeCanvas();
let snake, food, prevSnake, score, bestScore, gameOver, paused, started, mode;
let dir, inputQueue, particles;
let running;
function resetGame() {
snake = [{x:8,y:12},{x:7,y:12},{x:6,y:12}];
prevSnake = snake.map(p=>({...p}));
dir = {x:1,y:0};
score = 0;
gameOver = false;
paused = false;
mode = 'ai';
inputQueue = [];
particles = [];
scoreValue.textContent = '0';
bestValue.textContent = bestScore;
overlay.classList.add('hidden');
overlayMessage.style.display = 'none';
finalScoreEl.style.display = 'none';
}
function startGame() {
started = true;
running = true;
resetGame();
}
function updateBest() {
if (score > bestScore) bestScore = score;
bestValue.textContent = bestScore;
}
function spawnFood() {
let ok = false;
let f;
do {
f = {x: Math.floor(Math.random()*COLS), y: Math.floor(Math.random()*ROWS)};
ok = !snake.some(s => s.x === f.x && s.y === f.y);
} while (!ok);
food = f;
}
function endGame() {
gameOver = true;
running = false;
updateBest();
finalScoreEl.textContent = score;
finalScoreEl.style.display = 'block';
overlayMessage.style.display = 'none';
overlay.classList.remove('hidden');
}
function spawnParticles(pos, cell) {
const colors = ['#ef4444','#f59e0b','#facc15','#4ade80','#60a5fa'];
for (let i=0; i<14; i++) {
const angle = Math.random()*Math.PI*2;
const speed = 2 + Math.random()*3;
particles.push({
x: pos.x, y: pos.y,
vx: Math.cos(angle)*speed,
vy: Math.sin(angle)*speed,
life: 30,
maxLife: 30,
size: cell*0.08 + Math.random()*cell*0.06,
color: colors[Math.floor(Math.random()*colors.length)]
});
}
}
function updateParticles(dt) {
for (let i=particles.length-1; i>=0; i--) {
const p = particles[i];
p.life -= dt/16.7;
p.x += p.vx;
p.y += p.vy;
p.vx *= 0.95;
p.vy *= 0.95;
if (p.life <= 0) particles.splice(i,1);
}
}
function drawParticles() {
for (const p of particles) {
const alpha = Math.max(0, p.life / p.maxLife);
ctx.globalAlpha = alpha;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size * alpha, 0, Math.PI*2);
ctx.fillStyle = p.color;
ctx.fill();
}
ctx.globalAlpha = 1;
}
function getAIInput() {
const start = {x: snake[0].x, y: snake[0].y};
const goal = {x: food.x, y: food.y};
const blocked = snake.length > 1 ? snake.slice(0, -1) : snake;
const key = (x,y) => x+','+y;
const queue = [start];
const visited = new Set([key(start.x, start.y)]);
const parent = new Map();
while (queue.length) {
const cur = queue.shift();
if (cur.x === goal.x && cur.y === goal.y) {
const path = [];
let curKey = key(cur.x, cur.y);
let prev = parent.get(curKey);
while (prev) {
path.unshift({x: prev.dir.x, y: prev.dir.y});
curKey = prev.key;
prev = parent.get(curKey);
}
if (path.length > 0) return path[0];
break;
}
const dirs = [{x:1,y:0},{x:-1,y:0},{x:0,y:1},{x:0,y:-1}];
for (const d of dirs) {
if (d.x === -dir.x && d.y === -dir.y) continue;
const nx = cur.x + d.x;
const ny = cur.y + d.y;
if (nx < 0 || ny < 0 || nx >= COLS || ny >= ROWS) continue;
const k = nx+','+ny;
if (visited.has(k)) continue;
if (blocked.some(p => p.x === nx && p.y === ny)) continue;
visited.add(k);
parent.set(k, {key: key(cur.x, cur.y), dir: d});
queue.push({x:nx, y:ny});
}
}
const safe = [{x:1,y:0},{x:-1,y:0},{x:0,y:1},{x:0,y:-1}].filter(d => !(d.x === -dir.x && d.y === -dir.y));
return safe.length ? safe[Math.floor(Math.random()*safe.length)] : dir;
}
function processInput() {
if (inputQueue.length === 0) return false;
const candidate = inputQueue.shift();
const opposite = {x: -dir.x, y: -dir.y};
if (candidate.x === opposite.x && candidate.y === opposite.y) return processInput();
dir = candidate;
return true;
}
function doTick() {
if (mode === 'ai') {
dir = getAIInput();
} else {
processInput();
}
const newHead = {x: snake[0].x + dir.x, y: snake[0]. y + dir.y};
const willEat = newHead.x === food.x && newHead.y === food.y;
const body = willEat ? snake : (snake.length > 1 ? snake.slice(0, -1) : snake);
if (newHead.x < 0 || newHead.x >= COLS || newHead.y < 0 || newHead.y >= ROWS) {
endGame();
return;
}
for (const seg of body) {
if (seg.x === newHead.x && seg.y === newHead.y) {
endGame();
return;
}
}
prevSnake = snake.map(p=>({...p}));
if (willEat) {
score += 10;
const cell = canvas.width / COLS;
const px = newHead.x*cell + cell/2;
const py = newHead.y*cell + cell/2;
spawnParticles({x: px, y: py}, cell);
snake.push(newHead);
spawnFood();
} else {
snake.shift();
snake.push(newHead);
}
}
let lastTime = performance.now();
let lastTickTime = performance.now();
let accumulator = 0;
function loop(now) {
requestAnimationFrame(loop);
const nowMs = performance.now();
let dt = nowMs - lastTime;
lastTime = nowMs;
if (dt > 100) dt = 100;
if (!paused && running && !gameOver) {
accumulator += dt;
while (accumulator >= tickInterval) {
accumulator -= tickInterval;
doTick();
lastTickTime = performance.now();
if (gameOver) break;
}
}
const t = Math.min(1, (performance.now() - lastTickTime) / tickInterval);
if (!paused) updateParticles(dt);
render(t);
}
function getPositions(t) {
const res = [];
for (let i=0; i<snake.length; i++) {
const curr = snake[i];
const prev = prevSnake[i] || curr;
const x = prev.x + (curr.x - prev.x) * t;
const y = prev.y + (curr.y - prev.y) * t;
res.push({x, y});
}
return res;
}
function render(t) {
const cell = canvas.width / COLS;
ctx.fillStyle = '#0b1220';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = 'rgba(148,163,184,0.08)';
ctx.lineWidth = 1;
ctx.beginPath();
for (let i=1; i<COLS; i++) {
ctx.moveTo(i*cell + 0.5, 0);
ctx.lineTo(i*cell + 0.5, canvas.height);
}
for (let j=1; j<ROWS; j++) {
ctx.moveTo(0, j*cell + 0.5);
ctx.lineTo(canvas.width, j*cell + 0.5);
}
ctx.stroke();
drawParticles();
// food
const pulse = 1 + Math.sin(performance.now() / 250) * 0.05;
const fr = cell * 0.3 * pulse;
const fx = food.x*cell + cell/2;
const fy = food.y*cell + cell/2;
ctx.beginPath();
ctx.arc(fx, fy, fr, 0, Math.PI*2);
ctx.fillStyle = '#ef4444';
ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.2)';
ctx.lineWidth = 1;
ctx.stroke();
ctx.beginPath();
ctx.arc(fx - fr*0.3, fy - fr*0.3, fr*0.25, 0, Math.PI*2);
ctx.fillStyle = 'rgba(255,255,255,0.35)';
ctx.fill();
// snake
const positions = getPositions(t);
const headRadius = cell * 0.42;
const bodyRadius = cell * 0.36;
for (let i=positions.length-1; i>=0; i--) {
const p = positions[i];
const px = p.x*cell + cell/2;
const py = p.y*cell + cell/2;
const color = i === 0 ? '#4ade80' : '#22c55e';
ctx.beginPath();
ctx.arc(px, py, i === 0 ? headRadius : bodyRadius, 0, Math.PI*2);
ctx.fillStyle = color;
ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.25)';
ctx.lineWidth = 1;
ctx.stroke();
}
const head = positions[0];
const hx = head.x*cell + cell/2;
const hy = head.y*cell + cell/2;
const d = dir;
const forward = {x: d.x, y: d.y};
const side = {x: -d.y, y: d.x};
const eyeForward = {x: forward.x * cell*0.12, y: forward.y * cell*0.12};
const eyeSide = {x: side.x * cell*0.15, y: side.y * cell*0.15};
const eye1 = {x: hx + eyeForward.x + eyeSide.x, y: hy + eyeForward.y + eyeSide.y};
const eye2 = {x: hx + eyeForward.x - eyeSide.x, y: hy + eyeForward.y - eyeSide.y};
ctx.fillStyle = '#fff';
for (const eye of [eye1, eye2]) {
ctx.beginPath();
ctx.arc(eye.x, eye.y, cell*0.07, 0, Math.PI*2);
ctx.fill();
ctx.beginPath();
ctx.arc(eye.x + forward.x*cell*0.035, eye.y + forward.y*cell*0.035, cell*0.035, 0, Math.PI*2);
ctx.fillStyle = '#0b1220';
ctx.fill();
ctx.fillStyle = '#fff';
}
// update hud
scoreValue.textContent = score;
if (score > bestScore) bestScore = score;
bestValue.textContent = bestScore;
}
function togglePause() {
if (gameOver) return;
if (!started) return;
paused = !paused;
if (paused) {
overlay.classList.remove('hidden');
overlayMessage.textContent = 'Paused';
overlayMessage.style.display = 'block';
finalScoreEl.style.display = 'none';
} else {
overlay.classList.add('hidden');
overlayMessage.style.display = 'none';
finalScoreEl.style.display = 'none';
}
}
function restartGame() {
startGame();
}
function handleDir(newDir) {
if (gameOver || paused) return;
if (mode === 'ai') {
mode = 'player';
inputQueue = [];
}
const opposite = {x: -dir.x, y: -dir.y};
if (newDir.x === opposite.x && newDir.y === opposite.y) return;
inputQueue.push({...newDir});
}
function startFromKey() {
if (!started) {
startGame();
}
}
window.addEventListener('click', () => {
if (!started) startGame();
});
window.addEventListener('keydown', (e) => {
const key = e.key.toLowerCase();
if (!started) {
e.preventDefault();
startGame();
return;
}
if (e.key === 'ArrowUp' || key === 'w') {
e.preventDefault();
handleDir({x:0,y:-1});
} else if (e.key === 'ArrowDown' || key === 's') {
e.preventDefault();
handleDir({x:0,y:1});
} else if (e.key === 'ArrowLeft' || key === 'a') {
e.preventDefault();
handleDir({x:-1,y:0});
} else if (e.key === 'ArrowRight' || key === 'd') {
e.preventDefault();
handleDir({x:1,y:0});
} else if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
if (gameOver) restartGame();
} else if (key === 'p') {
e.preventDefault();
togglePause();
}
});
resetGame();
bestScore = 0;
bestValue.textContent = '0';
scoreValue.textContent = '0';
spawnFood();
running = false;
started = false;
paused = false;
gameOver = false;
mode = 'ai';
dir = {x:1,y:0};
overlayMessage.textContent = 'Click or press a key to start';
overlayMessage.style.display = 'block';
finalScoreEl.style.display = 'none';
overlay.classList.remove('hidden');
requestAnimationFrame(loop);
Wait, at bottom: resetGame() sets started? No, resetGame doesn't set started. It sets gameOver etc. Then we set started=false. But resetGame sets mode ai, etc. Then we set started=false. Good. But resetGame sets overlay hidden? No, resetGame does overlay.classList.add('hidden'); overlayMessage.style.display='none'; finalScore display none. Then after resetGame, we set overlayMessage.textContent = 'Click or press a key to start'; overlayMessage.style.display='block'; overlay.classList.remove('hidden'); Good.
But resetGame sets scoreValue 0, bestValue bestScore. bestScore set after? We set bestScore=0 after resetGame, but resetGame uses bestScore variable. At that point bestScore is undefined? We declared let score, bestScore... but didn't initialize. In resetGame, bestValue.textContent = bestScore; so bestScore should be initialized to 0 before resetGame. Let's add initial values at top: let snake, food, prevSnake, score = 0, bestScore = 0, gameOver = false, paused = false, started = false, mode = 'ai'; let dir, inputQueue, particles; let running = false;
Then resetGame uses score=0 etc.
At bottom:
resetGame();
spawnFood();
running = false;
started = false;
paused = false;
gameOver = false;
mode = 'ai';
dir = {x:1,y:0};
overlayMessage.textContent = 'Click or press a key to start';
overlayMessage.style.display = 'block';
finalScoreEl.style.display = 'none';
overlay.classList.remove('hidden');
requestAnimationFrame(loop);
But resetGame already sets overlay hidden and message none, then we override. Good.
Need to ensure overlay initially visible before JS? Since JS sets it, okay. But first paint before JS? JS runs quickly.
Now, check variable declarations: let snake, food, prevSnake, score = 0, bestScore = 0, gameOver = false, paused = false, started = false, mode = 'ai'; let dir, inputQueue, particles; let running = false;
But `let score = 0, bestScore = 0, gameOver = false, paused = false, started = false, mode = 'ai';` is valid? Multiple declarators with initializers: let score = 0, bestScore = 0, gameOver = false, paused = false, started = false, mode = 'ai'; Yes.
Then `let dir, inputQueue, particles; let running = false;` Good.
Now, in resetGame, we set `score = 0; gameOver = false; paused = false; mode = 'ai'; inputQueue = []; particles = [];` etc.
In startGame: `started = true; running = true; resetGame();`
In restartGame: `startGame();`
In endGame: `gameOver = true; running = false; updateBest(); ...`
In loop: if (!paused && running && !gameOver) { ... }.
In render, we use `score > bestScore` to update best. But bestScore is let, so okay.
In drawSnake, we set ctx.fillStyle = '#fff'; then for eye, after drawing pupil we set ctx.fillStyle = '#fff'; but we also set ctx.fillStyle = '#0b1220' before pupil. Need careful:
ctx.fillStyle = '#fff';
for (const eye of [eye1, eye2]) {
ctx.beginPath();
ctx.arc(eye.x, eye.y, cell*0.07, 0, Math.PI*2);
ctx.fill();
ctx.beginPath();
ctx.arc(eye.x + forward.x*cell*0.035, eye.y + forward.y*cell*0.035, cell*0.035, 0, Math.PI*2);
ctx.fillStyle = '#0b1220';
ctx.fill();
ctx.fillStyle = '#fff';
}
Good.
Now, check for `newHead.y + dir.y` typo? I wrote `newHead = {x: snake[0].x + dir.x, y: snake[0]. y + dir.y};` Need space? `snake[0].y` is valid. I wrote `snake[0]. y`? In draft: `const newHead = {x: snake[0].x + dir.x, y: snake[0]. y + dir.y};` There is a space between dot and y? That would be invalid. Must be `snake[0].y`. I'll write correctly.
Now, final code block. Ensure exactly one fenced code block, no text before/after. The instruction: 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.
So final answer should be just ```html ... ```.
Let's finalize code, checking for syntax errors.
Potential issue: `aspect-ratio` may not be supported in older Chrome? Latest Chrome supports. Fine.
Potential issue: overlay has `#overlayHint` style not defined? I didn't define in CSS. I used `<p id="overlayHint">Space/Enter restart · P pause</p>` but no CSS for it. It's okay, default p style. But maybe add style? Not necessary.
Potential issue: HUD uses `text-transform:uppercase` but content "Score". Fine.
Potential issue: `ctx.lineWidth = 1;` then stroke. Fine.
Potential issue: `ctx.globalAlpha` used, reset to 1. Fine.
Potential issue: BFS `blocked.some` where blocked is array of objects. Fine.
Potential issue: In doTick, for mode 'player', processInput might return false, then dir unchanged. Good.
Potential issue: When mode is 'ai', getAIInput uses `dir` for reverse check. dir is current movement. Good.
Potential issue: When player takes control, mode becomes 'player'. But if game over, handleDir returns. Good.
Potential issue: Start overlay: when user presses key while !started, startGame is called, which sets started true, running true, resetGame. Then the keydown handler returns. So pressing arrow starts and ignores. Good.
Potential issue: Click listener on window: if (!started) startGame(); But after start, overlay hidden, so no click. During pause, overlay visible but started true, so click does nothing. Good.
Potential issue: Game over overlay: Space/Enter restarts. But keydown handler: if started, handles space/enter. If gameOver, restartGame. Good.
Potential issue: P pauses. togglePause checks gameOver, started. Good.
Potential issue: When game over, running false. Loop skips update. Render still draws. Overlay visible.
Potential issue: When restart, resetGame sets gameOver false, paused false, mode ai, etc. startGame sets started true, running true. Good.
Potential issue: Initial running false, started false. Loop runs, render draws. Overlay visible.
Potential issue: Particles update when not paused, even if running false? In loop, if (!paused) updateParticles(dt); so on game over, particles update. Fine.
Potential issue: `lastTickTime` initialized before loop. In loop, after doTick, set lastTickTime = performance.now(). Then t computed. Good.
Potential issue: `accumulator` not reset after game over? Not needed.
Potential issue: `tickInterval` 130ms. At 60fps, dt ~16, accumulator accumulates. Good.
Potential issue: `getPositions` uses prevSnake. On reset, prevSnake set. On tick, prevSnake set before move. Good.
Potential issue: On eat, snake.push(newHead) without shift, so prevSnake length old, snake length old+1. getPositions for i=old? Actually when eating, snake length increases. prevSnake length is old length. For i from 0 to old length-1, prev exists. For i = old length (new tail), prev = curr (since prevSnake[i] undefined). That means new tail is at newHead? Wait, when eating, we push newHead, so new snake array: indices 0..old length-1 are old segments shifted? Actually no shift, so old segments remain, and newHead appended at end. That means the snake grows at the tail, not moving? That's wrong! In Snake, when eating, you typically add new head and keep tail (grow). But the array representation: snake[0] is head. On move, you shift and push newHead. On eat, you should push newHead without shift, which means the tail stays, and newHead is added at end? Wait, if you don't shift, the array becomes [head, body..., newHead]? Actually before move, snake[0] is head, snake[1] is neck, etc. If you push newHead without shift, the newHead goes to the end, which is tail position, not head. That's incorrect. The correct way: on eat, you shift? No, standard: snake.pop() (remove tail) then unshift newHead? Or shift and push, but if eating, don't shift tail, so you push newHead and remove tail? Let's clarify.
Array with head at index 0. To move: new head at index 0, so shift all, push newHead at end. If growing, we don't want tail to move, so we should push newHead and NOT shift? Actually if we don't shift, the array order becomes: old head becomes index 0? No, array shift removes first element and pushes to end. If we skip shift, we just push newHead, so array becomes [oldHead, oldBody..., newHead]. That means oldHead is still head, and newHead is tail. That's wrong.
Correct: Always shift and push newHead. To grow, instead of shifting, we can pop the tail then unshift newHead? Or simply: if willEat, snake.pop(); then unshift newHead? Or shift and push, but don't shift? Let's think.
Standard with array head at index 0:
- Move: newHead = head + dir; snake.shift(); snake.push(newHead); // now newHead at end? Wait, shift removes head, so old neck becomes head, and newHead pushed at end. That means newHead is at end, not head! Oh no, I had the array orientation wrong.
Actually, in many JS implementations, snake array is stored with head at index 0, and movement is: remove last element (tail) if not growing, then unshift new head? Or shift and push? Let's define clearly.
If snake = [{head}, {neck}, {tail}], to move forward:
- newHead = head + dir.
- If not growing: remove tail, add newHead at front. So: snake.pop(); snake.unshift(newHead); But then order is newHead, old head, old neck... which is head at front. That's good.
- If growing: remove tail? No, growing means tail stays, so just unshift newHead without pop. So: snake.unshift(newHead); // length increases.
Alternatively, if we use shift/push, we need to reverse order? Let's check: snake.shift() removes head, array becomes [neck, tail]. push(newHead) adds to end -> [neck, tail, newHead]. That puts newHead at tail, which is wrong for head-at-front. So my previous logic was wrong. I should use unshift and pop, or use array with head at end.
To avoid confusion, let's use head at index 0 and use unshift/push correctly.
Let's redefine movement:
- snake array: index 0 = head, last = tail.
- newHead = head + dir.
- willEat = newHead equals food.
- body for collision: if willEat, use snake (tail stays); else use snake without tail (pop last).
- If willEat: snake.unshift(newHead); // no pop, so length increases. (Head at front, tail stays)
- Else: snake.pop(); snake.unshift(newHead); // remove tail, add head.
Wait, if not eating, we remove tail then add head. Order: snake.pop() removes last (tail), then unshift(newHead) adds at front. Result: newHead, old head, old neck... (tail removed). Good.
If eating, we just unshift(newHead) without pop, so old tail stays, newHead added at front. Length increases. Good.
But for interpolation, prevSnake should be copy of snake before move. Then after move, snake has newHead at front. getPositions should map from head to tail. With unshift, index 0 is new head, last is tail. prevSnake similarly. Interpolate per index. This works.
Let's update doTick accordingly.
Current code: snake = [{x:8,y:12},{x:7,y:12},{x:6,y:12}] with head at index 0? Yes, first is head, moving right? Actually dir = {x:1,y:0}, so head at (8,12), neck (7,12), tail (6,12). That's moving right? If x increases to the right, head is at rightmost, neck left, tail left. That's actually moving left? Wait, if dir is right, head should be to the right of neck. Here head (8) is right of neck (7), so moving right. Good. So head at index 0, x increases to right.
Movement: newHead = head + dir = (9,12). If not eating, we want new head (9) at index 0, old head (8) becomes neck, tail removed. So we should unshift newHead and pop tail. If eating, unshift newHead without pop. Let's implement.
doTick:
const newHead = {x: snake[0].x + dir.x, y: snake[0].y + dir.y};
const willEat = newHead.x === food.x && newHead.y === food.y;
const body = willEat ? snake : (snake.slice(0, -1)); // for collision, if not eating, tail moves away, so can ignore last.
Check wall/self.
prevSnake = snake.map(p=>({...p}));
if (willEat) {
score += 10;
spawnParticles(...);
snake.unshift(newHead);
spawnFood();
} else {
snake.pop();
snake.unshift(newHead);
}
Wait, if willEat, we unshift newHead without pop, so length increases. The old tail stays, new head added. Good. If not willEat, pop tail then unshift newHead. Good.
But check collision body: if willEat, snake includes tail that stays, so check against all. If not willEat, snake without tail, because tail will move. Good.
Now, prevSnake: before move, snake is [head, neck, tail]. prevSnake = copy. After move, snake is [newHead, oldHead, oldNeck] (if not eating, tail removed). Interpolate per index: index 0 newHead vs prev head, index 1 old head vs prev neck, etc. This works.
Let's update code accordingly.
Also, in resetGame, snake = [{x:8,y:12},{x:7,y:12},{x:6,y:12}]; dir = {x:1,y:0}; head at (8,12) moving right. Neck at (7,12) to left, tail (6,12). Good.
Now, AI BFS: start = snake[0], goal = food. blocked = snake without tail? Actually for path, if not eating, tail moves, so can use snake.slice(0,-1). But if eating, tail stays. For AI, we don't know. Use snake.slice(0,-1) to be safe? Actually if snake length > 1, tail can move, so blocked can exclude tail. But if food is at tail? Not possible. So use snake.slice(0,-1). In getAIInput, blocked = snake.length > 1 ? snake.slice(0, -1) : snake; This means tail is not blocked, which is okay for movement. But for path to food, tail might be in the way but will move. Fine.
Now, update code in final answer.
Let's also check HUD best score update: in render, `if (score > bestScore) bestScore = score; bestValue.textContent = bestScore;` This updates best during game. Good.
Now, final code.
One more thing: The instruction says "The page must look right in a 1280×800 viewport and adapt gracefully down to 375px wide. No horizontal scrollbar." Our CSS uses min(94vw, 60vh, 560px) for wrap. On 1280x800, 60vh = 480, so wrap 480. Canvas internal size set via resizeCanvas to clientWidth, which is 480? Wait, canvas clientWidth is based on CSS width. #wrap width min(94vw, 60vh, 560px) = min(1203, 480, 560) = 480. Canvas width 100%, height 100%. So clientWidth ~480. resizeCanvas sets canvas.width = 480. Good. On 375x667, 94vw=352, 60vh=400, min=352. clientWidth=352, canvas.width=352. Good.
Now, final answer code block.
Let's write it carefully, no syntax errors.
Check for `newHead.y + dir.y` -> must be `newHead.y = snake[0].y + dir.y;`? Actually newHead is object: `const newHead = {x: snake[0].x + dir.x, y: snake[0].y + dir.y};` I'll write correctly.
Check for `snake[0].y` in many places. Yes.
Check for `ctx.arc(eye.x + forward.x*cell*0.035, eye.y + forward.y*cell*0.035, cell*0.035, 0, Math.PI*2);` Good.
Check for `overlayHint` style not defined, but okay.
Check for `finalScoreEl` display none initially. resetGame sets display none, then bottom override sets display none. Good.
Check for `overlayMessage` style display block initially? resetGame sets display none, then bottom override sets display block and text. Good.
Check for `overlay.classList.remove('hidden');` after bottom override. Good.
Check for `requestAnimationFrame(loop);` at end. loop is function declaration, hoisted. Good.
Check for `let lastTime = performance.now();` etc. performance.now available.
Check for `window.addEventListener('resize', resizeCanvas);` and call resizeCanvas. Good.
Check for `canvas.clientWidth` might be 0 if layout not done? At script end, layout likely done. If 0, max(240, 0) = 240, then canvas internal 240, but CSS width might be larger? Actually clientWidth reflects CSS width, so if wrap has width, it's not 0. Good.
Check for `aspect-ratio: 1/1;` in CSS. Good.
Check for no external fonts. Yes.
Check for no localStorage. Yes.
Check for no alert/confirm/prompt. Yes.
Check for no window.open. Yes.
Check for no storage. Yes.
Check for no network requests. Yes.
Check for inline CSS/JS. Yes.
Check for keyboard events after click: The window keydown listener is on window, which receives after click. Good. Before click, iframe might not focus? The requirement: iframe only receives keyboard events after user clicks it. Our window listener will receive if focused. The overlay message says click or press key. Good.
Check for start on key: if (!started) { startGame(); return; } So any key starts. Good.
Check for P pause: if key === 'p', togglePause. But if paused, overlay shows Paused. Good.
Check for Space/Enter restart: if (e.key === ' ' || e.key === 'Enter') { if (gameOver) restartGame(); } Good.
Check for game over overlay showing score: finalScoreEl.textContent = score; display block. overlayMessage display none. overlay remove hidden. Good.
Check for restart: restartGame calls startGame, which calls resetGame. resetGame sets overlay hidden, message none, final none. startGame sets started true, running true. Good. But resetGame sets gameOver false, paused false, mode ai, etc. Good.
Check for pause during game: togglePause flips paused. If paused, overlay visible with Paused. If unpaused, overlay hidden. Good.
Check for player input: handleDir checks gameOver || paused. If mode ai, switches to player. Good.
Check for quick successive key presses: inputQueue push, processInput shifts and sets dir. Since we validate reverse, no reverse. Good.
Check for snake never reverse: handleDir ignores opposite of current dir. But what if player presses two keys quickly: moving right, presses up (valid, queue [up]), then left (valid relative to up? Actually handleDir checks against current dir (right), left is opposite of right, so it ignores left! That's a bug. Quick successive key presses must both be honored. If moving right, pressing up then left quickly: first up is valid (not opposite of right), pushed. Second left is opposite of current dir (right), so handleDir ignores it. But the user intended up then left; after up is queued, left should be valid relative to up, not current dir. So we should not validate against current dir in handleDir; instead just push, and processInput will validate against the last applied direction when it's time to apply. However, if user presses left directly while moving right, we should ignore. But with queue, if we push left while moving right, processInput will see candidate left, opposite of dir right, and skip it. So we can just push without checking in handleDir. But to avoid queue filling with invalid, processInput will skip. So in handleDir, just push if not gameOver/paused, and switch mode. No reverse check there. Let's update handleDir:
function handleDir(newDir) {
if (gameOver || paused) return;
if (mode === 'ai') { mode = 'player'; inputQueue = []; }
inputQueue.push({...newDir});
}
Then processInput validates against current dir. But what if user presses up then left? Queue: [up, left]. First tick: processInput shifts up, valid (not opposite of right), sets dir=up. Second tick: shifts left, valid (not opposite of up), sets dir=left. Both honored. Good. If user presses left while moving right: queue [left], processInput sees left opposite of right, skips, queue empty. Good.
So update handleDir and processInput. processInput already checks opposite of dir. Good.
In handleDir, remove reverse check.
Now, final code.
Let's also ensure `getAIInput` uses `dir` for reverse check, but if mode is ai, dir is current. Good.
Now, write final answer.