Mistral Large 4
by MistralReasoningClean
Running live in a sandboxed frame. Click inside it before using the keyboard.
How it went
- Total time
- 6 min 34 s
- Cost
- $0.065
- Output tokens
- 30,601
- Spent thinking
- 24,492 tokens
- Speed
- 78 tok/s
- Console errors
- 0
- First token after
- 1.7 s
- First visible text after
- 5 min 44 s
- Prompt tokens
- 508
- Code size
- 17.7 KB
- Finish reason
- stop
- Served by
- Mistral
- Model id
mistralai/mistral-large-4-0- Reasoning sent
{"effort":"high"}- Max output
- 262,144 tokens
- Generated
- Nov 12, 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 · 520 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Snake</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
body{
min-height:100vh;min-height:100dvh;
display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;
padding:14px;overflow:hidden;
background:radial-gradient(1100px 750px at 50% -12%, #17291f 0%, #0c1310 46%, #070b0e 100%);
color:#e2e8f0;font-family:system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
user-select:none;-webkit-user-select:none;-webkit-tap-highlight-color:transparent;
}
#hud{display:flex;align-items:center;justify-content:space-between;width:480px}
.logo{font-weight:800;font-size:20px;letter-spacing:3px;color:#4ade80;text-shadow:0 0 16px rgba(74,222,128,.45)}
.stats{display:flex;gap:10px}
.chip{min-width:88px;text-align:center;padding:6px 14px 7px;border-radius:12px;
background:rgba(74,222,128,.07);border:1px solid rgba(74,222,128,.22)}
.chip label{display:block;font-size:10px;letter-spacing:2.5px;text-transform:uppercase;color:#86efac;opacity:.85}
.chip b{display:inline-block;font-size:22px;line-height:1.15;font-variant-numeric:tabular-nums;color:#f0fdf4}
.chip.best{background:rgba(251,191,36,.06);border-color:rgba(251,191,36,.28)}
.chip.best label{color:#fcd34d}
.chip b.pop{animation:pop .28s ease}
@keyframes pop{50%{transform:scale(1.4);color:#4ade80}}
#wrap{position:relative;border-radius:18px;touch-action:none}
canvas{display:block;border-radius:18px;background:#0a1017;
box-shadow:0 0 0 1px rgba(74,222,128,.16),0 26px 70px rgba(0,0,0,.55),0 0 48px rgba(74,222,128,.10)}
.overlay{position:absolute;inset:0;z-index:5;display:flex;align-items:center;justify-content:center;
border-radius:18px;background:rgba(4,9,7,.66);backdrop-filter:blur(3px);-webkit-backdrop-filter:blur(3px);
opacity:0;visibility:hidden;transition:opacity .25s ease,visibility .25s ease}
.overlay.show{opacity:1;visibility:visible}
.card{text-align:center;padding:26px 42px;border-radius:16px;background:rgba(8,15,11,.85);
border:1px solid rgba(148,163,184,.16);box-shadow:0 18px 50px rgba(0,0,0,.5)}
.card h1{font-size:32px;letter-spacing:6px;margin-bottom:12px}
h1.lose{color:#f87171;text-shadow:0 0 24px rgba(248,113,113,.55)}
h1.win{color:#4ade80;text-shadow:0 0 24px rgba(74,222,128,.55)}
h1.ptitle{color:#e2e8f0;text-shadow:0 0 24px rgba(226,232,240,.25)}
.final{font-size:13px;letter-spacing:2px;text-transform:uppercase;color:#94a3b8}
.final b{display:block;font-size:46px;color:#4ade80;font-variant-numeric:tabular-nums;margin-top:2px}
.final.small{font-size:12px;margin-top:6px}
.final.small span{color:#fcd34d;font-weight:700}
.hint{margin-top:16px;font-size:13px;color:#94a3b8}
kbd{display:inline-block;padding:1px 7px;border-radius:6px;font-size:11px;font-family:inherit;
background:rgba(148,163,184,.14);border:1px solid rgba(148,163,184,.3);border-bottom-width:2px;color:#e2e8f0}
#demoBanner{position:absolute;left:50%;bottom:14px;transform:translateX(-50%);z-index:6;pointer-events:none;
white-space:nowrap;padding:9px 18px;border-radius:999px;font-size:12.5px;letter-spacing:1px;font-weight:600;
color:#a7f3d0;background:rgba(6,14,10,.82);border:1px solid rgba(74,222,128,.4);
animation:pulse 1.8s ease-in-out infinite}
#demoBanner.hidden{display:none}
@keyframes pulse{0%,100%{box-shadow:0 0 0 0 rgba(74,222,128,.4)}55%{box-shadow:0 0 0 9px rgba(74,222,128,0)}}
#foot{font-size:12px;color:#64748b;letter-spacing:.3px;text-align:center}
@media (max-width:430px){
#demoBanner{font-size:10.5px;padding:8px 13px;letter-spacing:.4px}
.card{padding:20px 26px}
.card h1{font-size:26px;letter-spacing:4px}
.chip{min-width:74px;padding:5px 10px 6px}
.chip b{font-size:19px}
.logo{font-size:17px}
}
</style>
</head>
<body>
<header id="hud">
<div class="logo">🐍 SNAKE</div>
<div class="stats">
<div class="chip"><label>Score</label><b id="score">0</b></div>
<div class="chip best"><label>Best</label><b id="best">0</b></div>
</div>
</header>
<div id="wrap">
<canvas id="game"></canvas>
<div class="overlay" id="overOverlay">
<div class="card">
<h1 id="overTitle" class="lose">GAME OVER</h1>
<div class="final">Score<b id="finalScore">0</b></div>
<div class="final small">Best <span id="finalBest">0</span></div>
<p class="hint">Press <kbd>Space</kbd> or <kbd>Enter</kbd> — or click — to restart</p>
</div>
</div>
<div class="overlay" id="pauseOverlay">
<div class="card">
<h1 class="ptitle">PAUSED</h1>
<p class="hint">Press <kbd>P</kbd> to resume</p>
</div>
</div>
<div id="demoBanner">▶ AI demo — click or press any key to play</div>
</div>
<footer id="foot">Arrow keys / WASD to move · <kbd>P</kbd> pause · <kbd>Space</kbd> restart</footer>
<script>
(() => {
'use strict';
const GRID = 24;
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const wrap = document.getElementById('wrap');
const hud = document.getElementById('hud');
const foot = document.getElementById('foot');
const scoreEl = document.getElementById('score');
const bestEl = document.getElementById('best');
const overOverlay = document.getElementById('overOverlay');
const pauseOverlay = document.getElementById('pauseOverlay');
const overTitle = document.getElementById('overTitle');
const finalScore = document.getElementById('finalScore');
const finalBest = document.getElementById('finalBest');
const demoBanner = document.getElementById('demoBanner');
const DIRS = {
arrowup:{x:0,y:-1}, arrowdown:{x:0,y:1}, arrowleft:{x:-1,y:0}, arrowright:{x:1,y:0},
w:{x:0,y:-1}, s:{x:0,y:1}, a:{x:-1,y:0}, d:{x:1,y:0}
};
const ALL_DIRS = [DIRS.arrowup, DIRS.arrowdown, DIRS.arrowleft, DIRS.arrowright];
let boardSize = 480, cellPx = 20;
let snake, prevSnake, dir, dirQueue, food, score, best = 0;
let alive, paused, tickMs, acc;
let particles = [], popups = [];
let mode = 'demo';
let shakeUntil = 0, demoRestartAt = 0;
/* ---------- layout ---------- */
function resize(){
const availW = window.innerWidth - 28;
const availH = window.innerHeight - 158;
boardSize = Math.max(230, Math.min(640, Math.floor(Math.min(availW, availH))));
const dpr = Math.min(2, window.devicePixelRatio || 1);
canvas.width = Math.round(boardSize * dpr);
canvas.height = Math.round(boardSize * dpr);
canvas.style.width = boardSize + 'px';
canvas.style.height = boardSize + 'px';
wrap.style.width = boardSize + 'px';
wrap.style.height = boardSize + 'px';
hud.style.width = boardSize + 'px';
foot.style.width = boardSize + 'px';
cellPx = boardSize / GRID;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
window.addEventListener('resize', resize);
/* ---------- game state ---------- */
function reset(){
snake = [{x:8,y:12},{x:7,y:12},{x:6,y:12}];
prevSnake = snake.map(c => ({x:c.x, y:c.y}));
dir = {x:1,y:0};
dirQueue = [];
score = 0; tickMs = 135; acc = 0;
particles = []; popups = [];
alive = true; paused = false; shakeUntil = 0;
placeFood();
updateHUD(false);
}
function placeFood(){
const occ = new Set(snake.map(c => c.x + ',' + c.y));
const empty = [];
for(let x=0;x<GRID;x++) for(let y=0;y<GRID;y++) if(!occ.has(x+','+y)) empty.push({x,y});
food = empty.length ? empty[(Math.random()*empty.length)|0] : null;
}
function pushDir(d){
const last = dirQueue.length ? dirQueue[dirQueue.length-1] : dir;
if(d.x === last.x && d.y === last.y) return; // same direction: ignore
if(d.x === -last.x && d.y === -last.y) return; // reversal: never allowed
if(dirQueue.length < 3) dirQueue.push(d);
}
function updateHUD(pop){
scoreEl.textContent = score;
bestEl.textContent = best;
if(pop){ scoreEl.classList.remove('pop'); void scoreEl.offsetWidth; scoreEl.classList.add('pop'); }
}
function burst(x, y, palette){
const colors = palette || ['#f87171','#fbbf24','#4ade80','#fef08c','#ffffff'];
for(let i=0;i<18;i++){
const a = Math.random()*Math.PI*2;
const sp = 70 + Math.random()*150;
particles.push({
x, y, vx:Math.cos(a)*sp, vy:Math.sin(a)*sp - 40,
life:1, decay:1.4 + Math.random()*1.4,
size:2 + Math.random()*3.2,
color:colors[(Math.random()*colors.length)|0]
});
}
}
/* ---------- logic step (fixed timestep) ---------- */
function step(){
prevSnake = snake.map(c => ({x:c.x, y:c.y}));
if(mode === 'demo'){
const d = aiDir();
if(d && !(d.x === -dir.x && d.y === -dir.y)) dir = d;
} else if(dirQueue.length){
const d = dirQueue.shift();
if(!(d.x === -dir.x && d.y === -dir.y)) dir = d;
}
const head = snake[0];
const nh = {x: head.x + dir.x, y: head.y + dir.y};
if(nh.x < 0 || nh.y < 0 || nh.x >= GRID || nh.y >= GRID) return die();
const willEat = food && nh.x === food.x && nh.y === food.y;
const body = willEat ? snake : snake.slice(0, -1); // tail cell frees up unless growing
for(const c of body) if(c.x === nh.x && c.y === nh.y) return die();
snake.unshift(nh);
if(willEat){
score += 10;
if(score > best) best = score;
tickMs = Math.max(75, 135 - Math.floor(score/10)*2);
burst((food.x+0.5)*cellPx, (food.y+0.5)*cellPx);
popups.push({x:(food.x+0.5)*cellPx, y:(food.y+0.42)*cellPx, life:1, text:'+10'});
placeFood();
updateHUD(true);
if(!food) return win();
} else {
snake.pop();
}
}
function die(){
alive = false;
shakeUntil = performance.now() + 320;
const h = snake[0];
burst((h.x+0.5)*cellPx, (h.y+0.5)*cellPx, ['#f87171','#fb923c','#ef4444','#fca5a5']);
if(mode === 'play'){
overTitle.textContent = 'GAME OVER';
overTitle.className = 'lose';
finalScore.textContent = score;
finalBest.textContent = best;
overOverlay.classList.add('show');
} else {
demoRestartAt = performance.now() + 1400;
}
}
function win(){
alive = false;
if(mode === 'play'){
overTitle.textContent = 'YOU WIN!';
overTitle.className = 'win';
finalScore.textContent = score;
finalBest.textContent = best;
overOverlay.classList.add('show');
} else {
demoRestartAt = performance.now() + 1400;
}
}
/* ---------- demo AI (BFS to food + flood-fill safety) ---------- */
function aiDir(){
const head = snake[0];
const occ = new Set(snake.slice(0,-1).map(c => c.x + ',' + c.y));
const key = (x,y) => x + ',' + y;
if(food){
const prev = new Map();
const startK = key(head.x, head.y);
prev.set(startK, null);
const q = [[head.x, head.y]];
let found = false;
while(q.length){
const cur = q.shift();
if(cur[0] === food.x && cur[1] === food.y){ found = true; break; }
for(const d of ALL_DIRS){
const nx = cur[0]+d.x, ny = cur[1]+d.y;
if(nx<0||ny<0||nx>=GRID||ny>=GRID) continue;
const k = key(nx,ny);
if(occ.has(k) || prev.has(k)) continue;
prev.set(k, key(cur[0], cur[1]));
q.push([nx, ny]);
}
}
if(found){
let k = key(food.x, food.y);
while(prev.get(k) !== startK && prev.get(k) !== null) k = prev.get(k);
if(prev.get(k) === startK){
const p = k.split(',');
return {x: Number(p[0]) - head.x, y: Number(p[1]) - head.y};
}
}
}
let bestD = null, bestSpace = -1;
for(const d of ALL_DIRS){
if(d.x === -dir.x && d.y === -dir.y) continue;
const nx = head.x + d.x, ny = head.y + d.y;
if(nx<0||ny<0||nx>=GRID||ny>=GRID) continue;
if(occ.has(key(nx,ny))) continue;
const seen = new Set([key(nx,ny)]);
const q = [[nx,ny]];
let cnt = 0;
while(q.length && cnt < 120){
const cur = q.shift(); cnt++;
for(const dd of ALL_DIRS){
const mx = cur[0]+dd.x, my = cur[1]+dd.y;
if(mx<0||my<0||mx>=GRID||my>=GRID) continue;
const mk = key(mx,my);
if(occ.has(mk) || seen.has(mk)) continue;
seen.add(mk); q.push([mx,my]);
}
}
if(cnt > bestSpace){ bestSpace = cnt; bestD = d; }
}
return bestD || dir;
}
/* ---------- rendering (interpolated for smooth motion) ---------- */
function render(alpha, now){
ctx.clearRect(0, 0, boardSize, boardSize);
ctx.save();
if(now < shakeUntil){
const m = (shakeUntil - now) / 320 * 6;
ctx.translate((Math.random()*2-1)*m, (Math.random()*2-1)*m);
}
for(let y=0;y<GRID;y++){
for(let x=0;x<GRID;x++){
ctx.fillStyle = (x+y)%2 ? '#0c141d' : '#0f1822';
ctx.fillRect(x*cellPx, y*cellPx, cellPx+0.5, cellPx+0.5);
}
}
drawFood(now);
drawSnake(alpha);
drawParticles();
drawPopups();
ctx.strokeStyle = 'rgba(74,222,128,0.20)';
ctx.lineWidth = 2;
ctx.strokeRect(1, 1, boardSize-2, boardSize-2);
ctx.restore();
}
function drawFood(now){
if(!food) return;
const cx = (food.x+0.5)*cellPx, cy = (food.y+0.5)*cellPx;
const r = cellPx*0.30*(1 + 0.12*Math.sin(now/180));
ctx.save();
ctx.shadowColor = '#fb7185'; ctx.shadowBlur = 16;
ctx.fillStyle = '#f43f5e';
ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI*2); ctx.fill();
ctx.shadowBlur = 0;
ctx.fillStyle = 'rgba(255,255,255,0.55)';
ctx.beginPath(); ctx.arc(cx - r*0.32, cy - r*0.38, r*0.26, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#4ade80';
ctx.beginPath();
ctx.ellipse(cx + r*0.18, cy - r*0.95, r*0.34, r*0.16, -0.5, 0, Math.PI*2);
ctx.fill();
ctx.restore();
}
function drawSnake(alpha){
const n = snake.length;
const pts = new Array(n);
for(let i=0;i<n;i++){
const c = snake[i];
const p = prevSnake[Math.min(i, prevSnake.length-1)] || c;
pts[i] = {
x:(p.x + (c.x-p.x)*alpha + 0.5)*cellPx,
y:(p.y + (c.y-p.y)*alpha + 0.5)*cellPx
};
}
ctx.save();
ctx.lineCap = 'round'; ctx.lineJoin = 'round';
ctx.shadowColor = 'rgba(74,222,128,0.35)';
ctx.shadowBlur = 8;
for(let i=n-1;i>0;i--){
const t = n<=1 ? 0 : i/(n-1);
ctx.strokeStyle = `hsl(${150 - t*30}, 70%, ${48 - t*18}%)`;
ctx.lineWidth = cellPx*(0.80 - 0.30*t);
ctx.beginPath();
ctx.moveTo(pts[i].x, pts[i].y);
ctx.lineTo(pts[i-1].x, pts[i-1].y);
ctx.stroke();
}
const h = pts[0];
ctx.shadowColor = 'rgba(74,222,128,0.7)';
ctx.shadowBlur = 18;
ctx.fillStyle = '#4ade80';
ctx.beginPath(); ctx.arc(h.x, h.y, cellPx*0.52, 0, Math.PI*2); ctx.fill();
ctx.shadowBlur = 0;
// eyes facing the direction of travel
const d = dir, perp = {x:-d.y, y:d.x};
const f = cellPx*0.18, s = cellPx*0.21;
const er = cellPx*0.14, pr = cellPx*0.07;
for(const side of [-1, 1]){
const ex = h.x + d.x*f + perp.x*s*side;
const ey = h.y + d.y*f + perp.y*s*side;
ctx.fillStyle = '#ffffff';
ctx.beginPath(); ctx.arc(ex, ey, er, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#0f172a';
ctx.beginPath(); ctx.arc(ex + d.x*er*0.5, ey + d.y*er*0.5, pr, 0, Math.PI*2); ctx.fill();
}
ctx.restore();
}
function drawParticles(){
if(!particles.length) return;
ctx.save();
for(const p of particles){
ctx.globalAlpha = Math.max(0, p.life);
ctx.fillStyle = p.color;
ctx.beginPath();
ctx.arc(p.x, p.y, Math.max(0.5, p.size*p.life), 0, Math.PI*2);
ctx.fill();
}
ctx.restore();
}
function drawPopups(){
if(!popups.length) return;
ctx.save();
ctx.textAlign = 'center';
ctx.font = '700 ' + Math.round(cellPx*0.6) + 'px system-ui, sans-serif';
for(const p of popups){
ctx.globalAlpha = Math.max(0, p.life);
ctx.fillStyle = '#fef08c';
ctx.fillText(p.text, p.x, p.y);
}
ctx.restore();
}
function updateParticles(s){
for(let i=particles.length-1;i>=0;i--){
const p = particles[i];
p.life -= p.decay*s;
if(p.life <= 0){ particles.splice(i,1); continue; }
p.x += p.vx*s; p.y += p.vy*s;
p.vy += 160*s;
p.vx *= (1 - 2.4*s); p.vy *= (1 - 2.4*s);
}
}
function updatePopups(s){
for(let i=popups.length-1;i>=0;i--){
const p = popups[i];
p.life -= s*1.1; p.y -= 34*s;
if(p.life <= 0) popups.splice(i,1);
}
}
/* ---------- main loop ---------- */
let lastTime = performance.now();
function frame(now){
requestAnimationFrame(frame);
let dt = now - lastTime; lastTime = now;
if(dt > 100) dt = 100;
if(mode === 'demo' && !alive && now > demoRestartAt) reset();
if(!paused && alive){
acc += dt;
let guard = 0;
while(acc >= tickMs && guard < 6){
acc -= tickMs; guard++;
step();
if(!alive){ acc = 0; break; }
}
}
if(!paused){ updateParticles(dt/1000); updatePopups(dt/1000); }
const alpha = (!paused && alive) ? Math.min(1, acc/tickMs) : 1;
render(alpha, now);
}
/* ---------- input ---------- */
function hideOverlays(){
overOverlay.classList.remove('show');
pauseOverlay.classList.remove('show');
}
function restart(){ reset(); hideOverlays(); }
function exitDemo(lk){
mode = 'play';
demoBanner.classList.add('hidden');
reset();
hideOverlays();
const d = DIRS[lk];
if(d) pushDir(d);
}
window.addEventListener('keydown', (e) => {
const k = e.key;
if(k === ' ' || k.indexOf('Arrow') === 0) e.preventDefault();
const lk = k.toLowerCase();
if(mode === 'demo'){ exitDemo(lk); return; }
if(!alive){
if(k === ' ' || k === 'Enter') restart();
return;
}
if(lk === 'p'){ paused = !paused; pauseOverlay.classList.toggle('show', paused); return; }
const d = DIRS[lk];
if(d) pushDir(d);
});
wrap.addEventListener('pointerdown', () => {
if(mode === 'demo') exitDemo('');
else if(!alive) restart();
});
let tsx = 0, tsy = 0;
wrap.addEventListener('touchstart', e => {
const t = e.touches[0]; tsx = t.clientX; tsy = t.clientY;
}, {passive:true});
wrap.addEventListener('touchend', e => {
const t = e.changedTouches[0];
const dx = t.clientX - tsx, dy = t.clientY - tsy;
if(Math.max(Math.abs(dx), Math.abs(dy)) < 24) return;
const d = Math.abs(dx) > Math.abs(dy) ? {x:Math.sign(dx), y:0} : {x:0, y:Math.sign(dy)};
if(mode === 'play' && alive && !paused) pushDir(d);
}, {passive:true});
/* ---------- init ---------- */
resize();
reset();
requestAnimationFrame(frame);
})();
</script>
</body>
</html>Reasoning trace (24,492 tokens)
Let me build a polished Snake game in a single HTML file with canvas, vanilla JS. Requirements:
- Grid at least 20x20, centered
- Arrow keys + WASD, buffered input so quick successive presses both honored, no 180° reversal
- Food +10 points, grow by 1, food never on snake
- Game over on wall/self hit, overlay with score, Space/Enter restarts, P pauses
- Smooth movement (interpolated rendering between grid steps), distinct head with eyes facing direction, particle burst on eat
- Score + best score of session (session — in-memory variable is fine; localStorage blocked)
- Self-playing demo mode behind "Click or press a key to start" message — an AI plays so it can be judged in action. So on load, show demo mode where AI plays automatically, with overlay "Click or press any key to start". When user clicks/presses key, switch to player control. Hmm, but game must be visible in action within first seconds — demo mode auto-plays snake. Then clicking starts player mode. Actually re-read: "Games must start in a self-playing demo mode (a simple AI plays) behind a 'Click or press a key to start' message". So yes: on load, AI demo runs; message overlay says click/press to start; on interaction, player takes over (restart fresh game with player control).
- 1280x800 viewport, adapt to 375px wide, no horizontal scrollbar.
- Zero console errors.
- No localStorage etc.
Design plan:
- Canvas sized responsively: compute cell size based on min(viewport) — grid 24x24 maybe. Let's do 24 columns x 24 rows? "at least 20x20". Use 24x24 for nicer look. Canvas square sized to fit: min(window.innerWidth - padding, window.innerHeight - header space). Centered with flexbox.
- Smooth movement: fixed timestep logic (e.g., tick every 110ms decreasing slightly with score?), render interpolation between previous and current positions using lerp with alpha = (now - lastTick)/tickInterval. Draw snake as rounded segments; head distinct with eyes oriented by direction.
- Input buffer: array of queued directions; on each tick, consume up to... "Quick successive key presses must both be honored" — meaning if user presses Up then Left quickly within one tick, both should apply (not collapsed into one), as long as neither reverses relative to the last applied direction. Standard approach: queue of directions; on keydown, push if not same as last queued and not opposite of last queued; at each tick, shift one direction and validate against current direction (not opposite); apply. To honor two presses in one tick? Typically one direction per tick. "Both honored" means pressing up then left within the same tick interval should result in snake going up then left on subsequent ticks, not just left. With a queue capped at e.g. 3 entries, that works.
Implementation: dirQueue = []. On keydown: map key to dir; last = dirQueue.length ? dirQueue[dirQueue.length-1] : currentDir; if (dir is opposite of last or same as last) ignore; else if dirQueue.length < 3 push. On tick: if dirQueue.length, next = dirQueue.shift(); if next not opposite of currentDir (and not same), currentDir = next.
- Food: random empty cell; draw as glowing apple-ish circle with pulse.
- Particles: on eat, spawn ~14 particles at food cell, animate outward, fade; update in render loop with dt.
- Score: +10 per food. Best score session variable in memory (max). Display in HUD above canvas: Score and Best.
- Game over overlay: semi-transparent dark panel over canvas with "Game Over", score, "Press Space / Enter to restart", maybe "best". Restart resets.
- Pause: P toggles pause; overlay "Paused — press P to resume". Also auto-pause on blur maybe (nice touch). Space/Enter restart when game over; during play Space could... nothing or also pause? Requirement: Space or Enter restarts (on game over), P pauses. I'll make Space/Enter restart only when game over; P toggles pause anytime.
- Demo mode: AI plays. Simple AI: BFS or greedy with safety check. Keep it simple but decent: compute path to food using BFS on grid avoiding snake body (except tail which will move); if path exists, follow first step; else choose any safe move (maximize free space / avoid immediate death); if none, keep going (will die → then demo restarts automatically? For demo mode, on death, auto restart after short delay so it keeps playing for screenshot). Actually the screenshot at 3s should show action — snake moving, maybe food. Demo mode runs from load. Overlay message "Click or press any key to start" — semi-transparent banner, not blocking view entirely? "behind a message" — the message sits over the demo. I'll show a centered pill at bottom or center-top: "▶ Demo — click or press any key to play". It should be visible but the game visible too. I'll place a semi-transparent overlay panel in center with the message but with pointer-events none so canvas visible around; or a banner at top of canvas. Let's do a centered translucent card with backdrop blur but the game still visible around edges... A centered card would cover the snake. Better: a slim banner across the top inside the canvas area: "DEMO MODE — click or press any key to play". That keeps game visible. I'll do a floating pill at bottom center of the canvas with pulse animation. Good.
On first user interaction (click or keydown), exit demo: start fresh game with player control, hide banner. If the key pressed is a direction, apply it.
Edge: keyboard events only after click per iframe note — but we also listen to keydown on window; fine. Show message regardless.
- Rendering details:
- Background: dark gradient page; canvas with rounded corners, subtle grid lines, border glow.
- Snake: draw body as series of circles/rounded rects with slight size taper toward tail; color gradient from head (bright green) to tail (darker). Use interpolation: each segment has prev pos and current pos; draw at lerp(prev, cur, alpha). Head bigger, with eyes: two white circles with dark pupils positioned based on direction. Maybe a tongue? Keep eyes.
- Actually for smoothness, store snake as array of cells; keep prevSnake copy each tick. Interpolate each index. When growing, tail stays (prev has same length? handle: if lengths differ, for missing prev use current).
- Food: pulsing red/apple circle with glow (shadowBlur), slight bob.
- Particles: on eat spawn at food cell center; each particle: pos, vel, life, color (mix of red/green/yellow), size; update each frame with dt; draw with globalAlpha.
- Also small screen shake on death? Optional; add tiny shake on game over. Nice polish.
- Trail/glow: shadowBlur on snake.
- Speed: tick interval starts at 130ms, decreases by ~1.5ms per food, min 70ms. Interpolation handles smoothness.
- Score popups: floating "+10" text at food location. Nice.
- HUD: header with title "SNAKE", score, best. Use system fonts, styled. Score updates with small animation (scale pop).
- Layout: page flex column center; header row above canvas with Score | Best; canvas below centered; footer hint "Arrows / WASD to move · P pause · Space restart". On small screens scale down.
Canvas sizing: JS resize: const size = Math.min(window.innerWidth - 32, window.innerHeight - 160, 640); cell = size / GRID; canvas.width = size * dpr etc. Use devicePixelRatio for crispness. Recompute on resize. Keep grid 24. Actually "at least 20×20 cells" — 24x24 good. Or 25? 24 divides nicely.
Draw grid lines subtle: stroke rgba(255,255,255,0.04) every cell, or a checkerboard pattern of two dark shades. Checkerboard looks polished: alternate cell fill #0f1720 / #0c131b. Rounded? Just fill rects.
Walls: draw border glow around play area.
Game over overlay: absolutely positioned div over canvas wrapper (position relative). Semi-transparent backdrop, card with "GAME OVER", score big, best, "Space / Enter to restart". Show/hide via class. Pause overlay similar: "PAUSED — P to resume".
Demo overlay: pill at bottom center of canvas wrapper: "▶ AI demo — click or press any key to take control". Pulsing.
State machine: mode: 'demo' | 'play'; paused flag; gameOver flag. In demo mode, AI computes direction each tick; input ignored (any key/click → switch to play: reset game, mode='play'). In play mode: if gameOver, Space/Enter restarts (also click? maybe click restarts too — but careful: click used to start; after game over, click restart is fine). P toggles pause in play mode (and demo? allow pause in demo too, fine).
Timer: use requestAnimationFrame loop with accumulator for logic ticks, plus render every frame with interpolation alpha = accumulator / tickInterval clamped [0,1].
Let me write logic:
```
const GRID = 24;
let snake, dir, dirQueue, food, score, best = 0, alive, tickMs, acc, lastTime;
let particles = [], popups = [];
```
reset(): snake = [{x:12,y:12},{x:11,y:12},{x:10,y:12}] moving right; dir={x:1,y:0}; queue=[]; score=0; tickMs=130; acc=0; placeFood(); alive=true; particles=[]; popups=[].
placeFood(): collect empty cells (not in snake); pick random; if none (win) → handle: maybe just keep food null and win message; rare. Handle gracefully: if no empty cells, set food=null and maybe game won → show overlay "You win!". Keep simple: if no empty cell, food=null; eating impossible; snake fills board — declare win with overlay. I'll add win handling minimal.
tick():
- prevSnake = snake.map(c=>({...c}))
- apply queued dir: while queue... actually shift one per tick: if dirQueue.length { const d = dirQueue.shift(); if (!(d.x === -dir.x && d.y === -dir.y) && !(d.x===dir.x&&d.y===dir.y)) dir = d; }
Note: reversal check against current dir at time of application. Since queue entries were validated against each other at enqueue time, chain validity holds.
- newHead = {x: head.x + dir.x, y: head.y + dir.y}
- wall collision: if out of bounds → die.
- self collision: check newHead against snake body. Tricky: if not eating, tail will move away, so colliding with current tail cell is okay unless growing. Standard: compute willEat = food && newHead equals food. Body to check = willEat ? snake : snake.slice(0, -1). If newHead in body → die.
- unshift newHead; if willEat: score+=10; best=max; tickMs = max(70, 130 - (score/10)*2); spawnParticles(food cell); popup; placeFood(); else snake.pop().
- store prevSnake for interpolation.
Wait: prevSnake must be captured before mutation. And when growing, prevSnake length = old length; new snake length = old+1. For rendering index i: prev = prevSnake[min(i, prevSnake.length-1)].
Render:
- clear; draw board checker; draw food; draw snake interpolated; particles; popups.
- Snake draw: iterate from tail to head so head on top. For each segment i (0=head): pos = lerp(prev, cur, alpha). Size: head radius bigger; body radius tapers: r = cell*0.42 * (1 - i/len*0.35). Draw circle with fill color: hue gradient: head #4ade80 → body #22c55e → tail #15803d. Use hsl: `hsl(${140 - i*1.5}, 70%, ${55 - i*0.6}%)` something. Add shadowBlur green for head.
- Head eyes: based on dir: eye offset perpendicular. eyes at head pos + dir*0.15cell ± perp*0.18cell; white circle r=0.12cell, pupil r=0.06cell offset toward dir.
- Also draw a small rounded "snout"? Eyes enough.
Food draw: at cell center, pulsing radius = cell*0.32*(1+0.1*sin(t/200)); color #f87171 with shadowColor red shadowBlur 12; maybe a little leaf: small green ellipse on top. Cute.
Particles: {x,y,vx,vy,life,maxLife,size,color}; update: pos += vel*dt; vel *= 0.92; life -= dt; draw circle alpha = life/maxLife. Spawn 16 with random angle speed 60–160 px/s scaled... work in cell units? Use pixel coords: store pixel positions. I'll compute everything in canvas pixels: cellPx = canvas.width/dpr / GRID... Manage coordinate system: set canvas.width = size*dpr; ctx.setTransform(dpr,0,0,dpr,0,0); work in CSS pixels where board is `size` px. cell = size/GRID.
Popups: {x,y,text,life} rising and fading.
Loop:
```
function frame(now){
requestAnimationFrame(frame);
const dt = Math.min(50, now - lastTime); lastTime = now;
if(!paused && alive){
acc += dt;
while(acc >= tickMs){ acc -= tickMs; step(); if(!alive) break; }
}
const alpha = (!paused && alive) ? Math.min(1, acc/tickMs) : 1;
render(alpha, now);
updateParticles(dt); // always animate particles even when paused? if paused freeze; fine to update only when !paused
}
```
Actually particles should animate even on game over (burst continues). Update particles regardless of pause? If paused, freeze everything — simpler: update particles when not paused OR when game over (so burst plays out after death). I'll update particles always except paused.
Screen shake on death: shakeTime = 300ms; in render, translate random by (shakeTime remaining) amount.
AI for demo:
```
function aiStep(){
// BFS from head to food avoiding snake body (treat tail as free if not eating soon—simplify: avoid all body except tail)
const head = snake[0];
const body = new Set(snake.slice(0, -1).map(c=>c.x+','+c.y));
// BFS
...
if path found: next step = first move from head toward path → dir
else: // choose move that doesn't immediately die and maximizes reachable space (flood fill)
for each of 4 dirs not opposite: nh = head+dir; if in bounds and not in body: score by flood fill count from nh; pick max; if none, keep dir.
}
```
BFS each tick on 24x24 = trivial cost. Compute dir and set dirQueue = [dir] or directly dir (since AI). But reversal prevention: AI picks non-opposite moves anyway; set dirQueue=[chosen] and let tick apply with validation.
In demo mode, call aiStep() before each tick (or set pending dir when tick occurs). Simplest: in step(), if mode==='demo', compute aiDir and push to dirQueue (if valid). Or compute at tick time: in tick, if demo, dir = aiChoose(). I'll do: at start of tick, if mode demo, compute ai direction and assign dir (with opposite check).
Demo death: when alive=false in demo mode, after 1.2s auto reset() and continue demo. Implement: on death, set demoRestartAt = now + 1200; in frame, if mode demo && !alive && now>demoRestartAt → reset(). Also show overlay? In demo, maybe show small "crash, restarting" — just auto restart, keep banner. Keep gameOver overlay only in play mode. Actually could show overlay in demo too but auto-dismiss; simpler: in demo, don't show overlay; just particles + auto restart. Hmm but the death moment visible is fine.
Input handling:
```
const keyMap = {ArrowUp:[0,-1], w:[0,-1], W:[0,-1], ... }
window.addEventListener('keydown', e => {
const k = e.key;
if (['ArrowUp','ArrowDown','ArrowLeft','ArrowRight',' '].includes(k)) e.preventDefault();
if (mode==='demo'){ startPlay(); /* and if direction key, queue it */ }
...
});
```
Careful: when starting play from demo via a direction key, we should apply that direction as initial queue (snake starts moving right; pressing Up should turn up). In startPlay(): reset(); mode='play'; then process the key as normal input.
Key handling in play mode:
- direction keys: push to queue if valid vs last queued/current.
- 'p'/'P': toggle pause (if alive). If game over, ignore.
- ' ' or 'Enter': if !alive (game over) → reset() (mode stays play); else nothing (maybe also pause? no).
- Also prevent default for arrows/space to avoid scroll.
Click: canvas wrapper click → if mode demo → startPlay(); else if !alive → reset(); (restart on click after game over is convenient).
Pause: paused flag; when paused, show overlay; render still draws board with alpha=1 and overlay div visible. P resumes. Also auto-pause on visibilitychange/blur in play mode (nice). Add window blur → if mode play && alive → paused=true. But in sandboxed iframe, blur may fire? Only on focus loss; fine. Actually careful: auto-pause could pause during screenshot if iframe loses focus — the demo mode should not auto-pause (only play mode). And screenshot happens without interaction, mode=demo, so no pause. Good. But if auto-pause triggers in play mode when user clicks away... acceptable. Hmm, but there's risk: window 'blur' might fire in headless? To be safe, skip auto-pause. I'll skip blur pause to avoid surprises. Or keep it — sandbox screenshot: no interaction, demo mode, no pause anyway. I'll include it but only in play mode. Actually risk: after user clicks iframe and then screenshot tool... only demo matters. Keep it, it's polish. Eh — simpler to skip. I'll skip auto-pause to minimize risk.
HUD:
```
<header> SNAKE logo | Score: X | Best: Y </header>
```
Design: dark page background radial gradient; neon green accents. Title with glow. Score boxes with monospace-ish font (use font-variant-numeric: tabular-nums).
Fonts: system stack: `font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;` Fine.
Viewport fit: body height 100vh (use 100dvh fallback), overflow hidden? "No horizontal scrollbar" — set overflow hidden on body to be safe? Vertical scroll on small screens might be needed at 375px wide: canvas size = min(375-24, availableHeight). availableHeight = window.innerHeight - header(~70) - footer(~30) - margins. Compute in JS: `const availH = window.innerHeight - 140;` size = clamp(min(window.innerWidth - 24, availH), 280, 640). At 375x? e.g. 375x667: size = min(351, 527) = 351. Fine, fits. Body overflow hidden to guarantee no scrollbars.
Overlays positioned absolute within `.board-wrap` which is position relative, width/height = size px. Canvas fills it. Border-radius 14px, overflow hidden? If overflow hidden, glow shadows clipped — use box-shadow outside instead; canvas itself can have border-radius via CSS and drawing within. Set canvas style width/height = size px, border-radius 16px, and wrapper same size. Overlay divs inside wrapper with border-radius inherit.
Game over overlay content:
```
<div class="overlay" id="overOverlay">
<div class="card">
<h1>GAME OVER</h1>
<div class="final-score">Score <b id="finalScore">0</b></div>
<div class="best">Best <b id="finalBest">0</b></div>
<p class="hint">Press <kbd>Space</kbd> or <kbd>Enter</kbd> to restart</p>
</div>
</div>
```
Pause overlay similar with "PAUSED" and "Press P to resume".
Demo banner:
```
<div class="demo-banner" id="demoBanner">▶ DEMO — click or press any key to play</div>
```
Position absolute bottom 14px center, pill, pulsing animation, pointer-events none (so click passes to wrapper handler). Wrapper has click listener anyway; banner pointer-events:none so clicks hit wrapper.
Also top-left inside board maybe show mode? Not needed.
HUD update: scoreEl.textContent etc; add 'pop' class re-trigger animation.
Now write render carefully.
```
function render(alpha, now){
const size = boardSize; const cell = size/GRID;
ctx.clearRect(0,0,size,size);
// shake
ctx.save();
if(shakeUntil > now){ const m = (shakeUntil-now)/300 * 6; ctx.translate((Math.random()*2-1)*m, (Math.random()*2-1)*m); }
// board
for y,x: fill checker: ctx.fillStyle = (x+y)%2 ? '#0d141d' : '#101a24'; fillRect(x*cell,y*cell,cell,cell);
// subtle vignette? skip
drawFood(now, cell);
drawSnake(alpha, cell);
drawParticles();
drawPopups();
ctx.restore();
}
```
drawSnake:
```
const n = snake.length;
for(let i=n-1;i>=0;i--){
const cur = snake[i];
const prev = prevSnake[Math.min(i, prevSnake.length-1)] || cur;
const px = (prev.x + (cur.x-prev.x)*alpha + 0.5) * cell;
const py = (prev.y + (cur.y-prev.y)*alpha + 0.5) * cell;
const t = i/(n-1||1);
const r = cell * (i===0 ? 0.46 : 0.42*(1 - t*0.35));
// color
...
ctx.beginPath(); ctx.arc(px,py,r,0,Math.PI*2); fill
}
```
Head extras: eyes. dir used = current dir (or interpolated? current fine).
```
const hx = ..., hy = ...;
const d = dir; const perp = {x:-d.y, y:d.x};
eye offset: forward = 0.16*cell along d; side = 0.19*cell along perp;
for s of [-1,1]: eye center = (hx + d.x*f + perp.x*side*s, hy + d.y*f + perp.y*side*s)
white r=0.13*cell; pupil = eye + d*0.05*cell, r=0.065*cell, fill #0b1220.
```
Also maybe draw head slightly elongated: draw ellipse? Circle fine with eyes.
Wrap-around lerp issue: if snake wraps... no wrap (walls kill). But prev→cur interpolation across tick boundary is fine. However when snake moves right from x=5 to 6, prev.x=5 cur.x=6 → smooth. Good. Edge: at spawn prevSnake=snake copy; fine.
One subtlety: when eating, tail doesn't move: prev tail = cur tail → alpha irrelevant, stays. Good, growth appears at tail... actually growth: new head added at front; tail stays; body segments shift. Interpolation per index: index i cur = old index i-1. prev lookup: prevSnake[i] for i>=1 is old segment i — wrong pairing! Let me think: new snake = [newHead, old0, old1, ..., old(n-1)] when growing (no pop). For render index i (0-based, 0=head): cur = snake[i]; the segment that was at prev index i-1 for i>=1. But I look up prevSnake[min(i, len-1)] = prevSnake[i], which is the old segment i — mismatched by one, causing segments to appear to slide backward... Actually typical approach: keep prev positions aligned by index where prevSnake is the previous array; when growing, new tail = old tail (index n-1 old = index n new? new length = old+1: new[n] = old[n-1]. So new index i corresponds to old index i-1 for i>=1, and new[0] has no prev (use old[0]? head moved from old[0] to new[0], so prev for new[0] should be old[0]). Hmm: new[0]=newHead, old[0]=oldHead — the head moved one cell, so prev of new[0] is old[0]. And new[i]=old[i-1] for i>=1, so prev of new[i] is old[i-1]... but also old[i] was the position of segment... The physical segment that is now at new[i] was at old[i-1]. So prev lookup should be prevSnake[i-1] for i>=1, and prevSnake[0] for i=0? That gives head prev = old head ✓, segment 1 prev = old[0] = old head ✓ (segment 1 was the head). Wait new[1] = old[0], and its prev position is old[0]?? No — new[1] IS old[0] (same cell, no movement). The segment at new[1] was at old[0] before the tick and is at old[0]... hold on. When growing: snake.unshift(newHead); no pop. So new[1] = old[0], new[2] = old[1], etc. So segment now at index i was previously at index i-1 — but its previous *position* is old[i-1] which equals its current position new[i]?? No! new[i] = old[i-1] means current cell of segment i equals previous cell of segment i-1... I'm confusing cell values with segment identities.
Let's define positions array P (cells). After tick without growth: P' = [newHead, P0, P1, ..., P(n-2)] (dropped P(n-1)). Segment rendering by index: index i shows cell P'[i], interpolated from P[i]? P'[i] = P[i-1] for i>=1. Hmm, that means rendering index i at lerp(P[i], P'[i]) = lerp(P[i], P[i-1]) — that moves each rendered blob from P[i] toward P[i-1], i.e., everything slides backward?? That's wrong direction. Let me recheck: standard snake: head moves forward: newHead = P0 + dir. New array: [newHead, P0, P1, ..., P(n-2)]. So P'[0]=newHead (moved from P0 ✓ forward), P'[1] = P0 — but the blob that was at P0 should now appear at... the body follows the head: the segment behind head was at P0? No — before tick, segments at P0(head), P1, P2... After tick, head at newHead, and the segment that was head is now second — it should be at... it moves to where the head was? No! In snake, each segment moves to the position of the segment ahead of it. So after tick: new head at newHead; old head segment now at P0? But P0 is where old head was. Hmm, the old head moves forward to newHead, and the second segment moves to old head's position P0. So new positions: P' = [newHead, P0, P1, ..., P(n-2)]. Yes that's what unshift+pop gives. So blob at render index i: currently at P'[i], previously at P[i]?? Blob at index 1 now at P0; before tick, which blob was at P0? The head (index 0). So blob identities shift too. For smooth rendering, we track positions per index: render index i interpolates from P[i] to P'[i]. P[1]→P'[1] = P1→P0: moves backward one cell?? That seems wrong but visually: the body as a whole moves forward. Rendering each index from its old cell to new cell: index 0: P0→newHead (forward ✓). index 1: P1→P0 (backward ✗?).
Hmm wait, that's not right. Let me redo: P' after unshift(newHead) and pop(): P' = [newHead, P0, P1, ..., P(n-2)]. P'[1] = P0. Old P[1] = P1. So lerp from P1 to P0 = moving from cell (x+1?) ... depends on direction. If moving right, P0=(10,12), P1=(9,12), P2=(8,12). newHead=(11,12). P' = [(11,12),(10,12),(9,12)]. Render index 1: from P[1]=(9,12) to P'[1]=(10,12) — moves forward (right) by one cell ✓. I made an arithmetic slip: P'[1]=P0=(10,12), P[1]=(9,12) → lerp moves right ✓. Great, so per-index interpolation from prevSnake[i] to snake[i] works: every index moves forward by dir. And index 0: P0=(10,12) → newHead=(11,12) ✓ forward.
When growing (no pop): P' = [newHead, P0, ..., P(n-1)], length n+1. Render index i: prev = prevSnake[i] for i<=n-1, and prevSnake[n-1] for i=n (new tail: prev = old tail, cur = old tail → stays ✓). And index i in 1..n: cur P'[i]=P[i-1], prev P[i] → moves forward ✓. Index 0: P0→newHead ✓. So rule: prev = prevSnake[Math.min(i, prevSnake.length-1)] — for i=n (new tail), min(n, n-1)=n-1 = old tail ✓. For others prevSnake[i] ✓. My original formula was correct.
Self-collision check: body to check against: if willEat, all of snake; else snake minus tail (tail moves away). Note: with prevSnake available, standard: check newHead against snake.slice(0, snake.length - (willEat?0:1)).
Food spawn: build empty list; if empty → win: set food=null, alive=false, win=true → overlay "YOU WIN". Rare; include for robustness.
Particles spawn at food cell center in px coords:
```
function burst(cx, cy){ // px coords
const colors = ['#f87171','#fbbf24','#4ade80','#ffffff'];
for(let i=0;i<18;i++){ const a=Math.random()*Math.PI*2; const sp=60+Math.random()*140;
particles.push({x:cx,y:cy,vx:Math.cos(a)*sp,vy:Math.sin(a)*sp,life:1,decay:1.5+Math.random()*1.5,size:2+Math.random()*3,color:colors[i%4]}); }
}
```
Update with dt seconds: p.x += vx*dt; p.vx*=Math.pow(0.02, dt)? use damping: p.vx -= p.vx*3*dt. life -= decay*dt; remove when <=0. Draw alpha=life.
Popup: {x,y,life:1,text:'+10'}; y -= 30*dt; life-=dt*1.2; draw font bold cell*0.5, alpha life.
Score pop animation in HUD: on score change, add class, remove after animation (use el.classList.remove('pop'); void el.offsetWidth; el.classList.add('pop')).
tickMs schedule: base 135, minus 2 per food, min 75. Also demo mode maybe slightly slower for looks: same fine.
Now the "best score of the session": in-memory `best`. Display in HUD "BEST 0". Update on game over or live (max). Live update fine.
Restart: Space/Enter when game over → reset() and hide overlay. Also in demo mode any key starts play. Also what if user presses P in demo? It would start play mode then pause? Handle: in demo, any keydown → startPlay() first, then handle key (if 'p' → toggle pause → paused immediately; weird but harmless). Better: in demo mode, if key is p/P, just start play without pausing? Simplest: on demo keydown: startPlay(); then continue processing the key normally EXCEPT we don't want Space to do anything weird (Space when alive does nothing). P would pause right after start — acceptable, user can press P again. Actually let's make demo exit ignore the key's normal effect except direction keys: if key is direction, queue it; if p, ignore; if space/enter, ignore. Cleaner: exitDemo(key): startPlay(); if dirKey → pushDir. And click: startPlay() only.
startPlay(): mode='play'; hide demoBanner; reset(); hide overlays.
Also when game over in play mode and user presses direction key — ignore (only space/enter restart). Or also allow direction to restart? Spec says Space/Enter restart. Keep spec; click also restarts (add hint "or click").
Keydown handler details:
```
window.addEventListener('keydown', (e)=>{
const k = e.key;
const lower = k.length===1 ? k.toLowerCase() : k;
if(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight',' '].includes(k)) e.preventDefault();
if(mode==='demo'){ exitDemo(k); return; }
if(!alive){ if(k===' '||k==='Enter'){ reset(); hideOver(); } return; }
if(lower==='p'){ togglePause(); return; }
const d = DIRS[lower] || DIRS[k];
if(d) pushDir(d);
});
```
DIRS: map 'arrowup' etc and 'w','a','s','d'. Use lowercased key: 'arrowup'. Build:
```
const DIRS = {arrowup:{x:0,y:-1}, arrowdown:{x:0,y:1}, arrowleft:{x:-1,y:0}, arrowright:{x:1,y:0}, w:{x:0,y:-1}, s:{x:0,y:1}, a:{x:-1,y:0}, d:{x:1,y:0}};
```
lower = k.toLowerCase() works for arrows too ('ArrowUp'→'arrowup').
pushDir(d): const last = dirQueue.length? dirQueue[dirQueue.length-1] : dir; if((d.x===-last.x&&d.y===-last.y)||(d.x===last.x&&d.y===last.y)) return; if(dirQueue.length<3) dirQueue.push(d);
Note initial dir = right; pressing left ignored ✓. Pressing up then quickly left: queue [up, left]; tick1 applies up (dir up); tick2: shift left, check vs dir(up): left not opposite of up ✓ apply. Both honored ✓.
Edge: pressing up then down quickly: enqueue up (last=dir=right, up ok), then down: last queued = up, down is opposite of up → rejected ✓. Good — prevents reversal onto self.
tick step:
```
function step(){
prevSnake = snake.map(c=>({x:c.x,y:c.y}));
if(mode==='demo'){ const d = aiDir(); if(d) dir = d; }
else if(dirQueue.length){ const d = dirQueue.shift(); if(!(d.x===-dir.x&&d.y===-dir.y)) dir = d; }
const head = snake[0];
const nh = {x: head.x+dir.x, y: head.y+dir.y};
if(nh.x<0||nh.y<0||nh.x>=GRID||nh.y>=GRID) return die();
const willEat = food && nh.x===food.x && nh.y===food.y;
const body = willEat ? snake : snake.slice(0, snake.length-1);
for(const c of body){ if(c.x===nh.x&&c.y===nh.y) return die(); }
snake.unshift(nh);
if(willEat){
score += 10; if(score>best) best = score; updateHUD(true);
tickMs = Math.max(75, 135 - Math.floor(score/10)*2);
burst((food.x+0.5)*cellPx, (food.y+0.5)*cellPx);
popups.push({x:(food.x+0.5)*cellPx, y:(food.y)*cellPx, life:1, text:'+10'});
placeFood();
if(!food){ return win(); }
} else snake.pop();
}
```
cellPx computed at resize: cellPx = boardSize/GRID. Keep global.
die(): alive=false; shakeUntil = performance.now()+350; if(mode==='play'){ show overlay with score; best already updated } else { demoRestartAt = now+1400 } — need now; use performance.now(). Also burst red particles at head? Add small red burst at head on death. Nice.
win(): alive=false; show overlay "YOU WIN!" with score. Treat like game over overlay but title changes. I'll have overlay title element settable.
aiDir(): BFS:
```
function aiDir(){
const head = snake[0];
const occupied = new Set(snake.slice(0,-1).map(c=>c.x+','+c.y)); // tail cell will free up (approx)
// BFS to food
if(food){
const prev = new Map(); const key=(x,y)=>x+','+y;
const q=[[head.x,head.y]]; prev.set(key(head.x,head.y), null);
let found=false;
while(q.length){ const [x,y]=q.shift(); if(x===food.x&&y===food.y){found=true;break;}
for(const d of [DIRS.arrowup,DIRS.arrowdown,DIRS.arrowleft,DIRS.arrowright]){
const nx=x+d.x, ny=y+d.y;
if(nx<0||ny<0||nx>=GRID||ny>=GRID) continue;
if(occupied.has(nx+','+ny)) continue;
if(prev.has(nx+','+ny)) continue;
prev.set(nx+','+ny, key(x,y)); q.push([nx,ny]);
}
}
if(found){
// walk back to first step
let k = key(food.x,food.y); let first = k;
while(prev.get(k) !== null && prev.get(k) !== key(head.x,head.y)){ first = k; k = prev.get(k); }
if(prev.get(k) === key(head.x,head.y)) first = k;
const [fx,fy] = first.split(',').map(Number);
return {x: fx-head.x, y: fy-head.y};
}
}
// fallback: safe move maximizing flood-fill space
const dirs = [DIRS.arrowup,DIRS.arrowdown,DIRS.arrowleft,DIRS.arrowright].filter(d=>!(d.x===-dir.x&&d.y===-dir.y));
let bestD=null, bestScore=-1;
for(const d of dirs){
const nx=head.x+d.x, ny=head.y+d.y;
if(nx<0||ny<0||nx>=GRID||ny>=GRID) continue;
if(occupied.has(nx+','+ny)) continue;
// flood fill count
const seen=new Set([nx+','+ny]); const q=[[nx,ny]]; let cnt=0;
while(q.length){ const [x,y]=q.shift(); cnt++;
for(const dd of [DIRS.arrowup,...]){ ... if in bounds && !occupied && !seen → add }
if(cnt>60) break;
}
if(cnt>bestScore){bestScore=cnt;bestD=d;}
}
return bestD || dir; // if none, keep going (will die)
}
```
Wait the walk-back logic: prev maps cell→parent. Start at food, walk back until parent is head's key; the cell whose parent is head is the first step. My loop: k=food key; while prev.get(k) != null and prev.get(k) != headKey: first=k; k=parent. After loop, if prev.get(k)==headKey then first=k (the cell adjacent to head). Let me simplify:
```
let k = key(food.x, food.y);
while(prev.get(k) !== headKey){ k = prev.get(k); if(k===null) break; }
// k is now the cell adjacent to head (its parent is headKey) — but only if food reachable and headKey in chain
const [fx,fy]=k.split(',').map(Number);
return {x:fx-head.x, y:fy-head.y};
```
Since found=true guarantees chain from food back to head (headKey has parent null, so loop terminates when prev.get(k)===null at head... hmm: prev.get(headKey) = null. Chain: food → ... → head. Walking: eventually k = cell whose parent is headKey; prev.get(k)===headKey → loop stops. But if food===head? impossible (food not on snake). Also if head adjacent... fine. But guard: while(prev.get(k)!==headKey && prev.get(k)!==null). Then if prev.get(k)===null → shouldn't happen when found. OK.
Edge: occupied excludes tail; if path goes through tail cell it's allowed — fine approximation. Also BFS avoids body so no self-collision generally; but after following, tail moves — using slice(0,-1) accounts for that. Good enough for demo.
Demo speed: same tickMs; fine.
Now sizes/resizing:
```
const GRID=24;
let boardSize=480, cellPx=20, dpr=1;
function resize(){
const headerH = document.querySelector('header').offsetHeight; // or fixed estimate
const availW = window.innerWidth - 28;
const availH = window.innerHeight - 150;
boardSize = Math.max(240, Math.min(640, Math.min(availW, availH)));
dpr = Math.min(2, window.devicePixelRatio||1);
canvas.width = Math.round(boardSize*dpr); canvas.height = Math.round(boardSize*dpr);
canvas.style.width = boardSize+'px'; canvas.style.height = boardSize+'px';
wrap.style.width = boardSize+'px'; wrap.style.height = boardSize+'px';
cellPx = boardSize/GRID;
ctx.setTransform(dpr,0,0,dpr,0,0);
}
window.addEventListener('resize', resize);
```
At 1280x800: availH = 800-150=650, availW=1252 → boardSize=640 (capped). Header ~64 + canvas 640 + footer ~40 + gaps = ~760 fits in 800 ✓. Let me compute layout: body padding 16; header height ~56; margin 12; canvas 640; footer ~28; total ≈ 16+56+12+640+12+28+16 = 780 ✓.
At 375x667: availW=347, availH=517 → 347. total ≈ 16+56+12+347+12+28+16=487 ✓.
Use flex column, align center, justify center, min-height 100vh (100dvh), box-sizing border-box.
Header design: width matches boardSize? Set header width to boardSize via JS too, or just let it be auto centered. I'll set header width = boardSize px in resize for alignment polish. Footer same.
HUD elements: left: title "🐍 SNAKE" — emoji ok? It's text, fine. Maybe use a small canvas-drawn? Emoji fine. Right side: two stat chips: SCORE n, BEST n.
CSS polish: dark bg `radial-gradient(1200px 800px at 50% -10%, #14251c, #0a0f14 60%)`; chips with border 1px rgba(74,222,128,.25), bg rgba(74,222,128,.06), rounded 10px, padding. Numbers tabular.
Canvas CSS: border-radius 18px; box-shadow: 0 0 0 1px rgba(74,222,128,.18), 0 20px 60px rgba(0,0,0,.5), 0 0 40px rgba(74,222,128,.08); background #0b1118.
Overlays: absolute inset 0, display flex center, background rgba(5,10,8,.72), backdrop-filter blur(4px), border-radius inherit, opacity/visibility transition. Card: text center; h1 letter-spacing, color #f87171 for game over glow red; score big 48px green.
kbd style: border, rounded, padding 1px 6px, bg rgba(255,255,255,.08).
Demo banner: absolute, left 50%, bottom 16px, transform translateX(-50%), padding 8px 16px, border-radius 999px, bg rgba(10,20,14,.8), border 1px solid rgba(74,222,128,.35), color #a7f3d0, font-size 13px, letter-spacing .5px, animation pulse 1.6s infinite, pointer-events none, white-space nowrap. On narrow screens font smaller. Text: "▶ AI DEMO — click or press any key to play". At 375px width, board 347 — banner text might overflow: allow font-size 11px at small via media query, and max-width 90% with ellipsis? Better: shorter text "▶ Demo — press any key to play". Keep ~30 chars, at 11px ≈ 200px fits.
Pause overlay: id pauseOverlay, h1 "PAUSED", hint "Press P to resume".
Game over overlay: h1 id overTitle "GAME OVER", score line: "SCORE <span id=finalScore>", "BEST <span id=finalBest>", hint kbd Space / Enter. Also "or click to restart".
Also maybe show overlay in demo when demo snake dies? No—auto restart; skip.
Now first paint: demo mode running from load → snake visible moving within 3s ✓. Show banner. HUD score 0 best 0. Good.
requestAnimationFrame loop start immediately; lastTime init performance.now().
Pause behavior: when paused, skip stepping; render with alpha=1; overlays shown. Resume on P. Also if paused and user presses space? nothing.
Also handle Enter key: e.key === 'Enter'.
Prevent default for space always (avoid page scroll) — body overflow hidden anyway, but preventDefault good.
visibility of overlays: use class 'show' toggling opacity/visibility.
updateHUD(pop): scoreEl.textContent = score; bestEl.textContent = best; if pop, animate.
Also update finalScore/finalBest when showing overlay.
Let me also add subtle grid dots? Checkerboard enough. Add border inside canvas: draw rect stroke with rgba(74,222,128,.25) lineWidth 2 at inset 1.
Food drawing with pulse:
```
function drawFood(now){
if(!food) return;
const cx=(food.x+0.5)*cellPx, cy=(food.y+0.5)*cellPx;
const p = 1 + 0.12*Math.sin(now/180);
const r = cellPx*0.32*p;
ctx.save();
ctx.shadowColor='#fb7185'; ctx.shadowBlur=14;
ctx.fillStyle='#f43f5e';
ctx.beginPath(); ctx.arc(cx,cy,r,0,Math.PI*2); ctx.fill();
ctx.shadowBlur=0;
ctx.fillStyle='#fda4af'; // highlight
ctx.beginPath(); ctx.arc(cx-r*0.3, cy-r*0.35, r*0.28,0,Math.PI*2); ctx.fill();
// leaf
ctx.fillStyle='#4ade80';
ctx.beginPath(); ctx.ellipse(cx+r*0.15, cy-r*0.85, r*0.3, r*0.16, -0.6, 0, Math.PI*2); ctx.fill();
ctx.restore();
}
```
Snake colors: head #86efac? Let's do gradient: i=0: hsl(145, 80%, 60%); body: `hsl(${142 - i*1.2}, 65%, ${52 - Math.min(20, i*0.8)}%)`. Compute lightness decreasing to ~35%. Tail darker. Add glow: ctx.shadowColor='rgba(74,222,128,.55)'; shadowBlur = i===0?16:8; but shadow per segment expensive-ish; 24 segments fine.
Rounded body look: drawing circles per segment with radius slightly larger than half cell and overlapping gives smooth tube: r = cellPx*0.45 with spacing cellPx → overlapping circles create continuous body ✓. Taper: r_i = cellPx*(0.46 - 0.18*i/n). Head r=0.48.
Actually to look like a connected snake, overlap: distance between consecutive centers = cellPx (during interpolation less), radius 0.46*cellPx → overlap since 2r=0.92cell < cell?? 2*0.46=0.92 < 1 → gap! Need r >= 0.5*cell for touching. Use r = cellPx*0.5 at body → circles tangent, still lumpy at joints during turns. Better: draw body as a thick stroked path through segment centers with lineCap/lineJoin round, lineWidth = cellPx*0.8, then head circle on top. That gives smooth tube ✓. Stroke path through interpolated centers from tail to head. Color gradient along path is hard with single stroke; use per-segment strokes: for each i from tail to head, stroke line from center[i] to center[i-1] with lineWidth tapering and color per segment. With round caps, joints blend smoothly. That's a common technique and looks good.
Implementation:
```
function drawSnake(alpha){
const pts = snake.map((c,i)=>{ const p = prevSnake[Math.min(i,prevSnake.length-1)]||c;
return { x:(p.x+(c.x-p.x)*alpha+0.5)*cellPx, y:(p.y+(c.y-p.y)*alpha+0.5)*cellPx }; });
ctx.save();
ctx.lineCap='round'; ctx.lineJoin='round';
for(let i=snake.length-1;i>0;i--){
const t = i/(snake.length-1);
const w = cellPx*(0.78 - 0.30*t);
ctx.strokeStyle = segColor(i, snake.length);
ctx.lineWidth = w;
ctx.beginPath(); ctx.moveTo(pts[i].x, pts[i].y); ctx.lineTo(pts[i-1].x, pts[i-1].y); ctx.stroke();
}
// head
const h = pts[0];
ctx.shadowColor='rgba(74,222,128,.6)'; ctx.shadowBlur=18;
ctx.fillStyle = '#4ade80' gradient? use radial? simple: '#5eead4'? Keep green '#4ade80'.
ctx.beginPath(); ctx.arc(h.x,h.y,cellPx*0.52,0,Math.PI*2); ctx.fill();
ctx.shadowBlur=0;
// eyes
...
ctx.restore();
}
```
Wait shadow on head only; body segments small shadow: set shadowBlur=6 with shadowColor green for body strokes? Shadow on strokes also fine. I'll set ctx.shadowColor='rgba(74,222,128,.35)'; ctx.shadowBlur=8 for the loop, then bigger for head.
segColor(i,n): `hsl(${150 - i*(40/Math.max(1,n))}, 70%, ${55 - i*(18/Math.max(1,n))}%)` → head area bright green, tail teal-dark. i=0 is head but loop draws i from n-1..1 (segments between i and i-1, color of i). Head circle separate bright. Fine.
Eyes:
```
const d = dir; const perp = {x:-d.y, y:d.x};
const er = cellPx*0.13, pr = cellPx*0.065;
const f = cellPx*0.18, s = cellPx*0.20;
for(const side of [-1,1]){
const ex = h.x + d.x*f + perp.x*s*side;
const ey = h.y + d.y*f + perp.y*s*side;
ctx.fillStyle='#fff'; ctx.beginPath(); ctx.arc(ex,ey,er,0,Math.PI*2); ctx.fill();
ctx.fillStyle='#0f172a'; ctx.beginPath(); ctx.arc(ex+d.x*er*0.45, ey+d.y*er*0.45, pr,0,Math.PI*2); ctx.fill();
}
```
Head radius 0.52cell, eyes at f=0.18cell forward, side 0.20 → within head ✓ (dist = sqrt(0.18²+0.20²)=0.269cell + er 0.13 = 0.40 < 0.52 ✓).
Maybe add tongue on interval? Skip.
Particles draw:
```
ctx.save(); for p: ctx.globalAlpha=Math.max(0,p.life); ctx.fillStyle=p.color; arc(p.x,p.y,p.size*p.life+0.5) fill; restore (reset alpha).
```
Popups:
```
ctx.save(); ctx.font=`700 ${cellPx*0.55}px system-ui`; ctx.textAlign='center'; globalAlpha=life; fillStyle '#fef08c'; fillText(p.text, p.x, p.y); restore.
```
Board border: after board fill, stroke inner rect: ctx.strokeStyle='rgba(74,222,128,0.22)'; lineWidth 2; strokeRect(1,1,size-2,size-2).
Also draw wall glow? The canvas box-shadow suffices.
HUD HTML:
```
<header id="hud">
<div class="logo">🐍 <span>SNAKE</span></div>
<div class="stats">
<div class="chip"><label>Score</label><b id="score">0</b></div>
<div class="chip best"><label>Best</label><b id="best">0</b></div>
</div>
</header>
<div id="wrap">
<canvas id="game"></canvas>
<div class="overlay" id="overOverlay"> ... </div>
<div class="overlay" id="pauseOverlay"> ... </div>
<div id="demoBanner">▶ AI demo — click or press any key to play</div>
</div>
<footer>Arrows / WASD move · P pause · Space restart</footer>
```
Header width set in resize to boardSize. Footer same.
Score pop CSS:
```
.chip b.pop{animation:pop .25s ease}
@keyframes pop{50%{transform:scale(1.35); color:#4ade80}}
```
b display inline-block for transform.
Overlay CSS:
```
.overlay{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:rgba(4,9,7,.68);backdrop-filter:blur(3px);opacity:0;visibility:hidden;transition:.25s;border-radius:inherit;z-index:5}
.overlay.show{opacity:1;visibility:visible}
.card{text-align:center; padding: 24px 32px; ...}
```
Card maybe with border glow. h1: font-size 34px; letter-spacing 4px; margin. Game over color #f87171 text-shadow 0 0 18px rgba(248,113,113,.6). Win color green.
final score: font-size 56px bold color #4ade80.
hint: color #94a3b8, font-size 13px, margin-top 14px. kbd styling.
Demo banner z-index 4 (below overlays? overlays hidden in demo anyway). pointer-events none.
Now demo auto-restart: in frame loop:
```
if(mode==='demo' && !alive && performance.now()>demoRestartAt) reset();
```
die() sets demoRestartAt = performance.now()+1500.
Also in demo, maybe show a subtle "demo" state — banner suffices.
exitDemo(key):
```
function exitDemo(key){
mode='play';
demoBanner.classList.add('hidden'); // display none
reset();
hideOverlays();
const d = DIRS[key.toLowerCase()];
if(d) pushDir(d);
}
```
Note reset() sets alive=true etc. Also hide pause overlay, paused=false.
Click handler on wrap:
```
wrap.addEventListener('pointerdown', ()=>{ if(mode==='demo') exitDemo(''); else if(!alive) { reset(); hideOver(); } });
```
Use 'pointerdown' for responsiveness. Also keydown covers keys.
But note: "The iframe only receives keyboard events after the user clicks it" — fine, we show message.
reset():
```
function reset(){
snake=[{x:8,y:12},{x:7,y:12},{x:6,y:12}];
prevSnake=snake.map(c=>({...c}));
dir={x:1,y:0}; dirQueue=[]; score=0; tickMs=135; acc=0;
particles=[]; popups=[]; alive=true; paused=false; shakeUntil=0;
placeFood(); updateHUD(false);
}
```
Start length 3 at left-center moving right. GRID=24 → center y=12, x 6..8.
placeFood():
```
function placeFood(){
const occ=new Set(snake.map(c=>c.x+','+c.y));
const empty=[];
for(let x=0;x<GRID;x++)for(let y=0;y<GRID;y++) if(!occ.has(x+','+y)) empty.push({x,y});
food = empty.length? empty[(Math.random()*empty.length)|0] : null;
}
```
die():
```
function die(){
alive=false;
shakeUntil=performance.now()+320;
burst at head: red/orange colors;
if(mode==='play'){ best=Math.max(best,score); updateHUD(false); overTitle.textContent='GAME OVER'; overTitle.className='over-title lose'; finalScore.textContent=score; finalBest.textContent=best; show overOverlay; }
else demoRestartAt=performance.now()+1400;
}
```
win similar with title 'YOU WIN!' class 'win'.
Pause:
```
function togglePause(){ if(mode!=='play'||!alive) return; paused=!paused; pauseOverlay.classList.toggle('show',paused); }
```
Also allow pausing in demo? If demo paused, banner still there; fine to allow: condition mode any && alive. I'll allow any mode: if(!alive) return; paused=!paused; toggle overlay. But in demo, keydown exits demo first... P in demo → exitDemo then togglePause → paused play. Eh, that's okay but weird. Let's make demo ignore P for exit? Simplest: in demo mode, any key exits demo (as spec says "press a key to start"). If it's P, game starts paused — user presses P again. Acceptable. Or treat P in demo as just exit without pause. I'll special-case: in exitDemo, only push direction keys, don't toggle pause. Since exitDemo returns after handling, keydown handler returns early — so P won't pause. Good: keydown: if(mode==='demo'){ exitDemo(k); return; } and exitDemo only applies direction.
Frame loop:
```
let lastTime=performance.now();
function frame(now){
requestAnimationFrame(frame);
let dt = now-lastTime; lastTime=now;
if(dt>100) dt=100;
if(mode==='demo' && !alive && now>demoRestartAt){ reset(); }
if(!paused && alive){
acc+=dt;
let steps=0;
while(acc>=tickMs && steps<5){ acc-=tickMs; step(); steps++; if(!alive){acc=0;break;} }
}
if(!paused){ updateParticles(dt/1000); updatePopups(dt/1000); }
const alpha = (!paused&&alive)? Math.min(1,acc/tickMs) : 1;
render(alpha, now);
}
```
Hmm particles on death: alive=false → particles still update (not paused) ✓ burst plays.
updateParticles(s): for each: p.life-=p.decay*s; p.x+=p.vx*s; p.y+=p.vy*s; p.vx*= (1-2.2*s); p.vy*=(1-2.2*s); also gravity slight p.vy+=120*s? cute. filter life>0.
updatePopups(s): p.life-=s*1.1; p.y-=34*s.
render uses `now` for shake & food pulse.
One more: when paused, render still called each frame with alpha=1 — board static, fine.
updateHUD(pop):
```
scoreEl.textContent=score; bestEl.textContent=best;
if(pop){ scoreEl.classList.remove('pop'); void scoreEl.offsetWidth; scoreEl.classList.add('pop'); }
```
Init: resize(); reset(); requestAnimationFrame(frame). mode='demo' initial; banner visible.
Also ensure `dir` initial right and demo AI will take over.
Potential issue: In demo mode, step() sets dir = aiDir() directly (bypassing queue) — but must ensure not opposite: aiDir filters opposite of dir ✓ (fallback returns dir itself if none safe).
Also AI BFS occupied set uses snake.slice(0,-1) — but if about to eat, tail won't move; minor, ignore.
Check step() in demo: aiDir computed with current dir — fine.
Edge: dirQueue in demo unused.
Now the "no horizontal scrollbar": body{overflow:hidden} plus everything centered. Also html,body height 100%.
Mobile touch? Not required. Could add swipe — optional; skip (keys required anyway). Maybe add simple touch swipe for niceness — not required; skip to reduce risk. Actually cheap to add: touchstart/touchend delta → pushDir. I'll add minimal swipe support (doesn't hurt). Hmm, pointerdown on wrap already handles demo exit; swipe: record touchstart coords, on touchend compute dominant axis → pushDir. Add it — low risk. But pointerdown handler also fires on touch (pointerdown) — fine, exits demo. Then swipe turns. OK include.
Let me now also double check requirement "Quick successive key presses must both be honored" — queue approach ✓ with cap 3. Some implementations cap at 2; 3 is fine. Also ensure that within same tick two presses both eventually applied — yes across two ticks. Could also apply multiple per tick? No — one per tick standard; "honored" means not dropped. Good.
"new food never spawns on the snake" ✓.
"overlay showing the score" ✓.
"Space or Enter restarts, P pauses" ✓.
"smooth movement" ✓ interpolation.
"distinct head and eyes facing direction" ✓.
"particle burst when eating" ✓.
"current score and best score of session" ✓ HUD.
Grid ≥20×20: 24×24 ✓ centered ✓.
Now write final HTML. Also title "Snake". Meta viewport.
Let me write the code carefully, ensuring no console errors.
CSS details:
```
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
body{
min-height:100vh; min-height:100dvh;
display:flex; flex-direction:column; align-items:center; justify-content:center;
gap:12px; padding:14px;
background: radial-gradient(1000px 700px at 50% -10%, #16281f 0%, #0b1210 45%, #070b0e 100%);
color:#e2e8f0; font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
overflow:hidden; user-select:none;
}
header{display:flex; align-items:center; justify-content:space-between; width:480px; }
.logo{font-weight:800; letter-spacing:3px; font-size:20px; color:#4ade80; text-shadow:0 0 14px rgba(74,222,128,.5)}
.logo span{...}
.stats{display:flex; gap:10px}
.chip{background:rgba(74,222,128,.07); border:1px solid rgba(74,222,128,.22); border-radius:12px; padding:6px 14px; text-align:center; min-width:86px}
.chip label{display:block; font-size:10px; letter-spacing:2px; color:#7dd3a8; text-transform:uppercase}
.chip b{font-size:22px; font-variant-numeric:tabular-nums; color:#ecfdf5; display:inline-block}
.chip.best{border-color:rgba(251,191,36,.3); background:rgba(251,191,36,.06)}
.chip.best label{color:#fcd34d}
#wrap{position:relative; border-radius:18px;}
canvas{display:block; border-radius:18px; background:#0a1017;
box-shadow:0 0 0 1px rgba(74,222,128,.16), 0 24px 70px rgba(0,0,0,.55), 0 0 46px rgba(74,222,128,.10);}
footer{font-size:12px; color:#64748b; letter-spacing:.4px; text-align:center}
kbd{...}
```
Overlays inside wrap get border-radius 18px.
Demo banner pulse:
```
@keyframes pulse{0%,100%{box-shadow:0 0 0 0 rgba(74,222,128,.35)}50%{box-shadow:0 0 0 8px rgba(74,222,128,0)}}
#demoBanner{position:absolute; left:50%; bottom:14px; transform:translateX(-50%); ... animation:pulse 1.8s infinite; z-index:6; pointer-events:none; white-space:nowrap}
#demoBanner.hidden{display:none}
@media (max-width:420px){ #demoBanner{font-size:10.5px; padding:7px 12px} }
```
Overlay card:
```
.overlay .card{background:rgba(8,14,11,.82); border:1px solid rgba(148,163,184,.15); padding:26px 40px; border-radius:16px; text-align:center; box-shadow:0 18px 50px rgba(0,0,0,.5)}
.overlay h1{font-size:32px; letter-spacing:5px; margin-bottom:10px}
h1.lose{color:#f87171; text-shadow:0 0 22px rgba(248,113,113,.55)}
h1.win{color:#4ade80; text-shadow:0 0 22px rgba(74,222,128,.55)}
.final{font-size:15px; color:#94a3b8; margin-top:2px}
.final b{font-size:44px; color:#4ade80; display:block; font-variant-numeric:tabular-nums}
```
Hmm structure:
```
<div class="overlay" id="overOverlay">
<div class="card">
<h1 id="overTitle" class="lose">GAME OVER</h1>
<div class="final">Score <b id="finalScore">0</b></div>
<div class="final small">Best <span id="finalBest">0</span></div>
<p class="hint">Press <kbd>Space</kbd> / <kbd>Enter</kbd> — or click — to restart</p>
</div>
</div>
```
Pause overlay:
```
<div class="overlay" id="pauseOverlay">
<div class="card">
<h1 class="pause-title">PAUSED</h1>
<p class="hint">Press <kbd>P</kbd> to resume</p>
</div>
</div>
```
pause-title color #e2e8f0.
Now JS full write-up. Also guard: DIRS lookup with e.key.toLowerCase(); for ' ' key lower is ' '. Handle space separately.
Keydown:
```
window.addEventListener('keydown', (e)=>{
const k=e.key;
if(k===' '||k.startsWith('Arrow')) e.preventDefault();
const lk=k.toLowerCase();
if(mode==='demo'){ exitDemo(lk); return; }
if(!alive){
if(k===' '||k==='Enter'){ restart(); }
return;
}
if(lk==='p'){ togglePause(); return; }
const d=DIRS[lk];
if(d) pushDir(d);
});
```
restart(): reset(); overOverlay.classList.remove('show'); (mode stays 'play').
Note: after game over in play mode, pressing direction does nothing (fine).
exitDemo(lk): mode='play'; demoBanner.classList.add('hidden'); reset(); const d=DIRS[lk]; if(d) pushDir(d);
pointerdown on wrap: if(mode==='demo') exitDemo(''); else if(!alive) restart();
Also prevent context menu? not needed.
Touch swipe:
```
let tsx=0,tsy=0;
wrap.addEventListener('touchstart',e=>{const t=e.touches[0];tsx=t.clientX;tsy=t.clientY;},{passive:true});
wrap.addEventListener('touchend',e=>{const t=e.changedTouches[0];const dx=t.clientX-tsx,dy=t.clientY-tsy;
if(Math.abs(dx)<24&&Math.abs(dy)<24)return;
const d=Math.abs(dx)>Math.abs(dy)?{x:Math.sign(dx),y:0}:{x:0,y:Math.sign(dy)};
if(mode==='play'&&alive)pushDir(d);
},{passive:true});
```
resize also sets header/footer width:
```
hud.style.width=boardSize+'px'; foot.style.width=boardSize+'px';
```
Initial call order: define everything, resize(), reset(), rAF.
demoRestartAt init 0.
Also on resize, prevSnake etc unaffected (grid coords), cellPx updates → render uses current cellPx ✓. Particles stored in px — after resize they'd be misplaced slightly; negligible.
One more consideration: `ctx.setTransform(dpr,...)` each resize; render clears with clearRect(0,0,boardSize,boardSize) under transform ✓.
Shake: apply translate before drawing board — but board should shake too? Typically shake whole canvas: translate everything ✓ (we do ctx.save + translate at start, restore at end) — but clearRect before save covers full. Order: clear full, save, translate, draw all, restore. ✓.
Food pulse uses now param.
Let me also add a subtle moving background pattern? Skip, checker fine.
Check alpha when tab hidden long: dt clamped 100ms ✓.
steps<5 guard prevents spiral of death; if !alive break and acc=0 ✓.
Now, score HUD pop on eat: updateHUD(true) called in step when eating ✓.
Best displayed live: best updates only on eat (max with score) — update in step when eating ✓, and in die() for safety.
Write segColor:
```
function segColor(i,n){
const t=n<=1?0:i/(n-1);
return `hsl(${152 - t*28}, ${72 - t*10}%, ${56 - t*20}%)`;
}
```
i=0 head-ish bright (but head drawn separately; segment i=1..n-1 drawn). For i small t small → bright. Tail t=1 → hsl(124,62%,36%) darker teal-green ✓.
Head fill: radial gradient? Use solid '#4ade80' with lighter top? Simple: create radialGradient each frame — fine perf. Or solid + highlight circle. I'll do: fill '#34d399'→ actually head distinct: brighter: '#86efac' with shadow. Let's: head fill = '#5eead4'? Keep green family: head '#4ade80' (brighter than body start hsl(152,72%,56%)≈#3ee089 similar). Make body start darker so head pops: segColor lightness start 48%: `hsl(${150 - t*30}, 70%, ${48 - t*18}%)` → head area 48%, tail 30%. Head circle '#4ade80' (≈ hsl(150,70%,64%)) pops ✓.
Eyes white with dark pupil ✓.
Also draw a tiny darker outline on head? skip.
Food: also draw even in demo ✓.
Now, the demo banner text: "▶ AI demo — click or press any key to play". Use "●" maybe. Fine.
Also add small "DEMO" chip top-left inside board? Banner enough.
Double-check overlay shown only in play mode: die() in demo → no overlay, auto restart ✓. But during demo death, alive=false → keydown would exitDemo and reset — fine.
win():
```
function win(){ alive=false; if(mode==='play'){ overTitle.textContent='YOU WIN!'; overTitle.className='win'; finalScore...; show overlay } else demoRestartAt=... }
```
Also handle food===null in step: willEat false always; snake can still die; fine.
Now assemble final file. Also `<meta name="viewport" content="width=device-width, initial-scale=1">`.
Let me write out everything and mentally test for errors.
Potential bug: In render, prevSnake may be shorter/longer; handled with min index and fallback.
In step(), when demo and aiDir returns null → keep dir. aiDir returns bestD||dir — never null actually (returns dir fallback). Fine.
BFS perf each tick trivial.
One more: `dirQueue` cap 3; pushDir checks last queued. ✓
updateHUD initial call in reset ✓ shows 0/0.
Edge: exitDemo called with lk like 'p' → DIRS['p'] undefined → nothing ✓. With ' ' → nothing ✓. With 'enter' → nothing ✓.
Click restart after game over: restart() also should hide pause overlay & paused=false — reset() sets paused=false; also remove 'show' from pauseOverlay in restart. Add hideOverlays() in restart: both overlays remove show.
Also in exitDemo: hideOverlays().
togglePause also when demo? keydown in demo exits first, so P never pauses demo. Click can't pause. Fine.
But wait: what if mode==='demo' and game not started and user presses P — exits demo, starts play unpaused. OK.
render when paused: alpha=1, board drawn; overlay covers anyway.
Also draw demo banner is DOM, fine.
Let me now also make sure the snake initial prevSnake set so alpha render fine.
Write code now.
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Snake</title>
<style> ... </style>
</head>
<body>
<header id="hud">
<div class="logo">🐍 SNAKE</div>
<div class="stats">
<div class="chip"><label>Score</label><b id="score">0</b></div>
<div class="chip best"><label>Best</label><b id="best">0</b></div>
</div>
</header>
<div id="wrap">
<canvas id="game"></canvas>
<div class="overlay" id="overOverlay">
<div class="card">
<h1 id="overTitle" class="lose">GAME OVER</h1>
<div class="final">Score<b id="finalScore">0</b></div>
<div class="final small">Best <span id="finalBest">0</span></div>
<p class="hint">Press <kbd>Space</kbd> or <kbd>Enter</kbd> — or click — to restart</p>
</div>
</div>
<div class="overlay" id="pauseOverlay">
<div class="card">
<h1 class="ptitle">PAUSED</h1>
<p class="hint">Press <kbd>P</kbd> to resume</p>
</div>
</div>
<div id="demoBanner">▶ AI demo — click or press any key to play</div>
</div>
<footer id="foot">Arrow keys / WASD to move · <kbd>P</kbd> pause · <kbd>Space</kbd> restart</footer>
<script> ... </script>
</body>
</html>
```
CSS full:
```
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
body{
min-height:100vh;min-height:100dvh;
display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;
padding:14px;overflow:hidden;
background:radial-gradient(1100px 750px at 50% -12%, #17291f 0%, #0c1310 46%, #070b0e 100%);
color:#e2e8f0;font-family:system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
user-select:none;-webkit-user-select:none;
}
#hud{display:flex;align-items:center;justify-content:space-between;width:480px}
.logo{font-weight:800;font-size:20px;letter-spacing:3px;color:#4ade80;text-shadow:0 0 16px rgba(74,222,128,.45)}
.stats{display:flex;gap:10px}
.chip{min-width:88px;text-align:center;padding:6px 14px 7px;border-radius:12px;
background:rgba(74,222,128,.07);border:1px solid rgba(74,222,128,.22)}
.chip label{display:block;font-size:10px;letter-spacing:2.5px;text-transform:uppercase;color:#86efac;opacity:.85}
.chip b{display:inline-block;font-size:22px;line-height:1.15;font-variant-numeric:tabular-nums;color:#f0fdf4}
.chip.best{background:rgba(251,191,36,.06);border-color:rgba(251,191,36,.28)}
.chip.best label{color:#fcd34d}
.chip b.pop{animation:pop .28s ease}
@keyframes pop{50%{transform:scale(1.4);color:#4ade80}}
#wrap{position:relative;border-radius:18px}
canvas{display:block;border-radius:18px;background:#0a1017;
box-shadow:0 0 0 1px rgba(74,222,128,.16),0 26px 70px rgba(0,0,0,.55),0 0 48px rgba(74,222,128,.10)}
.overlay{position:absolute;inset:0;z-index:5;display:flex;align-items:center;justify-content:center;
border-radius:18px;background:rgba(4,9,7,.66);backdrop-filter:blur(3px);-webkit-backdrop-filter:blur(3px);
opacity:0;visibility:hidden;transition:opacity .25s ease,visibility .25s ease}
.overlay.show{opacity:1;visibility:visible}
.card{text-align:center;padding:26px 42px;border-radius:16px;background:rgba(8,15,11,.85);
border:1px solid rgba(148,163,184,.16);box-shadow:0 18px 50px rgba(0,0,0,.5)}
.card h1{font-size:32px;letter-spacing:6px;margin-bottom:12px}
h1.lose{color:#f87171;text-shadow:0 0 24px rgba(248,113,113,.55)}
h1.win{color:#4ade80;text-shadow:0 0 24px rgba(74,222,128,.55)}
h1.ptitle{color:#e2e8f0;text-shadow:0 0 24px rgba(226,232,240,.25)}
.final{font-size:13px;letter-spacing:2px;text-transform:uppercase;color:#94a3b8}
.final b{display:block;font-size:46px;color:#4ade80;font-variant-numeric:tabular-nums;margin-top:2px}
.final.small{font-size:12px;margin-top:6px}
.final.small span{color:#fcd34d;font-weight:700}
.hint{margin-top:16px;font-size:13px;color:#94a3b8}
kbd{display:inline-block;padding:1px 7px;border-radius:6px;font-size:11px;font-family:inherit;
background:rgba(148,163,184,.14);border:1px solid rgba(148,163,184,.3);border-bottom-width:2px;color:#e2e8f0}
#demoBanner{position:absolute;left:50%;bottom:14px;transform:translateX(-50%);z-index:6;pointer-events:none;
white-space:nowrap;padding:9px 18px;border-radius:999px;font-size:12.5px;letter-spacing:1px;font-weight:600;
color:#a7f3d0;background:rgba(6,14,10,.82);border:1px solid rgba(74,222,128,.4);
animation:pulse 1.8s ease-in-out infinite}
#demoBanner.hidden{display:none}
@keyframes pulse{0%,100%{box-shadow:0 0 0 0 rgba(74,222,128,.4)}55%{box-shadow:0 0 0 9px rgba(74,222,128,0)}}
#foot{font-size:12px;color:#64748b;letter-spacing:.3px;text-align:center}
@media (max-width:430px){
#demoBanner{font-size:10.5px;padding:8px 13px;letter-spacing:.4px}
.card{padding:20px 26px}
.card h1{font-size:26px}
.chip{min-width:74px;padding:5px 10px 6px}
.chip b{font-size:19px}
.logo{font-size:17px}
}
```
JS:
```
(() => {
'use strict';
const GRID = 24;
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const wrap = document.getElementById('wrap');
const hud = document.getElementById('hud');
const foot = document.getElementById('foot');
const scoreEl = document.getElementById('score');
const bestEl = document.getElementById('best');
const overOverlay = document.getElementById('overOverlay');
const pauseOverlay = document.getElementById('pauseOverlay');
const overTitle = document.getElementById('overTitle');
const finalScore = document.getElementById('finalScore');
const finalBest = document.getElementById('finalBest');
const demoBanner = document.getElementById('demoBanner');
const DIRS = {
arrowup:{x:0,y:-1}, arrowdown:{x:0,y:1}, arrowleft:{x:-1,y:0}, arrowright:{x:1,y:0},
w:{x:0,y:-1}, s:{x:0,y:1}, a:{x:-1,y:0}, d:{x:1,y:0}
};
const ALL_DIRS = [DIRS.arrowup, DIRS.arrowdown, DIRS.arrowleft, DIRS.arrowright];
let boardSize = 480, cellPx = 20;
let snake, prevSnake, dir, dirQueue, food, score, best = 0;
let alive, paused, tickMs, acc;
let particles = [], popups = [];
let mode = 'demo';
let shakeUntil = 0, demoRestartAt = 0;
function resize(){
const availW = window.innerWidth - 28;
const availH = window.innerHeight - 158;
boardSize = Math.max(230, Math.min(640, Math.floor(Math.min(availW, availH))));
const dpr = Math.min(2, window.devicePixelRatio || 1);
canvas.width = Math.round(boardSize * dpr);
canvas.height = Math.round(boardSize * dpr);
canvas.style.width = boardSize + 'px';
canvas.style.height = boardSize + 'px';
wrap.style.width = boardSize + 'px';
wrap.style.height = boardSize + 'px';
hud.style.width = boardSize + 'px';
foot.style.width = boardSize + 'px';
cellPx = boardSize / GRID;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
window.addEventListener('resize', resize);
function reset(){
snake = [{x:8,y:12},{x:7,y:12},{x:6,y:12}];
prevSnake = snake.map(c => ({x:c.x, y:c.y}));
dir = {x:1,y:0};
dirQueue = [];
score = 0; tickMs = 135; acc = 0;
particles = []; popups = [];
alive = true; paused = false; shakeUntil = 0;
placeFood();
updateHUD(false);
}
function placeFood(){
const occ = new Set(snake.map(c => c.x + ',' + c.y));
const empty = [];
for(let x=0;x<GRID;x++) for(let y=0;y<GRID;y++) if(!occ.has(x+','+y)) empty.push({x,y});
food = empty.length ? empty[(Math.random()*empty.length)|0] : null;
}
function pushDir(d){
const last = dirQueue.length ? dirQueue[dirQueue.length-1] : dir;
if(d.x === last.x && d.y === last.y) return;
if(d.x === -last.x && d.y === -last.y) return;
if(dirQueue.length < 3) dirQueue.push(d);
}
function updateHUD(pop){
scoreEl.textContent = score;
bestEl.textContent = best;
if(pop){ scoreEl.classList.remove('pop'); void scoreEl.offsetWidth; scoreEl.classList.add('pop'); }
}
function burst(x, y, palette){
const colors = palette || ['#f87171','#fbbf24','#4ade80','#fef08c','#ffffff'];
for(let i=0;i<18;i++){
const a = Math.random()*Math.PI*2;
const sp = 70 + Math.random()*150;
particles.push({x, y, vx:Math.cos(a)*sp, vy:Math.sin(a)*sp - 40,
life:1, decay:1.4 + Math.random()*1.4, size:2 + Math.random()*3.2, color:colors[(Math.random()*colors.length)|0]});
}
}
function step(){
prevSnake = snake.map(c => ({x:c.x, y:c.y}));
if(mode === 'demo'){
const d = aiDir();
if(d && !(d.x === -dir.x && d.y === -dir.y)) dir = d;
} else if(dirQueue.length){
const d = dirQueue.shift();
if(!(d.x === -dir.x && d.y === -dir.y)) dir = d;
}
const head = snake[0];
const nh = {x: head.x + dir.x, y: head.y + dir.y};
if(nh.x < 0 || nh.y < 0 || nh.x >= GRID || nh.y >= GRID) return die();
const willEat = food && nh.x === food.x && nh.y === food.y;
const body = willEat ? snake : snake.slice(0, -1);
for(const c of body) if(c.x === nh.x && c.y === nh.y) return die();
snake.unshift(nh);
if(willEat){
score += 10;
if(score > best) best = score;
tickMs = Math.max(75, 135 - Math.floor(score/10)*2);
burst((food.x+0.5)*cellPx, (food.y+0.5)*cellPx);
popups.push({x:(food.x+0.5)*cellPx, y:(food.y+0.42)*cellPx, life:1, text:'+10'});
placeFood();
updateHUD(true);
if(!food) return win();
} else {
snake.pop();
}
}
function die(){
alive = false;
shakeUntil = performance.now() + 320;
const h = snake[0];
burst((h.x+0.5)*cellPx, (h.y+0.5)*cellPx, ['#f87171','#fb923c','#ef4444','#fca5a5']);
if(mode === 'play'){
overTitle.textContent = 'GAME OVER';
overTitle.className = 'lose';
finalScore.textContent = score;
finalBest.textContent = best;
overOverlay.classList.add('show');
} else {
demoRestartAt = performance.now() + 1400;
}
}
function win(){
alive = false;
if(mode === 'play'){
overTitle.textContent = 'YOU WIN!';
overTitle.className = 'win';
finalScore.textContent = score;
finalBest.textContent = best;
overOverlay.classList.add('show');
} else {
demoRestartAt = performance.now() + 1400;
}
}
```
aiDir:
```
function aiDir(){
const head = snake[0];
const occ = new Set(snake.slice(0, -1).map(c => c.x + ',' + c.y));
const key = (x,y) => x + ',' + y;
if(food){
const prev = new Map();
const startK = key(head.x, head.y);
prev.set(startK, null);
const q = [[head.x, head.y]];
let found = false;
while(q.length){
const cur = q.shift();
if(cur[0] === food.x && cur[1] === food.y){ found = true; break; }
for(const d of ALL_DIRS){
const nx = cur[0]+d.x, ny = cur[1]+d.y;
if(nx<0||ny<0||nx>=GRID||ny>=GRID) continue;
const k = key(nx,ny);
if(occ.has(k) || prev.has(k)) continue;
prev.set(k, key(cur[0], cur[1]));
q.push([nx, ny]);
}
}
if(found){
let k = key(food.x, food.y);
while(prev.get(k) !== startK && prev.get(k) !== null) k = prev.get(k);
if(prev.get(k) === startK){
const parts = k.split(',');
return {x: Number(parts[0]) - head.x, y: Number(parts[1]) - head.y};
}
}
}
let bestD = null, bestSpace = -1;
for(const d of ALL_DIRS){
if(d.x === -dir.x && d.y === -dir.y) continue;
const nx = head.x + d.x, ny = head.y + d.y;
if(nx<0||ny<0||nx>=GRID||ny>=GRID) continue;
if(occ.has(key(nx,ny))) continue;
const seen = new Set([key(nx,ny)]);
const q = [[nx,ny]];
let cnt = 0;
while(q.length && cnt < 120){
const cur = q.shift(); cnt++;
for(const dd of ALL_DIRS){
const mx = cur[0]+dd.x, my = cur[1]+dd.y;
if(mx<0||my<0||mx>=GRID||my>=GRID) continue;
const mk = key(mx,my);
if(occ.has(mk) || seen.has(mk)) continue;
seen.add(mk); q.push([mx,my]);
}
}
if(cnt > bestSpace){ bestSpace = cnt; bestD = d; }
}
return bestD || dir;
}
```
Note BFS while loop: `while(prev.get(k) !== startK && prev.get(k) !== null) k = prev.get(k);` — if chain broken, prev.get(k) becomes null and k=null → next iteration prev.get(null)... wait condition checks prev.get(k)!==null first? Order: `prev.get(k) !== startK && prev.get(k) !== null` — if prev.get(k)===null, second condition false → loop stops with k being head? Hmm: walking from food: k=food key; prev.get(k) is parent; loop while parent isn't startK and isn't null. When k reaches the cell adjacent to head, prev.get(k)===startK → stop, k = that cell ✓. If food is head-adjacent, prev.get(food)===startK immediately → k stays food key ✓ correct (first step = food cell). Good. The null case: found=true guarantees chain to start (BFS from head), so fine. But guard anyway: if k becomes null... can't since found. OK.
Render:
```
function render(alpha, now){
ctx.clearRect(0, 0, boardSize, boardSize);
ctx.save();
if(now < shakeUntil){
const m = (shakeUntil - now) / 320 * 6;
ctx.translate((Math.random()*2-1)*m, (Math.random()*2-1)*m);
}
// board
for(let y=0;y<GRID;y++){
for(let x=0;x<GRID;x++){
ctx.fillStyle = (x+y)%2 ? '#0c141d' : '#0f1822';
ctx.fillRect(x*cellPx, y*cellPx, cellPx+0.5, cellPx+0.5);
}
}
drawFood(now);
drawSnake(alpha);
drawParticles();
drawPopups();
// frame
ctx.strokeStyle = 'rgba(74,222,128,0.20)';
ctx.lineWidth = 2;
ctx.strokeRect(1, 1, boardSize-2, boardSize-2);
ctx.restore();
}
```
fillRect with +0.5 to avoid seams.
drawFood:
```
function drawFood(now){
if(!food) return;
const cx = (food.x+0.5)*cellPx, cy = (food.y+0.5)*cellPx;
const r = cellPx*0.30*(1 + 0.12*Math.sin(now/180));
ctx.save();
ctx.shadowColor = '#fb7185'; ctx.shadowBlur = 16;
ctx.fillStyle = '#f43f5e';
ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI*2); ctx.fill();
ctx.shadowBlur = 0;
ctx.fillStyle = 'rgba(255,255,255,0.55)';
ctx.beginPath(); ctx.arc(cx - r*0.32, cy - r*0.38, r*0.26, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#4ade80';
ctx.beginPath();
ctx.ellipse(cx + r*0.18, cy - r*0.95, r*0.34, r*0.16, -0.5, 0, Math.PI*2);
ctx.fill();
ctx.restore();
}
```
drawSnake:
```
function drawSnake(alpha){
const n = snake.length;
const pts = new Array(n);
for(let i=0;i<n;i++){
const c = snake[i];
const p = prevSnake[Math.min(i, prevSnake.length-1)] || c;
pts[i] = { x:(p.x + (c.x-p.x)*alpha + 0.5)*cellPx, y:(p.y + (c.y-p.y)*alpha + 0.5)*cellPx };
}
ctx.save();
ctx.lineCap = 'round'; ctx.lineJoin = 'round';
ctx.shadowColor = 'rgba(74,222,128,0.35)';
ctx.shadowBlur = 8;
for(let i=n-1;i>0;i--){
const t = n<=1 ? 0 : i/(n-1);
ctx.strokeStyle = `hsl(${150 - t*30}, 70%, ${48 - t*18}%)`;
ctx.lineWidth = cellPx*(0.80 - 0.30*t);
ctx.beginPath();
ctx.moveTo(pts[i].x, pts[i].y);
ctx.lineTo(pts[i-1].x, pts[i-1].y);
ctx.stroke();
}
const h = pts[0];
ctx.shadowColor = 'rgba(74,222,128,0.7)';
ctx.shadowBlur = 18;
ctx.fillStyle = '#4ade80';
ctx.beginPath(); ctx.arc(h.x, h.y, cellPx*0.52, 0, Math.PI*2); ctx.fill();
ctx.shadowBlur = 0;
// eyes
const d = dir, perp = {x:-d.y, y:d.x};
const f = cellPx*0.18, s = cellPx*0.21;
const er = cellPx*0.14, pr = cellPx*0.07;
for(const side of [-1, 1]){
const ex = h.x + d.x*f + perp.x*s*side;
const ey = h.y + d.y*f + perp.y*s*side;
ctx.fillStyle = '#ffffff';
ctx.beginPath(); ctx.arc(ex, ey, er, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#0f172a';
ctx.beginPath(); ctx.arc(ex + d.x*er*0.5, ey + d.y*er*0.5, pr, 0, Math.PI*2); ctx.fill();
}
ctx.restore();
}
```
drawParticles / drawPopups:
```
function drawParticles(){
if(!particles.length) return;
ctx.save();
for(const p of particles){
ctx.globalAlpha = Math.max(0, p.life);
ctx.fillStyle = p.color;
ctx.beginPath(); ctx.arc(p.x, p.y, Math.max(0.5, p.size*p.life), 0, Math.PI*2); ctx.fill();
}
ctx.restore();
}
function drawPopups(){
if(!popups.length) return;
ctx.save();
ctx.textAlign = 'center';
ctx.font = `700 ${Math.round(cellPx*0.6)}px system-ui, sans-serif`;
for(const p of popups){
ctx.globalAlpha = Math.max(0, p.life);
ctx.fillStyle = '#fef08c';
ctx.fillText(p.text, p.x, p.y);
}
ctx.restore();
}
function updateParticles(s){
for(let i=particles.length-1;i>=0;i--){
const p = particles[i];
p.life -= p.decay*s;
if(p.life <= 0){ particles.splice(i,1); continue; }
p.x += p.vx*s; p.y += p.vy*s;
p.vy += 160*s;
p.vx *= (1 - 2.4*s); p.vy *= (1 - 2.4*s);
}
}
function updatePopups(s){
for(let i=popups.length-1;i>=0;i--){
const p = popups[i];
p.life -= s*1.1; p.y -= 34*s;
if(p.life <= 0) popups.splice(i,1);
}
}
```
Frame loop:
```
let lastTime = performance.now();
function frame(now){
requestAnimationFrame(frame);
let dt = now - lastTime; lastTime = now;
if(dt > 100) dt = 100;
if(mode === 'demo' && !alive && now > demoRestartAt) reset();
if(!paused && alive){
acc += dt;
let guard = 0;
while(acc >= tickMs && guard < 6){
acc -= tickMs; guard++;
step();
if(!alive){ acc = 0; break; }
}
}
if(!paused){ updateParticles(dt/1000); updatePopups(dt/1000); }
const alpha = (!paused && alive) ? Math.min(1, acc/tickMs) : 1;
render(alpha, now);
}
```
Input:
```
function hideOverlays(){ overOverlay.classList.remove('show'); pauseOverlay.classList.remove('show'); }
function restart(){ reset(); hideOverlays(); }
function exitDemo(lk){
mode = 'play';
demoBanner.classList.add('hidden');
reset();
hideOverlays();
const d = DIRS[lk];
if(d) pushDir(d);
}
window.addEventListener('keydown', (e)=>{
const k = e.key;
if(k === ' ' || k.indexOf('Arrow') === 0) e.preventDefault();
const lk = k.toLowerCase();
if(mode === 'demo'){ exitDemo(lk); return; }
if(!alive){
if(k === ' ' || k === 'Enter') restart();
return;
}
if(lk === 'p'){ paused = !paused; pauseOverlay.classList.toggle('show', paused); return; }
const d = DIRS[lk];
if(d) pushDir(d);
});
wrap.addEventListener('pointerdown', ()=>{
if(mode === 'demo') exitDemo('');
else if(!alive) restart();
});
let tsx=0, tsy=0;
wrap.addEventListener('touchstart', e=>{ const t=e.touches[0]; tsx=t.clientX; tsy=t.clientY; }, {passive:true});
wrap.addEventListener('touchend', e=>{
const t=e.changedTouches[0];
const dx=t.clientX-tsx, dy=t.clientY-tsy;
if(Math.max(Math.abs(dx),Math.abs(dy)) < 24) return;
const d = Math.abs(dx) > Math.abs(dy) ? {x:Math.sign(dx),y:0} : {x:0,y:Math.sign(dy)};
if(mode==='play' && alive && !paused) pushDir(d);
}, {passive:true});
```
Init:
```
resize();
reset();
requestAnimationFrame(frame);
```
Wait: reset() sets mode-independent stuff; mode='demo' initial — declared with let mode='demo' before reset called ✓ (declaration order in IIFE: variables declared at top ✓).
One issue: in demo mode, keydown 'p' exits demo (start play) — acceptable.
Another: after exitDemo via click (lk=''), DIRS[''] undefined ✓.
Also when game over in play mode and user presses 'p' — handler: !alive branch returns before p check ✓ good.
Space during play (alive): falls to DIRS[' '] undefined → nothing ✓.
Check: `k.indexOf('Arrow')===0` prevents default for arrows ✓.
HUD widths: hud/foot width set in resize ✓ initial CSS 480 fallback.
Now check availH at 800: 800-158=642 → boardSize=min(1252,642)=642→ floor 642, cap 640 → 640. Layout height: padding 14*2 + gap 12*2 + hud (~48) + wrap 640 + foot (~17) = 28+24+48+640+17 = 757 < 800 ✓ centered.
At 375×667: availW=347, availH=509 → 347. Height: 28+24+~44+347+17=460 ✓.
At very short viewport e.g. 1280×500: availH=342 → board 342, total ≈ 28+24+48+342+17=459 <500 ✓.
Edge: extremely small height → boardSize min 230 could overflow → overflow hidden, acceptable.
Font in canvas popup uses cellPx — fine.
Check step() when demo and aiDir returns dir unchanged etc ✓.
Potential visual bug: interpolation when snake length 1? Never (starts 3).
prevSnake when reset: set ✓.
The head eyes use current `dir` — during the tick after turn, head moves in new dir; eyes match ✓.
One more polish: draw a subtle glow under food? shadowBlur done.
Also maybe add slight vignette inside canvas: after board, radial gradient overlay? Skip.
Check "best score of the session" — in-memory best resets on page reload; session = page session ✓ (localStorage blocked anyway).
Demo AI quality: BFS to food avoiding body — snake will grow and eventually maybe trap itself; fallback flood-fill helps. If it dies, auto restart after 1.4s — during screenshot at 3s, likely alive and moving ✓. Actually maybe make demo less likely to die quickly: it starts length 3, BFS safe-ish. Fine.
Also ensure demo doesn't die instantly: aiDir avoids occupied and walls ✓.
Now, one subtle thing: in demo mode, step() uses aiDir which references `dir` global for opposite filter ✓.
The `guard<6` while loop: if tickMs small and tab slow, fine.
particles update uses dt/1000 seconds ✓ decay ~1.4-2.8/s → life ~0.4-0.7s ✓.
Popup life decay 1.1/s → ~0.9s ✓.
Now the overlay in demo mode never shows ✓. But what if demo snake dies exactly at screenshot? restart at +1.4s; screenshot at 3s — snake alive from start (t=0), first possible death maybe after several seconds; fine.
Also initial render before first rAF: rAF fires immediately, fine. First paint: canvas drawn on first frame ✓ (within 3s screenshot).
Emoji 🐍 in logo — fine.
Check no console errors: all elements exist; DIRS lookups safe; `prevSnake[Math.min(...)] || c` — prevSnake non-empty always ✓.
`overTitle.className = 'lose'` replaces classes — h1 has id, class set; CSS h1.lose ✓. Initially class="lose" in HTML ✓.
backdrop-filter in sandbox fine.
One more requirement: "centered on the page" — flex centering ✓.
"Adapt gracefully down to 375px" ✓.
Also ensure canvas doesn't exceed viewport causing scroll — overflow hidden on body ✓ and sizes computed from viewport ✓.
Let me also double check the queue honoring scenario: dir=right. Press ArrowUp then ArrowLeft quickly (within one tick). Queue: [up] then left: last=up, left not opposite/same → queue [up,left]. Tick1: shift up; up vs dir right: not opposite → dir=up ✓. Tick2: shift left; left vs up: not opposite → dir=left ✓. Both honored, no reversal ✓.
Scenario: dir=right, press left then up quickly: left rejected (opposite of right) ✓; up queued ✓.
Scenario: dir=up, press left then down quickly: left queued (vs up ok); down: last=left, down opposite of left → rejected ✓. Good — prevents the classic double-press reversal death.
Now, tickMs decrease: score/10*2 → at 10 foods (100 pts) tick 115; min 75 at 30 foods. Fine.
Also add: when eating, maybe tiny slow-mo? skip.
Write final answer as single html code block. Also ensure no text outside block.
Let me also reconsider header height contribution: hud height ~ chip: label 10px + b 22px + padding 13 ≈ 48px. gap 12 between three children → 2 gaps = 24. padding 28. foot 17. Sum non-canvas = 28+24+48+17 = 117. availH = innerHeight - 158 leaves 41px slack ✓ good.
Actually gap applies between header, wrap, footer: 2 gaps × 12 = 24 ✓ included.
One more: `min-height:100dvh` with fallback 100vh ✓.
Also add `touch-action: none` on wrap to prevent scroll on swipe? body overflow hidden anyway; add `touch-action:none` to canvas/wrap for safety.
Also `-webkit-tap-highlight-color: transparent`.
I think ready. Also include `aria` bits? Not necessary.
Double-check the BFS `prev.get(k) !== startK && prev.get(k) !== null` loop — if food unreachable found=false skip. If found, chain exists. But note: BFS marks start with null; when walking back, the loop condition uses prev.get(k); initial k=food key; prev.get(food) is its parent (not null unless food==start, impossible). Terminates when parent is startK ✓.
Edge: what if food adjacent to head diagonally? First step is orthogonal neighbor on path ✓.
Also AI could choose direction into cell that is tail (allowed since occ excludes tail) — if not eating, tail vacates ✓; if eating this tick, tail stays — could collide. Rare; acceptable for demo. Actually could cause demo death occasionally → auto restart, fine.
Final check on `render` clearRect under transform: clearRect(0,0,boardSize,boardSize) with transform dpr scales → clears full canvas ✓.
Write it out.